├── MANIFEST.in ├── xban ├── files │ ├── xBanBG.png │ ├── xBanUI.icns │ ├── xbanUI.ico │ ├── xbanUI.png │ └── xBanStyle.css ├── __init__.py ├── xban.py ├── style.py ├── io.py ├── mainwindow.py ├── utils.py └── board.py ├── requirements ├── tox.ini ├── setup.py ├── tests ├── test_style.py └── test_io.py ├── LICENSE ├── .gitignore ├── README.md └── CHANGELOG.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include xban/files/* 2 | -------------------------------------------------------------------------------- /xban/files/xBanBG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhys/xBan/HEAD/xban/files/xBanBG.png -------------------------------------------------------------------------------- /xban/files/xBanUI.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhys/xBan/HEAD/xban/files/xBanUI.icns -------------------------------------------------------------------------------- /xban/files/xbanUI.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhys/xBan/HEAD/xban/files/xbanUI.ico -------------------------------------------------------------------------------- /xban/files/xbanUI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhys/xBan/HEAD/xban/files/xbanUI.png -------------------------------------------------------------------------------- /requirements: -------------------------------------------------------------------------------- 1 | # requirements for xban development 2 | 3 | PySide6 4 | pyyaml>=5.3.0 5 | Click>=7.1.0 6 | 7 | # testing tool 8 | 9 | tox 10 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36 3 | 4 | [testenv] 5 | deps = 6 | pytest 7 | -r{toxinidir}/requirements 8 | commands = 9 | pytest 10 | -------------------------------------------------------------------------------- /xban/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | root_logger = logging.getLogger() 5 | 6 | handler = logging.StreamHandler(sys.stdout) 7 | formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s") 8 | handler.setFormatter(formatter) 9 | root_logger.addHandler(handler) 10 | 11 | 12 | def handle_exception(exc_type, exc_value, exc_traceback): 13 | """Track uncaught exception to debug mode""" 14 | root_logger.error( 15 | "Exception Occurred\n", exc_info=(exc_type, exc_value, exc_traceback) 16 | ) 17 | 18 | 19 | sys.excepthook = handle_exception 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | import setuptools 6 | 7 | with open("README.md", "r") as fh: 8 | long_description = fh.read() 9 | 10 | setuptools.setup( 11 | name="xBan", 12 | version="0.3.0", 13 | author="Peter Sun", 14 | author_email="peterhs73@outlook.com", 15 | description="Offline personal kanban work-flow", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | url="https://github.com/pterhs73/xBan", 19 | packages=["xban"], 20 | license="BSD", 21 | classifiers=[ 22 | "Programming Language :: Python :: 3", 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: OS Independent", 25 | ], 26 | install_requires=["pyyaml>=5.0", "Click", "PySide6"], 27 | entry_points=""" 28 | [console_scripts] 29 | xban=xban.xban:cli 30 | """, 31 | python_requires=">=3.6", 32 | include_package_data=True, 33 | ) 34 | -------------------------------------------------------------------------------- /tests/test_style.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | """Test the xban tile style are correctly formatted""" 6 | 7 | from xban.style import TILE_STYLE 8 | 9 | 10 | BLACK_SYTLE = """ 11 | QListView::item { 12 | background-color: #D9D7D7; 13 | color: black; 14 | font-size: 15px; font-weight: bold; 15 | border: 1px solid #414141; 16 | border-radius: 4px; 17 | margin-top: 3px; 18 | margin-bottom: 3px; 19 | margin-right: 3px; 20 | padding: 6px 10px 6px 10px; 21 | } 22 | QListView QTextEdit { 23 | color: black; 24 | background : #D9D7D7; 25 | border-style: none; 26 | } 27 | QListView::item:selected { 28 | color: black; 29 | border: 2px solid black; 30 | } 31 | QListView QTextEdit QScrollBar:vertical { 32 | color: #D9D7D7; 33 | background: #D9D7D7; 34 | } 35 | QListView QTextEdit QScrollBar::handle:vertical { 36 | background:black; 37 | } 38 | """ 39 | 40 | 41 | def test_color_format(): 42 | 43 | assert TILE_STYLE["black"] == BLACK_SYTLE 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2021, Peter Sun 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | src/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Mac 100 | .DS_Store 101 | 102 | # Visual Studio 103 | .vscode 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xBan project [![Current Version](https://img.shields.io/badge/version-0.3.0-green.svg)](https://github.com/peterhs73/xBan/releases) [![](https://img.shields.io/badge/python-3.6+-blue.svg)](https://www.python.org/downloads/) [![License](https://img.shields.io/badge/License-BSD%202--Clause-orange.svg)](https://opensource.org/licenses/BSD-2-Clause) 2 | 3 | xBan offers a completely offline kanban work-flow. It allows edits, drag and drop tiles and boards, and saves as yaml format. All operations are done locally. xBan can also convert valid dictionary formatted yaml file into a kanban board. 4 | 5 | ![xBan GUI](https://media.giphy.com/media/4IAFWoA2C6HKPNb4xg/giphy.gif) 6 | 7 | Some of the color schemes are inspired by Quip. 8 | 9 | ## Quickstart 10 | 11 | To install xBan directly from github release (version 0.3.0): 12 | 13 | python -m pip install git+https://github.com/peterhs73/xBan.git@v0.3.0#egg=xban 14 | 15 | To create/render xban (or valid yaml) file: 16 | 17 | xban FILEPATH 18 | 19 | To turn on debug mode: 20 | 21 | xban -d FIELPATH 22 | 23 | ### Development 24 | 25 | Clone xBan to local: 26 | 27 | git clone https://github.com/peterhs73/xBan.git 28 | 29 | Under the xBan directory, run installation in edit mode: 30 | 31 | pip install -e . 32 | 33 | Run the tests: 34 | 35 | tox 36 | 37 | 38 | ## Features 39 | 40 | A xBan project consists of a title section, a description section and an individual board. Each board has a title and its own tiles. Selected features: 41 | 42 | - Drag and drop tiles and boards 43 | - Add/delete tiles and boards 44 | - Change tile color 45 | 46 | 47 | ## References 48 | 49 | - [Change Log](https://github.com/peterhs73/xBan/blob/master/CHANGELOG.md) 50 | - [Releases](https://github.com/peterhs73/xBan/releases) 51 | -------------------------------------------------------------------------------- /xban/xban.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import click 6 | import logging 7 | from xban.mainwindow import main_app 8 | from xban.io import process_yaml 9 | 10 | 11 | cli_logger = logging.getLogger("xban-cli") 12 | 13 | # MODUEL PATH 14 | BASE_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files") 15 | 16 | 17 | """The command line interface, the handler is called from setup.py 18 | """ 19 | 20 | @click.command() 21 | @click.option( 22 | "-d/ ", "--debug", is_flag=True, default=False, help="Toggle debug mode" 23 | ) 24 | @click.argument("filepath", type=click.Path(resolve_path=True)) 25 | def cli(debug, filepath): 26 | 27 | """FILEPATH should be a valid filepath with correct extension 28 | 29 | xBan renders if the input file is a valid format, 30 | or asks to create a new file if does not exist 31 | """ 32 | 33 | root_logger = logging.getLogger() 34 | if debug: 35 | click.echo("Debug mode on") 36 | root_logger.setLevel(logging.DEBUG) 37 | else: 38 | root_logger.setLevel(logging.INFO) 39 | 40 | # check filepath 41 | 42 | file_dir, filename = os.path.split(filepath) 43 | if os.path.isfile(filepath): 44 | file_config = process_yaml(filepath) 45 | 46 | if file_config: 47 | main_app(BASE_PATH, filepath, file_config) 48 | else: 49 | cli_logger.error(f'{file_dir} is not a valid ymal file') 50 | 51 | elif not file_dir: 52 | cli_logger.error(f"directory {file_dir} does not exist") 53 | 54 | # create new file if does not exist 55 | elif click.confirm(f'{filepath} does not exist, create?'): 56 | 57 | with open(filepath, "w+"): 58 | pass 59 | file_config = process_yaml(filepath) 60 | main_app(BASE_PATH, filepath, file_config) 61 | -------------------------------------------------------------------------------- /xban/style.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """The script outlines the style sheet for tile colors""" 5 | 6 | 7 | COLOR_DICT = { 8 | "black": {"bgcolor": "#D9D7D7", "color": "black", "bcolor": "#414141"}, 9 | "teal": {"bgcolor": "#c2eaf0", "color": "#426A70", "bcolor": "#6b8485"}, 10 | "blue": {"bgcolor": "#eaf8fe", "color": "#29ade8", "bcolor": "#c6ebfb"}, 11 | "green": {"bgcolor": "#bafce2", "color": "#00c678", "bcolor": "#2ce89e"}, 12 | "purple": {"bgcolor": "#fbdbff", "color": "#d30bea", "bcolor": "#fb87ff"}, 13 | "red": {"bgcolor": "#ffecee", "color": "#fe3a51", "bcolor": "#fec9d0"}, 14 | "yellow": {"bgcolor": "#fef8e7", "color": "#ff9900", "bcolor": "#f2ce98"}, 15 | "brown": {"bgcolor": "#e3c099", "color": "#795644", "bcolor": "#a1785c"}, 16 | } 17 | 18 | 19 | WIDGET_FORMAT = """ 20 | QListView::item {{ 21 | background-color: {bgcolor}; 22 | color: {color}; 23 | font-size: 15px; font-weight: bold; 24 | border: 1px solid {bcolor}; 25 | border-radius: 4px; 26 | margin-top: 3px; 27 | margin-bottom: 3px; 28 | margin-right: 3px; 29 | padding: 6px 10px 6px 10px; 30 | }} 31 | QListView QTextEdit {{ 32 | color: {color}; 33 | background : {bgcolor}; 34 | border-style: none; 35 | }} 36 | QListView::item:selected {{ 37 | color: {color}; 38 | border: 2px solid {color}; 39 | }} 40 | QListView QTextEdit QScrollBar:vertical {{ 41 | color: {bgcolor}; 42 | background: {bgcolor}; 43 | }} 44 | QListView QTextEdit QScrollBar::handle:vertical {{ 45 | background:{color}; 46 | }} 47 | """ 48 | 49 | MENU_FORMAT = """ 50 | QLabel {{ 51 | background:{bgcolor}; 52 | color:{color}; 53 | border: 1px solid {bcolor}; 54 | padding: 5 10 5 10px; 55 | margin: 3 5 3 5px; 56 | border-radius: 2px; 57 | }} 58 | QLabel:hover {{ 59 | background:#c2c2c2; 60 | }} 61 | """ 62 | 63 | 64 | # The following code is for widget background and currently that is not added 65 | # BG_FORMAT = """background-image: url('{}') 100px 100px stretch stretch; 66 | # background-repeat: no-repeat; background-position: center center; 67 | # """ 68 | 69 | """Tile style is the color style sheet for the tiles and menu style is 70 | the color style sheet for the drop down menu""" 71 | 72 | TILE_STYLE = {} 73 | MENU_STYLE = {} 74 | 75 | for color_name, setting in COLOR_DICT.items(): 76 | TILE_STYLE[color_name] = WIDGET_FORMAT.format(**setting) 77 | MENU_STYLE[color_name] = MENU_FORMAT.format(**setting) 78 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.3.0] - 2021-08-10 8 | ### Changed 9 | - Change dependency from Qt5 to Qt6 (require PySide6) 10 | - Simplify command line interface, the command should be `xban FILEPATH` 11 | 12 | ### Fixed 13 | - Fix incorrect test file 14 | 15 | ## [0.2.1] - 2021-03-22 16 | ### Added 17 | - Add the qmainwindow for the board widget 18 | - Add save button 19 | - Add statusbar that displays log message (currently saving event) 20 | - Add brown color 21 | 22 | ### Fixed 23 | - Fix the menu button hovering issue: when the button is pressed, the unhover event is not triggered. 24 | The hovered color persists after the button clicks. This is resolved by modifying the button press 25 | event to change the background color in the button behavior. I think this is a bug in the qt end. 26 | The hover behavior is still defined in css style file since it is easier to identify different buttons. 27 | - Set a minimum width of the button. 28 | - Fix issue where multiple tiles are selected across the boards 29 | - Fix issue where after dropping item no longer editable 30 | 31 | ### Changed 32 | - Use class object name for better child style sheet settings 33 | - Separate GUI to the mainwindow session and the board session for better readability 34 | - Simplify color pull-down menu 35 | - Add background color when pressing btn and consistent across the application 36 | - Change color menu to match the context button size 37 | - Change color menu to reflect the proper color of the option 38 | 39 | ## [0.2.0] - 2021-02-08 40 | ### Changed 41 | - Change command line message when files already exist 42 | - Change QListWidget editor to QTextEditor using delegates 43 | - Change the default background-color 44 | - Change the board buttons, the delete button is now separate 45 | 46 | ### Fixed 47 | - Fix the issue that the tile item is cropped 48 | - Fix the scrollbar color and padding 49 | - Fix the setup files to include non-python file in the installation 50 | - Fix the wrong package name on `setup.py` (smh) 51 | - Fix package namespace issue in `setup.py` 52 | 53 | ### Added 54 | - Add more information on the README 55 | - Add save logging 56 | - Add shadow to tile board 57 | - Add button tooltip 58 | - Add `MANIFEST.in` for packaging 59 | 60 | ## [0.1.0] - 2021-02-07 61 | ### Added 62 | - Add "black" and "teal" color 63 | - Add logging 64 | 65 | ### Changed 66 | - Rewrite the internal, now individual tiles are part of `QListWidget` 67 | - Change xban files format to `yaml` 68 | - Change interface from `QMainWindow` to command-line + `QWidget` 69 | 70 | ### Fixed 71 | - Fix code formating -------------------------------------------------------------------------------- /xban/io.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import yaml 5 | import os 6 | import logging 7 | from xban.style import TILE_STYLE 8 | import random 9 | 10 | """Interaction with yaml files""" 11 | 12 | io_logger = logging.getLogger("xban-io") 13 | 14 | 15 | def xban_content(filepath, yaml_stream): 16 | """Check and correct yaml_stream into the correct xban format 17 | 18 | this function is used within process_yaml function 19 | There are several scenarios: 20 | - File is empty (directly passed from the xban create) 21 | - File is a yaml file but is not valid xban yaml 22 | - File is a valid xban yaml file but not a xban file 23 | - File is a xban file 24 | :param filepath str: yaml filepath 25 | :param yaml_stream: yaml read stream 26 | """ 27 | filename = os.path.basename(os.path.splitext(filepath)[0]) 28 | xban_config_default = { 29 | "xban_config": { 30 | "title": filename, 31 | "description": "", 32 | "board_color": [], 33 | } 34 | } 35 | if yaml_stream: 36 | if not all(isinstance(page, dict) for page in yaml_stream): 37 | io_logger.error(f"{filepath} does not have a valid xban format") 38 | return [] 39 | elif "xban_config" in yaml_stream[0]: 40 | return yaml_stream 41 | elif len(yaml_stream) >= 2: 42 | io_logger.error(f"{filepath} have too many yaml documents") 43 | return [] 44 | else: 45 | # if the yaml file does not have the configuration 46 | # check the length and add the color 47 | content_len = len(yaml_stream[0]) 48 | color = random.sample(TILE_STYLE.keys(), content_len) 49 | xban_config_default["xban_config"]["board_color"].extend(color) 50 | return [xban_config_default, yaml_stream[0]] 51 | else: 52 | return [xban_config_default, {}] 53 | 54 | 55 | def process_yaml(filepath): 56 | """Process yaml file 57 | 58 | if the file cannot be opened, an error will be logged 59 | the detailed file processing see xban_content() 60 | """ 61 | try: 62 | with open(filepath, "r") as f: 63 | yaml_stream = list(yaml.load_all(f, Loader=yaml.SafeLoader)) 64 | 65 | return xban_content(filepath, yaml_stream) 66 | except Exception as e: 67 | io_logger.error(f"Incorrect {filepath}. Error: {str(e)}") 68 | return [] 69 | 70 | 71 | def save_yaml(filepath, xban_content): 72 | """Save the xban configuration to yaml format""" 73 | try: 74 | with open(filepath, "w+") as f: 75 | yaml.safe_dump_all( 76 | xban_content, f, default_flow_style=False, sort_keys=False 77 | ) 78 | except Exception as e: 79 | io_logger.error(f"Cannot save {filepath}. Error: {str(e)}") 80 | -------------------------------------------------------------------------------- /xban/mainwindow.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """The mainwindow GUI of xban""" 5 | 6 | import sys 7 | import os 8 | from functools import partial 9 | from PySide6.QtCore import Qt 10 | from PySide6.QtGui import QIcon, QColor, QScreen, QGuiApplication 11 | from PySide6.QtWidgets import ( 12 | QMainWindow, 13 | QScrollArea, 14 | QStatusBar, 15 | QApplication, 16 | QStyleFactory, 17 | QGraphicsDropShadowEffect, 18 | ) 19 | import logging 20 | from xban.board import BanBoard 21 | from xban.utils import BanButton, QLogHandler 22 | 23 | 24 | main_logger = logging.getLogger("xban-main") 25 | 26 | 27 | class xBanWindow(QMainWindow): 28 | """The main window of xban 29 | 30 | The main window serves three major purposes: 31 | - statusbar (sand the save button) 32 | - scrollable area 33 | """ 34 | 35 | def __init__(self, base_path, file, file_config, parent=None): 36 | super().__init__(parent) 37 | 38 | board = BanBoard(file, file_config) 39 | board_area = QScrollArea() 40 | board_area.setWidget(board) 41 | board_area.setWidgetResizable(True) 42 | self.setCentralWidget(board_area) 43 | 44 | self.stbar = QStatusBar() 45 | 46 | # add a save button at the right bottom corner 47 | save_btn = BanButton( 48 | "save", 49 | objectName="appBtn_save", 50 | toolTip="save xban file", 51 | shortcut="Ctrl+S", 52 | ) 53 | 54 | shadow = QGraphicsDropShadowEffect( 55 | self, blurRadius=10, offset=5, color=QColor("lightgrey") 56 | ) 57 | save_btn.setGraphicsEffect(shadow) 58 | save_btn.pressed.connect(board.save_board) 59 | 60 | self.stbar.addPermanentWidget(save_btn) 61 | self.setStatusBar(self.stbar) 62 | log_handler = QLogHandler(self) 63 | root_logger = logging.getLogger() 64 | root_logger.addHandler(log_handler) 65 | log_handler.signal.log_msg.connect( 66 | partial(self.stbar.showMessage, timeout=1500) 67 | ) 68 | self.stbar.showMessage(f"Initiate {file}", 1500) 69 | self.show() 70 | 71 | def closeEvent(self, event): 72 | """Auto save when close""" 73 | 74 | self.centralWidget().widget().save_board() 75 | super().closeEvent(event) 76 | 77 | 78 | def main_app(base_path, file, file_config): 79 | """Run the GUI of xBan 80 | 81 | The function initiates and resize the application 82 | """ 83 | app = QApplication(sys.argv) 84 | 85 | if hasattr(QStyleFactory, "AA_UseHighDpiPixmaps"): 86 | app.setAttribute(Qt.AA_UseHighDpiPixmaps) 87 | 88 | with open(os.path.join(base_path, "xBanStyle.css"), "r") as style_sheet: 89 | style = style_sheet.read() 90 | 91 | app.setWindowIcon(QIcon(os.path.join(base_path, "xBanUI.png"))) 92 | xBanApp = xBanWindow(base_path, file, file_config) 93 | 94 | xBanApp.setStyleSheet(style) 95 | 96 | # resize and move screen to center 97 | 98 | primary_screen = QGuiApplication.primaryScreen() 99 | if primary_screen: 100 | screen_size = primary_screen.availableSize() 101 | 102 | xBanApp.resize(screen_size.width() / 3, screen_size.height() / 2) 103 | xBanApp.move( 104 | (screen_size.width() - xBanApp.width()) / 2, 105 | (screen_size.height() - xBanApp.height()) / 2, 106 | ) 107 | 108 | app.setStyle("Fusion") 109 | sys.exit(app.exec_()) 110 | 111 | else: 112 | main_logger.error("Primary screen not found") 113 | -------------------------------------------------------------------------------- /xban/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | """This script host some customized utilities""" 6 | 7 | 8 | from PySide6.QtWidgets import QPushButton 9 | from PySide6.QtCore import QEvent 10 | import logging 11 | from PySide6.QtCore import Signal, QThread, QSize 12 | 13 | 14 | class BanButton(QPushButton): 15 | """Custom pushbutton to adjust hover event""" 16 | 17 | sizeSig = Signal(QSize) 18 | 19 | def __init__(self, *args, **kwargs): 20 | """Assign hover color and event filter 21 | 22 | The default color is grey with white background 23 | 24 | :param color list of tuples: first tuple is the hover colors and 25 | second is the press colors. 26 | The behavior is after pressed the color return to before. 27 | """ 28 | 29 | hover, press = kwargs.pop("color", []) or [ 30 | ("white", "#bdbdbd"), 31 | ("white", "grey"), 32 | ] 33 | default_color = kwargs.pop("default_color", []) or ("grey", "white") 34 | style_format = "QPushButton {{color: {}; background-color: {};}}" 35 | self.default_c = style_format.format(*default_color) 36 | self.hover_c = style_format.format(*hover) 37 | self.press_c = style_format.format(*press) 38 | 39 | super().__init__(*args, **kwargs) 40 | self.installEventFilter(self) 41 | self.setStyleSheet(self.default_c) 42 | 43 | def eventFilter(self, object, event): 44 | """Workaround for hover event painting 45 | 46 | This is a workaround of the button hover behavior of css 47 | The issue is that when the menu button is pressed, there is no 48 | unhover event, which will keep the color of the hover when the 49 | menu is exited. Here I modify the press event and release event 50 | to fake the exit of the hover event. This again cannot be 51 | replicated by css either (this is probably a bug as well). 52 | """ 53 | 54 | if event.type() == QEvent.HoverEnter: 55 | self.setStyleSheet(self.hover_c) 56 | 57 | elif event.type() == QEvent.HoverLeave: 58 | self.setStyleSheet(self.default_c) 59 | 60 | elif event.type() == QEvent.MouseButtonPress: 61 | self.setStyleSheet(self.press_c) 62 | 63 | elif event.type() == QEvent.MouseButtonRelease: 64 | self.setStyleSheet(self.default_c) 65 | 66 | return super().eventFilter(object, event) 67 | 68 | def resizeEvent(self, event): 69 | """add resize event to change the broadcast the size change 70 | 71 | THIs is used for resize the menu for color menu 72 | """ 73 | super().resizeEvent(event) 74 | self.sizeSig.emit(self.size()) 75 | 76 | 77 | class QLogSignal(QThread): 78 | """Qt signal for logging 79 | To create a thread safe logging, need to use signal-slot to pass the 80 | logging and formatting message 81 | """ 82 | 83 | log_msg = Signal(str) 84 | 85 | def __init__(self, parent=None): 86 | super().__init__(parent) 87 | 88 | 89 | class QLogHandler(logging.Handler): 90 | """Custom handler to stream log to QTextEdit 91 | To have a thread safe to exit, need to define parent of the signal 92 | thread, here passed as the parent parameters. 93 | """ 94 | 95 | def __init__(self, parent=None): 96 | super().__init__() 97 | self.signal = QLogSignal(parent) 98 | 99 | def emit(self, record): 100 | """Emit colored log record""" 101 | msg = self.format(record) 102 | self.signal.log_msg.emit(msg) 103 | -------------------------------------------------------------------------------- /xban/files/xBanStyle.css: -------------------------------------------------------------------------------- 1 | /*The styles have several levels 2 | application - style across applications 3 | window - style in mainwindow 4 | board - style in individual board 5 | subboard - style within the listwidget frame 6 | (note most of the subboard style is added in style.py) 7 | */ 8 | 9 | /*application styles 10 | some child widget overrides this*/ 11 | 12 | QWidget { 13 | background-color: #ebebeb; 14 | } 15 | 16 | QToolTip { 17 | background-color: #ebebeb; 18 | border-style: none; 19 | color: black; 20 | font: 12px; 21 | } 22 | QLineEdit { 23 | border-style: none; 24 | } 25 | QTextEdit { 26 | border: white; 27 | background-color: white; 28 | } 29 | QPushButton#appBtn_save { 30 | /*font-family: "Monospace"; */ 31 | border-radius: 5px; 32 | outline: none; 33 | padding: 5px 5px 5px 5px; 34 | margin-bottom: 4px; 35 | min-width: 40px; 36 | font-weight: bold; 37 | } 38 | 39 | /*window styles*/ 40 | QPushButton#windowBtn_add{ 41 | border-radius: 5px; 42 | font-size: 20px; 43 | outline: none; 44 | padding: 20px 5px 20px 5px; 45 | min-width: 40px; 46 | } 47 | 48 | QLineEdit#windowEdit_title { 49 | font-family: "Courier New"; 50 | font-size: 24px; 51 | font-weight: bold; 52 | } 53 | QTextEdit#windowEdit_text { 54 | font-size: 14px; 55 | background-color:transparent; 56 | } 57 | 58 | /*board styles*/ 59 | QPushButton[objectName^="boardBtn"]{ 60 | border-radius: 5px; 61 | font-size: 20px; 62 | outline: none; 63 | min-width: 40px; 64 | } 65 | 66 | QPushButton#boardBtn_color::menu-indicator { 67 | position: relative; 68 | top: -4px; left: -4px; 69 | } 70 | 71 | QPushButton#boardBtn_color::menu-indicator:pressed, QPushButton::menu-indicator:open { 72 | position: relative; 73 | top: 0px; 74 | } 75 | 76 | QTextEdit#boardEdit { 77 | border-style: none; 78 | font-size: 18px; 79 | font-weight: bold; 80 | } 81 | 82 | QMenu { 83 | background: white; 84 | border-radius: 4px; 85 | } 86 | 87 | /*subboard style*/ 88 | 89 | QFrame#subBoardFrame { 90 | border: 0.5px solid #dddddd; 91 | border-radius: 8px; 92 | background-color:white; 93 | } 94 | 95 | QListWidget { 96 | background-color:white; 97 | border-style: none; 98 | outline:none; 99 | } 100 | 101 | /*message box button style*/ 102 | 103 | QMessageBox { 104 | background-color: white; 105 | border-style: none; 106 | color: white; 107 | } 108 | QMessageBox QLabel { 109 | background: white; 110 | } 111 | 112 | QMessageBox QPushButton { 113 | border-style: none; 114 | color: #b5b3b3; 115 | background-color: white; 116 | } 117 | QMessageBox QPushButton:hover { 118 | color: black; 119 | } 120 | 121 | /*scrollbar customization*/ 122 | 123 | QScrollBar:vertical { 124 | border-style: none; 125 | background: white; 126 | width: 5px; 127 | padding-left: 2px; 128 | } 129 | QScrollBar::handle:vertical { 130 | background:darkgray; 131 | min-height: 10px; 132 | border-radius: 5px; 133 | } 134 | QScrollBar::add-line:vertical { 135 | border: none; 136 | background: none; 137 | } 138 | QScrollBar::sub-line:vertical { 139 | border: none; 140 | background: none; 141 | } 142 | QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical { 143 | border: none; 144 | background: none; 145 | } 146 | QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { 147 | background: none; 148 | } 149 | QScrollBar:horizontal { 150 | border-radius: 5px; 151 | background: white; 152 | height:5px; 153 | padding-top:2px; 154 | } 155 | QScrollBar::handle:horizontal { 156 | background: darkgray; 157 | min-height: 0px; 158 | border-radius: 5px; 159 | } 160 | QScrollBar::add-line:horizontal { 161 | border: none; 162 | background: none; 163 | } 164 | QScrollBar::sub-line:horizontal { 165 | border: none; 166 | background: none; 167 | } 168 | QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal { 169 | border: none; 170 | background: none; 171 | } 172 | QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { 173 | background: none; 174 | } 175 | -------------------------------------------------------------------------------- /tests/test_io.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | """Test xban yaml file processing and saving""" 6 | 7 | import yaml 8 | from xban.io import xban_content, process_yaml 9 | from xban.style import TILE_STYLE 10 | import logging 11 | from unittest.mock import patch, mock_open 12 | 13 | 14 | def test_empty(): 15 | """Test when the input string is empty""" 16 | default = [ 17 | {"xban_config": {"title": "testfile", "description": "", "board_color": [],}}, 18 | {}, 19 | ] 20 | 21 | assert default == xban_content("test/testfile.yaml", []) 22 | 23 | 24 | def test_invalid_xban(caplog): 25 | """Test when the yaml stream is invalid""" 26 | 27 | # first is a valid dict but the second one is not 28 | stream = [{"config": {"test": "new"}}, []] 29 | assert xban_content("test/testfile.yaml", stream) == [] 30 | assert caplog.record_tuples == [ 31 | ( 32 | "xban-io", 33 | logging.ERROR, 34 | "test/testfile.yaml does not have a valid xban format", 35 | ) 36 | ] 37 | 38 | 39 | def test_invalid_xban(caplog): 40 | """Test when the yaml stream is invalid""" 41 | 42 | # first is a valid dict but the second one is not 43 | stream = [{"config": {"test": "new"}}, []] 44 | assert xban_content("test/testfile.yaml", stream) == [] 45 | assert caplog.record_tuples == [ 46 | ( 47 | "xban-io", 48 | logging.ERROR, 49 | "test/testfile.yaml does not have a valid xban format", 50 | ) 51 | ] 52 | 53 | 54 | def test_valid_xban(): 55 | """Test when the yaml stream is a valid xban format""" 56 | 57 | # first is a valid dict but the second one is not 58 | stream = [ 59 | {"xban_config": {"title": "testfile", "description": "", "board_color": [],}}, 60 | {}, 61 | ] 62 | assert xban_content("test/testfile.yaml", stream) == stream 63 | 64 | 65 | def test_valid_yaml_xban(caplog): 66 | """Test when the yaml stream is a valid yaml file""" 67 | 68 | # the color generation is random so need to check individual values 69 | 70 | stream = [{"new": ["a", "b"], "old": ["c", "d"]}] 71 | 72 | parsed = xban_content("test/testfile.yaml", stream) 73 | assert parsed[1] == stream[0] 74 | assert parsed[0]["xban_config"]["title"] == "testfile" 75 | 76 | color = parsed[0]["xban_config"]["board_color"] 77 | 78 | assert len(color) == 2 79 | assert color[0] in TILE_STYLE 80 | 81 | 82 | def test_multi_docs_stream(caplog): 83 | """Test when yaml file has too many documents""" 84 | stream = [{"new": ["a", "b"], "old": ["c", "d"]}, {"new": ["a", "b"]}] 85 | assert xban_content("test/testfile.yaml", stream) == [] 86 | assert caplog.record_tuples == [ 87 | ("xban-io", logging.ERROR, "test/testfile.yaml have too many yaml documents",) 88 | ] 89 | 90 | 91 | def test_process_yaml_invalid(caplog): 92 | """Test given process yaml given a mocked io and invalid input""" 93 | data = """ 94 | text_key: incorrect format 95 | - listitem 96 | - listitem 97 | """ 98 | 99 | with patch("builtins.open", mock_open(read_data=data)): 100 | result = process_yaml("test/file.yaml") 101 | 102 | for record in caplog.records: 103 | assert ( 104 | "Incorrect test/file.yaml. Error: while parsing a block mapping" 105 | in record.message 106 | ) 107 | assert record.levelname == "ERROR" 108 | assert result == [] 109 | 110 | 111 | DATA = """ 112 | xban_config: 113 | title: testfile 114 | description: test io 115 | board_color: 116 | - red 117 | - teal 118 | --- 119 | todo: 120 | - need more tests! 121 | - and more! 122 | finished: 123 | - io tests 124 | """ 125 | 126 | 127 | def test_process_yaml_valid(caplog): 128 | """Test given process yaml given a mocked io""" 129 | 130 | with patch("builtins.open", mock_open(read_data=DATA)): 131 | result = process_yaml("test/testfile.yaml") 132 | assert result == [ 133 | { 134 | "xban_config": { 135 | "title": "testfile", 136 | "description": "test io", 137 | "board_color": ["red", "teal"], 138 | } 139 | }, 140 | {"todo": ["need more tests!", "and more!"], "finished": ["io tests"],}, 141 | ] 142 | -------------------------------------------------------------------------------- /xban/board.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from PySide6.QtCore import Qt, Signal, QMimeData, QSize 5 | from PySide6.QtGui import QTextCursor, QDrag, QKeySequence, QColor 6 | from PySide6.QtWidgets import ( 7 | QWidget, 8 | QTextEdit, 9 | QHBoxLayout, 10 | QVBoxLayout, 11 | QListWidget, 12 | QListWidgetItem, 13 | QAbstractItemView, 14 | QLineEdit, 15 | QMenu, 16 | QMessageBox, 17 | QGraphicsDropShadowEffect, 18 | QFrame, 19 | QStyledItemDelegate, 20 | QScrollBar, 21 | QWidgetAction, 22 | QLabel, 23 | ) 24 | 25 | from xban.utils import BanButton 26 | from xban.style import TILE_STYLE, MENU_STYLE 27 | from functools import partial 28 | from xban.io import save_yaml 29 | import logging 30 | 31 | gui_logger = logging.getLogger("xban-board") 32 | 33 | 34 | class BanBoard(QWidget): 35 | """The main board of xBan""" 36 | 37 | def __init__(self, filepath, file_config, parent=None): 38 | super().__init__(parent) 39 | 40 | self.filepath = filepath 41 | self.file_config = file_config 42 | 43 | self.draw_board(file_config) 44 | self.setAcceptDrops(True) 45 | 46 | gui_logger.info("Main xban board created") 47 | 48 | def draw_board(self, file_config): 49 | """Initiate UI 50 | 51 | The UI consists of 2 parts, top is the board title and info 52 | and button is the subbords with tiles in each ones 53 | the subboard is drawn based on file_configuration 54 | """ 55 | config, content = file_config 56 | 57 | mainlayout = QVBoxLayout() 58 | mainlayout.setContentsMargins(20, 20, 20, 20) 59 | 60 | title_edit = QLineEdit( 61 | config["xban_config"]["title"], 62 | objectName="windowEdit_title", 63 | parent=self, 64 | ) 65 | title_edit.setPlaceholderText("Enter title here ...") 66 | 67 | info_edit = NoteTile( 68 | config["xban_config"]["description"], "windowEdit_text", self 69 | ) 70 | info_edit.setPlaceholderText("Enter description here ...") 71 | 72 | mainlayout.addWidget(title_edit) 73 | mainlayout.addWidget(info_edit) 74 | 75 | self.sublayout = QHBoxLayout() 76 | color = config["xban_config"]["board_color"] 77 | self.sublayout.setContentsMargins(10, 10, 10, 10) 78 | 79 | self.sublayout.setSpacing(20) 80 | 81 | add_btn = BanButton( 82 | "+", 83 | clicked=self.insert_board, 84 | toolTip="add board", 85 | objectName="windowBtn_add", 86 | ) 87 | shadow = QGraphicsDropShadowEffect( 88 | self, blurRadius=10, offset=5, color=QColor("lightgrey") 89 | ) 90 | add_btn.setGraphicsEffect(shadow) 91 | self.sublayout.addWidget(add_btn) 92 | 93 | mainlayout.addLayout(self.sublayout) 94 | self.setLayout(mainlayout) 95 | 96 | for i, tile_contents in enumerate(content.items()): 97 | # insert the boards 98 | self.insert_board(tile_contents, color[i]) 99 | 100 | def insert_board(self, content=("", ()), color="black"): 101 | """Insert a board into the layout""" 102 | new_board = SubBoard(content, color, self) 103 | new_board.delBoardSig.connect(partial(self.delete_board, new_board)) 104 | new_board.listwidget.itemSelectionChanged.connect( 105 | partial(self.single_selection, new_board.listwidget) 106 | ) 107 | # insert second to last 108 | self.sublayout.insertWidget(self.sublayout.count() - 1, new_board) 109 | 110 | def delete_board(self, board): 111 | """Delete the board""" 112 | 113 | self.sublayout.removeWidget(board) 114 | board.deleteLater() 115 | 116 | def parse_board(self): 117 | """Parse the board to the correct yaml files 118 | 119 | Note this function will rewrite the file 120 | """ 121 | color = [] 122 | content = {} 123 | title = self.layout().itemAt(0).widget().text() 124 | description = self.layout().itemAt(1).widget().toPlainText() 125 | sublayout = self.layout().itemAt(2) 126 | # exclude the plus button 127 | for i in range(sublayout.count() - 1): 128 | subboard = sublayout.itemAt(i).widget() 129 | color.append(subboard.color) 130 | sub_content = subboard.parse() 131 | content.update({sub_content[0]: sub_content[1]}) 132 | config = { 133 | "xban_config": { 134 | "title": title, 135 | "description": description, 136 | "board_color": color, 137 | } 138 | } 139 | return [config, content] 140 | 141 | def get_index(self, pos): 142 | """Get index of the subboard layout based on the mouse position""" 143 | 144 | sublayout = self.layout().itemAt(2) 145 | for i in range(sublayout.count()): 146 | if sublayout.itemAt(i).geometry().contains(pos): 147 | return i 148 | return -1 149 | 150 | def dragEnterEvent(self, event): 151 | """Drag enter event 152 | 153 | Only accept the drag event when the moving widget is SubBoard instance 154 | The accepted drap event will proceed to dropEvent 155 | 156 | mouseMoveEvent needs to be defined for the child class 157 | """ 158 | 159 | if isinstance(event.source(), SubBoard): 160 | event.accept() 161 | 162 | def dropEvent(self, event): 163 | """Drop Event 164 | 165 | When the widget is dropped, determine the current layout index 166 | of the cursor and insert widget in the layout 167 | 168 | Note the last widget of the layout is the plus button, hence 169 | never insert at the end 170 | """ 171 | 172 | position = event.pos() 173 | widget = event.source() 174 | 175 | sublayout = self.layout().itemAt(2) 176 | index_new = self.get_index(position) 177 | if index_new >= 0: 178 | index = min(index_new, sublayout.count() - 1) 179 | sublayout.insertWidget(index, widget) 180 | event.setDropAction(Qt.MoveAction) 181 | event.accept() 182 | 183 | def save_board(self): 184 | """Save the board to yaml file""" 185 | 186 | xban_content = self.parse_board() 187 | save_yaml(self.filepath, xban_content) 188 | gui_logger.info(f"Saved to {self.filepath}") 189 | 190 | def single_selection(self, selected_board): 191 | """ensure that only single tile from a board is selected 192 | 193 | This is achieved by emit and received every time there 194 | is a selection made. The has selection check makes sure 195 | that it does not go into a recursion, since clear 196 | selection also triggers the signal 197 | """ 198 | 199 | if selected_board.selectionModel().hasSelection(): 200 | for i in range(self.sublayout.count() - 1): 201 | subboard = self.sublayout.itemAt(i).widget().listwidget 202 | 203 | if subboard is not selected_board: 204 | subboard.clearSelection() 205 | 206 | 207 | class SubBoard(QFrame): 208 | """The subboard of xBan 209 | 210 | The board contains the individual "blocks" of the board 211 | delBoardSig is trigger when the board is deleted 212 | """ 213 | 214 | delBoardSig = Signal() 215 | 216 | def __init__(self, tile_contents, color, parent=None): 217 | super().__init__(parent) 218 | title_name, tile_items = tile_contents 219 | self.color = color 220 | self.setObjectName("subBoardFrame") 221 | 222 | shadow = QGraphicsDropShadowEffect( 223 | self, blurRadius=10, offset=5, color=QColor("lightgrey") 224 | ) 225 | self.setGraphicsEffect(shadow) 226 | 227 | board = QVBoxLayout() 228 | board.setContentsMargins(20, 20, 20, 20) 229 | tile_title = NoteTile(title_name, "boardEdit", self) 230 | tile_title.setPlaceholderText("Title here ...") 231 | 232 | board.addWidget(tile_title) 233 | 234 | self.listwidget = BanListWidget(self) 235 | self.listwidget.setStyleSheet(TILE_STYLE.get(color, "black")) 236 | for tile in tile_items: 237 | self.listwidget.add_item(tile) 238 | 239 | board.addWidget(self.listwidget) 240 | 241 | btn_layout = QHBoxLayout() 242 | add_btn = BanButton( 243 | "+", 244 | clicked=self.add_listitem, 245 | toolTip="add tile", 246 | objectName="boardBtn", 247 | ) 248 | del_btn = BanButton( 249 | "-", 250 | clicked=self.del_listitem, 251 | toolTip="delete tile", 252 | objectName="boardBtn", 253 | shortcut=QKeySequence(Qt.Key_Backspace), 254 | ) 255 | color_btn = BanButton( 256 | "\u2261", 257 | toolTip="change color", 258 | objectName="boardBtn_color", 259 | color=[("white", "#bdbdbd"), ("grey", "white")], 260 | ) 261 | 262 | self.color_menu = self.context_menu() 263 | color_btn.setMenu(self.color_menu) 264 | color_btn.sizeSig.connect(self.menu_resize) 265 | 266 | destory_btn = BanButton( 267 | "\u00D7", 268 | clicked=self.delete_board, 269 | toolTip="delete board", 270 | objectName="boardBtn_des", 271 | color=[("white", "tomato"), ("white", "red")], 272 | ) 273 | 274 | btn_layout.addWidget(add_btn) 275 | btn_layout.addWidget(del_btn) 276 | 277 | btn_layout.addWidget(color_btn) 278 | btn_layout.addWidget(destory_btn) 279 | board.addLayout(btn_layout) 280 | 281 | self.setLayout(board) 282 | 283 | def parse(self): 284 | """Parse the subboard content into a content list 285 | 286 | In the board layout, the first item is the title widget 287 | and the second is the list widget 288 | """ 289 | board = self.layout() 290 | title = board.itemAt(0).widget().toPlainText() 291 | 292 | tile_items = [ 293 | self.listwidget.item(i).text() 294 | for i in range(self.listwidget.count()) 295 | ] 296 | return title, tile_items 297 | 298 | def add_listitem(self): 299 | """Add entry for listwidget""" 300 | 301 | self.listwidget.add_item("") 302 | # set the current row the new item 303 | self.listwidget.clearSelection() 304 | self.listwidget.setCurrentRow(self.listwidget.count() - 1) 305 | 306 | def del_listitem(self): 307 | """Delete entry for listwidget""" 308 | 309 | for item in self.listwidget.selectedItems(): 310 | self.listwidget.del_item(item) 311 | 312 | def mouseMoveEvent(self, event): 313 | """event call when mouse movement (press and move) detected 314 | 315 | This is the core of the drag and drop for the element. 316 | The QDrag object will create a the pixel image of the widget 317 | HopSpot is set so that it remains where the grab point is 318 | """ 319 | 320 | if event.buttons() == Qt.LeftButton: 321 | drag = QDrag(self) 322 | widget_image = self.grab() 323 | mimedata = QMimeData() 324 | drag.setMimeData(mimedata) 325 | drag.setPixmap(widget_image) 326 | drag.setHotSpot(event.pos() - self.rect().topLeft()) 327 | drag.exec_() 328 | 329 | super().mouseMoveEvent(event) 330 | 331 | def context_menu(self): 332 | """Add context menu triggered by right click 333 | 334 | The two purpose is to delete the column 335 | and change tile color 336 | """ 337 | 338 | menu = QMenu(self) 339 | 340 | for color_name in TILE_STYLE: 341 | label = QLabel(color_name, self) 342 | 343 | label.setStyleSheet(MENU_STYLE[color_name]) 344 | action = QWidgetAction(self) 345 | action.setDefaultWidget(label) 346 | action.triggered.connect( 347 | partial(self.color_change, color=color_name) 348 | ) 349 | menu.addAction(action) 350 | 351 | return menu 352 | 353 | def menu_resize(self, size): 354 | """Resize the menu to the same width of the button if possible 355 | 356 | A signal is emitted when the button is pressed, and the resize 357 | function is triggered. 358 | """ 359 | self.color_menu.setMinimumWidth(size.width()) 360 | 361 | def color_change(self, color): 362 | """Change the color of the tiles""" 363 | self.color = color 364 | self.layout().itemAt(1).widget().setStyleSheet(TILE_STYLE[color]) 365 | 366 | def delete_board(self): 367 | """Send a confirm message to delete the board""" 368 | reply = QMessageBox.question( 369 | self, 370 | "Delete Board", 371 | "Confirm to delete the sub-board (cannot be reversed)", 372 | QMessageBox.Yes | QMessageBox.Cancel, 373 | QMessageBox.Cancel, 374 | ) 375 | 376 | if reply == QMessageBox.Yes: 377 | self.delBoardSig.emit() 378 | 379 | 380 | class BanListWidget(QListWidget): 381 | """Initiate individual note blocks (one layer up from note tiles) 382 | 383 | In order to display full individual note tiles without cropping, 384 | the list view needs to update its size when the individual note 385 | change; and the individual note needs to resize while the listview 386 | widget changes size. This results in two signal-slot: when the 387 | listview changes size, it emits widthChangeSig, which connects to 388 | NoteTile.adjust that adjust the width of the note. While notetile 389 | changes width (or height due to added text) resizeSig is emitted and 390 | connects to the BanBlock widget, which change the listwidgetitem's 391 | sizeHint(). 392 | """ 393 | 394 | def __init__(self, parent=None): 395 | super().__init__(parent) 396 | self.setItemDelegate(TileDelegate(self)) 397 | self.setDragDropMode(QAbstractItemView.DragDrop) 398 | self.setSelectionMode(QAbstractItemView.ExtendedSelection) 399 | self.setWordWrap(True) 400 | self.setAcceptDrops(True) 401 | self.setEditTriggers(QAbstractItemView.DoubleClicked) 402 | self.setTextElideMode(Qt.ElideNone) 403 | self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) 404 | 405 | def dropEvent(self, event): 406 | """Drop and drag event 407 | 408 | This is the main function on drop and drop 409 | if the source is not self, meaning the item is moved from 410 | another widget, need to delete the original item. This can 411 | only be done by finding the item row and delete the whole row 412 | 413 | The issue of listwidget is that after the drop event, the 414 | new list item is newly created and no longer shares the proper 415 | flags. I have found no setting to make item editable global 416 | in the listwidget, the current workaround is to reset the flag 417 | of each items. (it is much easier to reset all items, then to 418 | find out which item is being dropped - this is certainly doable 419 | but requires to custom define a item model) 420 | """ 421 | if event.source() != self: 422 | board = event.source() 423 | board.takeItem(board.currentRow()) 424 | 425 | event.setDropAction(Qt.MoveAction) 426 | super().dropEvent(event) 427 | 428 | for i in range(self.count()): 429 | item = self.item(i) 430 | item.setFlags(item.flags() | Qt.ItemIsEditable) 431 | 432 | def del_item(self, item): 433 | """Delete and item based on the item ID 434 | 435 | There no way to directly delete the item, and there's isn't 436 | a good way to search the row number by item. Here we iterate 437 | through all the entries and find the correct row value 438 | 439 | :param item QListWidgetItem: listwidget item 440 | """ 441 | 442 | for i in range(self.count()): 443 | if self.item(i) is item: 444 | self.takeItem(i) 445 | 446 | def add_item(self, text): 447 | """add editable text item 448 | 449 | need to use item widget to make it editable 450 | :param text str: content of the item 451 | """ 452 | item = QListWidgetItem(text) 453 | item.setFlags(item.flags() | Qt.ItemIsEditable | Qt.ItemIsSelectable) 454 | self.addItem(item) 455 | 456 | 457 | class TileDelegate(QStyledItemDelegate): 458 | """Delegate the list widget tile editor to NoteTile 459 | And adjust the size properly. This is used in listwidgetview 460 | """ 461 | 462 | def __init__(self, parent=None): 463 | super().__init__(parent) 464 | 465 | def createEditor(self, parent, option, index): 466 | """Change the default editor to NoteTile""" 467 | 468 | editor = TileEdit(parent=parent) 469 | editor.tile_finishSig.connect(self.finish_edit) 470 | 471 | return editor 472 | 473 | def setEditorData(self, editor, index): 474 | """Sets the editor data to the correct value""" 475 | 476 | editor.setText(index.data()) 477 | editor.moveCursor(QTextCursor.End) 478 | 479 | def setModelData(self, editor, model, index): 480 | """Get data from the editor""" 481 | 482 | model.setData(index, editor.toPlainText()) 483 | 484 | def sizeHint(self, option, index): 485 | """Change the sizehint this is to prevent horizontal crop 486 | 487 | Adjust the width slightly less than the default value 488 | """ 489 | size = super().sizeHint(option, index) 490 | return QSize(size.width() - 20, size.height() + 10) 491 | 492 | def finish_edit(self): 493 | """Emit signal when editing is finished 494 | 495 | The signal tile_finishSig is triggered by QTextEdit 496 | out of focus event 497 | """ 498 | editor = self.sender() 499 | self.commitData.emit(editor) 500 | self.closeEditor.emit(editor) 501 | 502 | 503 | class TileEdit(QTextEdit): 504 | """Editor for each tile""" 505 | tile_finishSig = Signal() 506 | 507 | def __init__(self, parent=None): 508 | super().__init__(parent) 509 | self.setVerticalScrollBar(QScrollBar()) 510 | 511 | def focusOutEvent(self, event): 512 | self.tile_finishSig.emit() 513 | super().focusOutEvent(event) 514 | 515 | 516 | class NoteTile(QTextEdit): 517 | """Create individual note tiles""" 518 | 519 | tile_resizeSig = Signal() 520 | tile_finishSig = Signal() 521 | 522 | def __init__(self, text="", objectName="", parent=None): 523 | super().__init__(parent) 524 | 525 | self.setObjectName(objectName) 526 | self.setPlainText(text) 527 | self.setLineWrapMode(QTextEdit.WidgetWidth) 528 | self.setTabChangesFocus(True) 529 | self.moveCursor(QTextCursor.End) 530 | self.setAcceptRichText(False) 531 | self.setContextMenuPolicy(Qt.PreventContextMenu) 532 | self.textChanged.connect(self._resize) 533 | self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 534 | 535 | def _resize(self): 536 | """Resize the height based on the text document size 537 | 538 | The height is automatically adjusted to 1.1 times of the text 539 | height so no scrolling will be displayed 540 | """ 541 | self.setFixedHeight(self.document().size().height() * 1.1) 542 | 543 | def focusOutEvent(self, event): 544 | self.tile_finishSig.emit() 545 | super().focusOutEvent(event) 546 | 547 | def resizeEvent(self, event): 548 | """Overwrite the resize event""" 549 | 550 | super().resizeEvent(event) 551 | self._resize() 552 | --------------------------------------------------------------------------------