From 1595e0de940a86a7810df0e02e43838d97c0d846 Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 6 Jun 2020 17:44:26 +0200 Subject: Provide nix build --- .gitignore | 1 + README.md | 6 +- bin/test | 4 + bin/todo | 22 +++ default.nix | 34 ++++ scripts/test | 4 - setup.py | 12 ++ shell.nix | 20 --- src/arguments.py | 13 -- src/database.py | 19 --- src/db/init.py | 51 ------ src/db/tags.py | 72 --------- src/db/task_tags.py | 39 ----- src/db/tasks.py | 103 ------------ src/gui/color.py | 21 --- src/gui/icon.py | 27 ---- src/gui/signal.py | 13 -- src/gui/tags/list.py | 47 ------ src/gui/tags/panel/dialog.py | 65 -------- src/gui/tags/panel/form/state.py | 58 ------- src/gui/tags/panel/form/widget.py | 137 ---------------- src/gui/tags/panel/signal.py | 27 ---- src/gui/tags/panel/table/menu.py | 36 ----- src/gui/tags/panel/table/model.py | 104 ------------- src/gui/tags/panel/table/widget.py | 73 --------- src/gui/tags/panel/widget.py | 30 ---- src/gui/tasks/dialog.py | 78 ---------- src/gui/tasks/duration.py | 50 ------ src/gui/tasks/form/state.py | 100 ------------ src/gui/tasks/form/widget.py | 184 ---------------------- src/gui/tasks/signal.py | 28 ---- src/gui/tasks/table/menu.py | 49 ------ src/gui/tasks/table/widget.py | 284 ---------------------------------- src/gui/tasks/test_duration.py | 21 --- src/gui/tasks/widget.py | 30 ---- src/gui/window.py | 36 ----- src/main.py | 20 --- src/model/difficulty.py | 30 ---- src/model/priority.py | 30 ---- src/model/status.py | 30 ---- src/model/tag.py | 12 -- src/model/task.py | 22 --- src/model/task_tag.py | 5 - src/service/tasks.py | 32 ---- src/util/array.py | 5 - src/util/range.py | 30 ---- src/util/test_array.py | 12 -- src/util/test_range.py | 6 - todo/__init__.py | 0 todo/arguments.py | 13 ++ todo/database.py | 19 +++ todo/db/__init__.py | 0 todo/db/init.py | 51 ++++++ todo/db/tags.py | 72 +++++++++ todo/db/task_tags.py | 39 +++++ todo/db/tasks.py | 103 ++++++++++++ todo/gui/__init__.py | 0 todo/gui/color.py | 21 +++ todo/gui/icon.py | 27 ++++ todo/gui/signal.py | 13 ++ todo/gui/tags/__init__.py | 0 todo/gui/tags/list.py | 47 ++++++ todo/gui/tags/panel/__init__.py | 0 todo/gui/tags/panel/dialog.py | 64 ++++++++ todo/gui/tags/panel/form/__init__.py | 0 todo/gui/tags/panel/form/state.py | 58 +++++++ todo/gui/tags/panel/form/widget.py | 137 ++++++++++++++++ todo/gui/tags/panel/signal.py | 27 ++++ todo/gui/tags/panel/table/__init__.py | 0 todo/gui/tags/panel/table/menu.py | 35 +++++ todo/gui/tags/panel/table/model.py | 103 ++++++++++++ todo/gui/tags/panel/table/widget.py | 73 +++++++++ todo/gui/tags/panel/widget.py | 30 ++++ todo/gui/tasks/__init__.py | 0 todo/gui/tasks/dialog.py | 76 +++++++++ todo/gui/tasks/duration.py | 50 ++++++ todo/gui/tasks/form/__init__.py | 0 todo/gui/tasks/form/state.py | 100 ++++++++++++ todo/gui/tasks/form/widget.py | 184 ++++++++++++++++++++++ todo/gui/tasks/signal.py | 28 ++++ todo/gui/tasks/table/__init__.py | 0 todo/gui/tasks/table/menu.py | 48 ++++++ todo/gui/tasks/table/widget.py | 283 +++++++++++++++++++++++++++++++++ todo/gui/tasks/test_duration.py | 21 +++ todo/gui/tasks/widget.py | 30 ++++ todo/gui/window.py | 36 +++++ todo/model/__init__.py | 0 todo/model/difficulty.py | 30 ++++ todo/model/priority.py | 30 ++++ todo/model/status.py | 30 ++++ todo/model/tag.py | 12 ++ todo/model/task.py | 22 +++ todo/model/task_tag.py | 5 + todo/service/__init__.py | 0 todo/service/tasks.py | 32 ++++ todo/util/__init__.py | 0 todo/util/array.py | 5 + todo/util/range.py | 30 ++++ todo/util/test_array.py | 12 ++ todo/util/test_range.py | 6 + 100 files changed, 2078 insertions(+), 2056 deletions(-) create mode 100755 bin/test create mode 100755 bin/todo create mode 100644 default.nix delete mode 100755 scripts/test create mode 100644 setup.py delete mode 100644 shell.nix delete mode 100644 src/arguments.py delete mode 100644 src/database.py delete mode 100644 src/db/init.py delete mode 100644 src/db/tags.py delete mode 100644 src/db/task_tags.py delete mode 100644 src/db/tasks.py delete mode 100644 src/gui/color.py delete mode 100644 src/gui/icon.py delete mode 100644 src/gui/signal.py delete mode 100644 src/gui/tags/list.py delete mode 100644 src/gui/tags/panel/dialog.py delete mode 100644 src/gui/tags/panel/form/state.py delete mode 100644 src/gui/tags/panel/form/widget.py delete mode 100644 src/gui/tags/panel/signal.py delete mode 100644 src/gui/tags/panel/table/menu.py delete mode 100644 src/gui/tags/panel/table/model.py delete mode 100644 src/gui/tags/panel/table/widget.py delete mode 100644 src/gui/tags/panel/widget.py delete mode 100644 src/gui/tasks/dialog.py delete mode 100644 src/gui/tasks/duration.py delete mode 100644 src/gui/tasks/form/state.py delete mode 100644 src/gui/tasks/form/widget.py delete mode 100644 src/gui/tasks/signal.py delete mode 100644 src/gui/tasks/table/menu.py delete mode 100644 src/gui/tasks/table/widget.py delete mode 100644 src/gui/tasks/test_duration.py delete mode 100644 src/gui/tasks/widget.py delete mode 100644 src/gui/window.py delete mode 100644 src/main.py delete mode 100644 src/model/difficulty.py delete mode 100644 src/model/priority.py delete mode 100644 src/model/status.py delete mode 100644 src/model/tag.py delete mode 100644 src/model/task.py delete mode 100644 src/model/task_tag.py delete mode 100644 src/service/tasks.py delete mode 100644 src/util/array.py delete mode 100644 src/util/range.py delete mode 100644 src/util/test_array.py delete mode 100644 src/util/test_range.py create mode 100644 todo/__init__.py create mode 100644 todo/arguments.py create mode 100644 todo/database.py create mode 100644 todo/db/__init__.py create mode 100644 todo/db/init.py create mode 100644 todo/db/tags.py create mode 100644 todo/db/task_tags.py create mode 100644 todo/db/tasks.py create mode 100644 todo/gui/__init__.py create mode 100644 todo/gui/color.py create mode 100644 todo/gui/icon.py create mode 100644 todo/gui/signal.py create mode 100644 todo/gui/tags/__init__.py create mode 100644 todo/gui/tags/list.py create mode 100644 todo/gui/tags/panel/__init__.py create mode 100644 todo/gui/tags/panel/dialog.py create mode 100644 todo/gui/tags/panel/form/__init__.py create mode 100644 todo/gui/tags/panel/form/state.py create mode 100644 todo/gui/tags/panel/form/widget.py create mode 100644 todo/gui/tags/panel/signal.py create mode 100644 todo/gui/tags/panel/table/__init__.py create mode 100644 todo/gui/tags/panel/table/menu.py create mode 100644 todo/gui/tags/panel/table/model.py create mode 100644 todo/gui/tags/panel/table/widget.py create mode 100644 todo/gui/tags/panel/widget.py create mode 100644 todo/gui/tasks/__init__.py create mode 100644 todo/gui/tasks/dialog.py create mode 100644 todo/gui/tasks/duration.py create mode 100644 todo/gui/tasks/form/__init__.py create mode 100644 todo/gui/tasks/form/state.py create mode 100644 todo/gui/tasks/form/widget.py create mode 100644 todo/gui/tasks/signal.py create mode 100644 todo/gui/tasks/table/__init__.py create mode 100644 todo/gui/tasks/table/menu.py create mode 100644 todo/gui/tasks/table/widget.py create mode 100644 todo/gui/tasks/test_duration.py create mode 100644 todo/gui/tasks/widget.py create mode 100644 todo/gui/window.py create mode 100644 todo/model/__init__.py create mode 100644 todo/model/difficulty.py create mode 100644 todo/model/priority.py create mode 100644 todo/model/status.py create mode 100644 todo/model/tag.py create mode 100644 todo/model/task.py create mode 100644 todo/model/task_tag.py create mode 100644 todo/service/__init__.py create mode 100644 todo/service/tasks.py create mode 100644 todo/util/__init__.py create mode 100644 todo/util/array.py create mode 100644 todo/util/range.py create mode 100644 todo/util/test_array.py create mode 100644 todo/util/test_range.py diff --git a/.gitignore b/.gitignore index 42b5d44..8c33891 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ database +result diff --git a/README.md b/README.md index e323a4d..b2f47b8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# todo-next +# todo Manage a context-based next-action list, compatible with the [GTD](https://en.wikipedia.org/wiki/Getting_Things_Done) method. @@ -6,13 +6,13 @@ Manage a context-based next-action list, compatible with the ## Getting started ```bash -nix-shell --run "python src/main.py" +nix-shell --run bin/todo ``` ## Tests ```bash -scripts/test +nix-shell --run bin/test ``` ## Links diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..cd6c0de --- /dev/null +++ b/bin/test @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname $0)/.." +python -m pytest diff --git a/bin/todo b/bin/todo new file mode 100755 index 0000000..c743d0d --- /dev/null +++ b/bin/todo @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import sys +from PyQt5 import QtCore, QtWidgets +import sqlite3 +import os.path + +import todo.database +import todo.db.init +import todo.gui.window +import todo.arguments +import todo.database + +args = todo.arguments.parser().parse_args() +todo.database.init(args.database if args.database != None else "database") +app = QtWidgets.QApplication(sys.argv) + +window = todo.gui.window.get() +window.show() +res = app.exec_() + +todo.database.close() +sys.exit(res) diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..4430918 --- /dev/null +++ b/default.nix @@ -0,0 +1,34 @@ +with (import (builtins.fetchGit { + name = "nixpkgs-20.03"; + url = "git@github.com:nixos/nixpkgs.git"; + rev = "5272327b81ed355bbed5659b8d303cf2979b6953"; + ref = "refs/tags/20.03"; +}) {}); + +python38Packages.buildPythonApplication rec { + pname = "todo"; + version = "0.1.0"; + + src = ./.; + + buildInputs = [ + qt5.qtbase + sqlite + ]; + + propagatedBuildInputs = with python38Packages; [ + pyqt5 + pytest + ]; + + makeWrapperArgs = [ + "--set QT_QPA_PLATFORM_PLUGIN_PATH ${qt5.qtbase.bin}/lib/qt-*/plugins/platforms" + "--set QT_PLUGIN_PATH ${qt5.qtbase.bin}/lib/qt-*/plugins" + ]; + + shellHook = '' + export QT_QPA_PLATFORM_PLUGIN_PATH="$(echo ${qt5.qtbase.bin}/lib/qt-*/plugins/platforms)" + export QT_PLUGIN_PATH="$(echo ${qt5.qtbase.bin}/lib/qt-*/plugins)" + export PYTHONPATH=./:$PYTHONPATH # Give access to kiosk_browser module + ''; +} diff --git a/scripts/test b/scripts/test deleted file mode 100755 index 9b3fae7..0000000 --- a/scripts/test +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname $0)/../src" -python -m pytest diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..31fac1e --- /dev/null +++ b/setup.py @@ -0,0 +1,12 @@ +import setuptools + +setuptools.setup( + name="todo", + version="0.1.0", + author="Joris Guyonvarch", + description="Context based next-action list manager", + long_description_content_type="text/markdown", + url="https://gitlab.com/guyonvarch/todo/", + packages=setuptools.find_packages(), + scripts=['bin/todo'] +) diff --git a/shell.nix b/shell.nix deleted file mode 100644 index bb95254..0000000 --- a/shell.nix +++ /dev/null @@ -1,20 +0,0 @@ -with (import (builtins.fetchGit { - name = "nixpkgs-20.03"; - url = "git@github.com:nixos/nixpkgs.git"; - rev = "5272327b81ed355bbed5659b8d303cf2979b6953"; - ref = "refs/tags/20.03"; -}) {}); - -mkShell { - - buildInputs = [ - qt5.full - sqlite - ]; - - propagatedBuildInputs = [ - python38Packages.pyqt5 - python38Packages.pytest - ]; - -} diff --git a/src/arguments.py b/src/arguments.py deleted file mode 100644 index bb60dce..0000000 --- a/src/arguments.py +++ /dev/null @@ -1,13 +0,0 @@ -import argparse - -def parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - usage = "%(prog)s [OPTION]", - description = "Manage a context-base next-action list." - ) - parser.add_argument( - "-v", "--version", action = "version", - version = f"{parser.prog} version 1.0.0" - ) - parser.add_argument("-d", "--database") - return parser diff --git a/src/database.py b/src/database.py deleted file mode 100644 index 478f62e..0000000 --- a/src/database.py +++ /dev/null @@ -1,19 +0,0 @@ -import db.init - -_database = None - -def init(path): - global _database - _database = db.init.init(path) - -def cursor(): - global _database - return _database.cursor() - -def commit(): - global _database - _database.commit() - -def close(): - global _database - _database.close() diff --git a/src/db/init.py b/src/db/init.py deleted file mode 100644 index 5d847a3..0000000 --- a/src/db/init.py +++ /dev/null @@ -1,51 +0,0 @@ -import sqlite3 -import os.path -import time - -def init(path): - - is_db_new = not os.path.isfile(path) - - database = sqlite3.connect(path) - - cursor = database.cursor() - - if is_db_new: - - cursor.execute( - " CREATE TABLE IF NOT EXISTS tasks(" - " id INTEGER PRIMARY KEY," - " created_at INTEGER NOT NULL," - " updated_at INTEGER NOT NULL," - " name TEXT NOT NULL," - " duration INTEGER," - " difficulty INT," - " priority INT," - " description TEXT," - " status TEXT" - " )") - - cursor.execute( - " CREATE TABLE IF NOT EXISTS tags(" - " id INTEGER PRIMARY KEY," - " created_at INTEGER NOT NULL," - " updated_at INTEGER NOT NULL," - " name TEXT NOT NULL," - " color TEXT NOT NULL" - " )") - - cursor.execute( - " CREATE TABLE IF NOT EXISTS task_tags(" - " task_id INTEGER NOT NULL," - " tag_id INTEGER NOT NULL," - " created_at INTEGER NOT NULL," - " FOREIGN KEY (task_id) REFERENCES tasks(id)," - " FOREIGN KEY (tag_id) REFERENCES tags(id)," - " PRIMARY KEY (task_id, tag_id)" - " )") - - cursor.execute("PRAGMA foreign_keys = ON") - - database.commit() - - return database diff --git a/src/db/tags.py b/src/db/tags.py deleted file mode 100644 index 666bd1e..0000000 --- a/src/db/tags.py +++ /dev/null @@ -1,72 +0,0 @@ -from sqlite3 import Cursor -import time -from typing import List - -from model.tag import Tag, ValidTagForm - -def get(cursor: Cursor) -> List[Tag]: - cursor.execute( - " SELECT" - " id," - " created_at," - " updated_at," - " name," - " color" - " FROM tags") - - res = [] - - for tag in cursor.fetchall(): - res.append(Tag( - id = tag[0], - created_at = tag[1], - updated_at = tag[2], - name = tag[3], - color = tag[4] - )) - - return res - -def insert(cursor: Cursor, form: ValidTagForm): - now = int(time.time()) - cursor.execute( - " INSERT INTO tags(" - " created_at," - " updated_at," - " name," - " color" - " ) VALUES (?, ?, ?, ?)", - (now, now, form.name, form.color)) - - return Tag( - id = cursor.lastrowid, - created_at = now, - updated_at = now, - name = form.name, - color = form.color - ) - -def update(cursor: Cursor, tag: Tag, form: ValidTagForm): - now = int(time.time()) - - cursor.execute( - " UPDATE tags SET" - " updated_at = ?," - " name = ?," - " color = ?" - " WHERE id = ?", - (now, form.name, form.color, tag.id)) - - return Tag( - id = tag.id, - created_at = tag.created_at, - updated_at = now, - name = form.name, - color = form.color - ) - -def delete(cursor: Cursor, ids): - if len(ids) >= 1: - cursor.execute( - "DELETE FROM tags WHERE id IN (%s)" % ",".join("?"*len(ids)), - ids) diff --git a/src/db/task_tags.py b/src/db/task_tags.py deleted file mode 100644 index e8c0ee0..0000000 --- a/src/db/task_tags.py +++ /dev/null @@ -1,39 +0,0 @@ -from sqlite3 import Cursor -import time -from typing import List - -from model.task_tag import TaskTag - -def one_is_used(cursor: Cursor, tag_ids: List[int]) -> bool: - if len(tag_ids) >= 1: - cursor.execute( - "SELECT task_id FROM task_tags WHERE tag_id IN (%s) LIMIT 1" % ",".join("?"*len(tag_ids)), - tag_ids) - return len(cursor.fetchall()) == 1 - else: - return False - -def get(cursor: Cursor) -> List[TaskTag]: - cursor.execute("SELECT task_id, tag_id FROM task_tags") - return [TaskTag(r[0], r[1]) for r in cursor.fetchall()] - -def insert_many(cursor: Cursor, task_id: int, tag_ids: List[int]) -> List[TaskTag] : - now = int(time.time()) - - task_tags = [TaskTag(task_id = task_id, tag_id = tag) for tag in tag_ids] - - cursor.executemany( - " INSERT INTO task_tags(" - " task_id," - " tag_id," - " created_at" - " ) VALUES (?, ?, ?)", - [(t.task_id, t.tag_id, now) for t in task_tags]) - - return task_tags - -def delete(cursor: Cursor, task_ids: List[int]): - if len(task_ids) >= 1: - cursor.execute( - "DELETE FROM task_tags WHERE task_id IN (%s)" % ",".join("?"*len(task_ids)), - task_ids) diff --git a/src/db/tasks.py b/src/db/tasks.py deleted file mode 100644 index efb88d6..0000000 --- a/src/db/tasks.py +++ /dev/null @@ -1,103 +0,0 @@ -from sqlite3 import Cursor -import time -from typing import List - -from model.task import Task, ValidTaskForm -from model.status import Status -from model import difficulty, priority, status - -def get(cursor: Cursor, s: Status) -> List[Task]: - cursor.execute( - " SELECT" - " id," - " created_at," - " updated_at," - " name," - " duration," - " difficulty," - " priority," - " description" - " FROM" - " tasks" - " WHERE" - " status = ?", - (status.format(s),)) - - res = [] - - for task in cursor.fetchall(): - res.append(Task( - id = task[0], - created_at = task[1], - updated_at = task[2], - name = task[3], - duration = task[4], - difficulty = difficulty.parse(task[5]), - priority = priority.parse(task[6]), - description = task[7] - )) - - return res - -def insert(cursor: Cursor, s: Status, form: ValidTaskForm): - now = int(time.time()) - cursor.execute( - " INSERT INTO tasks(" - " created_at," - " updated_at," - " name," - " duration," - " difficulty," - " priority," - " description," - " status" - " ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (now, now, form.name, form.duration, difficulty.format(form.difficulty), priority.format(form.priority), form.description, status.format(s))) - - return Task( - id = cursor.lastrowid, - created_at = now, - updated_at = now, - name = form.name, - duration = form.duration, - difficulty = form.difficulty, - priority = form.priority, - description = form.description - ) - -def update(cursor: Cursor, task: Task, form: ValidTaskForm): - now = int(time.time()) - - cursor.execute( - " UPDATE tasks SET" - " updated_at = ?," - " name = ?," - " duration = ?," - " difficulty = ?," - " priority = ?," - " description = ?" - " WHERE id = ?", - (now, form.name, form.duration, difficulty.format(form.difficulty), priority.format(form.priority), form.description, task.id)) - - return Task( - id = task.id, - created_at = task.created_at, - updated_at = now, - name = form.name, - duration = form.duration, - difficulty = form.difficulty, - priority = form.priority, - description = form.description - ) - -def delete(cursor: Cursor, ids: List[int]): - if len(ids) >= 1: - cursor.execute( - "DELETE FROM tasks WHERE id IN (%s)" % ",".join("?"*len(ids)), - ids) - -def update_status(cursor: Cursor, ids: List[int], s: Status): - if len(ids) >= 1: - cursor.execute( - "UPDATE tasks SET status = ? WHERE id IN (%s)" % ",".join("?"*len(ids)), - [status.format(s)] + ids) diff --git a/src/gui/color.py b/src/gui/color.py deleted file mode 100644 index cc7e5a8..0000000 --- a/src/gui/color.py +++ /dev/null @@ -1,21 +0,0 @@ -from PyQt5 import QtGui - -black = QtGui.QColor(0, 0, 0) -red = QtGui.QColor(200, 30, 30) -orange = QtGui.QColor(200, 100, 30) -green = QtGui.QColor(30, 180, 30) -blue = QtGui.QColor(30, 30, 200) - -text = black - -easy_difficulty = green -normal_difficulty = orange -hard_difficulty = red - -low_priority = green -middle_priority = orange -high_priority = red - -short_duration = green -medium_duration = orange -long_duration = red diff --git a/src/gui/icon.py b/src/gui/icon.py deleted file mode 100644 index 7e2156d..0000000 --- a/src/gui/icon.py +++ /dev/null @@ -1,27 +0,0 @@ -from PyQt5 import QtWidgets - -# List of icons: https://joekuan.wordpress.com/2015/09/23/list-of-qt-icons/ - -def task_ready(style): - return style.standardIcon(QtWidgets.QStyle.SP_DialogApplyButton) - -def task_waiting(style): - return style.standardIcon(QtWidgets.QStyle.SP_BrowserReload) - -def task_maybe(style): - return style.standardIcon(QtWidgets.QStyle.SP_TitleBarContextHelpButton) - -def new_folder(style): - return style.standardIcon(QtWidgets.QStyle.SP_FileDialogNewFolder) - -def dialog_open(style): - return style.standardIcon(QtWidgets.QStyle.SP_DialogOpenButton) - -def dialog_ok(style): - return style.standardIcon(QtWidgets.QStyle.SP_DialogOkButton) - -def dialog_cancel(style): - return style.standardIcon(QtWidgets.QStyle.SP_DialogCancelButton) - -def trash(style): - return style.standardIcon(QtWidgets.QStyle.SP_TrashIcon) diff --git a/src/gui/signal.py b/src/gui/signal.py deleted file mode 100644 index 99100f1..0000000 --- a/src/gui/signal.py +++ /dev/null @@ -1,13 +0,0 @@ -from PyQt5 import QtCore - -class Reload(QtCore.QObject): - _signal = QtCore.pyqtSignal(name = "reload") - - def __init__(self): - QtCore.QObject.__init__(self) - - def emit(self): - self._signal.emit() - - def connect(self, f): - self._signal.connect(f) diff --git a/src/gui/tags/list.py b/src/gui/tags/list.py deleted file mode 100644 index f7eeba3..0000000 --- a/src/gui/tags/list.py +++ /dev/null @@ -1,47 +0,0 @@ -from PyQt5 import QtWidgets, QtCore -from typing import List, Tuple - -from model.tag import Tag -import db.tags -import database - -class SelectionSignal(QtCore.QObject): - _signal = QtCore.pyqtSignal(list, name = "selection") - - def __init__(self): - QtCore.QObject.__init__(self) - - def emit(self, tag_ids): - self._signal.emit(tag_ids) - - def connect(self, f): - self._signal.connect(f) - -def widget(parent, init_tags: List[int]) -> Tuple[QtWidgets.QWidget, SelectionSignal]: - widget = QtWidgets.QWidget(parent) - signal = SelectionSignal() - - layout = QtWidgets.QVBoxLayout(widget) - widget.setLayout(layout) - - layout.addWidget(QtWidgets.QLabel("Tags")) - - list_widget = QtWidgets.QListWidget() - list_widget.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) - layout.addWidget(list_widget) - - tags = db.tags.get(database.cursor()) - - for tag in tags: - item = QtWidgets.QListWidgetItem(tag.name) - list_widget.addItem(item) - if tag.id in init_tags: - item.setSelected(True) - - def on_item_selection_changed(): - tag_texts = [item.text() for item in list_widget.selectedItems()] - signal.emit([tag.id for tag in tags if tag.name in tag_texts]) - - list_widget.itemSelectionChanged.connect(on_item_selection_changed) - - return (widget, signal) diff --git a/src/gui/tags/panel/dialog.py b/src/gui/tags/panel/dialog.py deleted file mode 100644 index f0ca986..0000000 --- a/src/gui/tags/panel/dialog.py +++ /dev/null @@ -1,65 +0,0 @@ -from PyQt5 import QtCore, QtWidgets - -from model.tag import Tag, ValidTagForm - -import db.tags -import gui.tags.panel.form.widget -import database - -def add(parent_widget, add_tag_signal): - - def on_add(form: ValidTagForm): - tag = db.tags.insert(database.cursor(), form) - database.commit() - add_tag_signal.emit(tag) - - return widget(parent_widget, "Add a tag", "add", None, on_add) - -def update(parent_widget, update_tag_signal, row, tag): - - def on_update(form: ValidTagForm): - updated_tag = db.tags.update(database.cursor(), tag, form) - update_tag_signal.emit(row, updated_tag) - database.commit() - - return widget(parent_widget, "Modify a tag", "modify", tag, on_update) - -def show_delete(table, rows): - confirm = QtWidgets.QMessageBox.question( - table, - "Tag deletion", - "Do you really want to delete the selected tags ?", - QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.Yes) - - if confirm == QtWidgets.QMessageBox.Yes: - db.tags.delete(database.cursor(), table.model().row_ids(rows)) - database.commit() - table.model().delete_tags(rows) - -def widget( - parent: QtWidgets.QWidget, - title: str, - action_title: str, - tag: Tag, - on_validated): - - dialog = QtWidgets.QDialog(parent) - dialog.setWindowTitle(title) - dialog.setMinimumSize(QtCore.QSize(320, 240)) - - layout = QtWidgets.QVBoxLayout(dialog) - dialog.setLayout(layout) - - def on_dialog_validated(form): - dialog.accept() - on_validated(form) - - layout.addWidget(gui.tags.panel.form.widget.widget( - parent = dialog, - action_title = action_title, - tag = tag, - on_validated = on_dialog_validated, - on_cancel = lambda: dialog.reject())) - - return dialog diff --git a/src/gui/tags/panel/form/state.py b/src/gui/tags/panel/form/state.py deleted file mode 100644 index fbec956..0000000 --- a/src/gui/tags/panel/form/state.py +++ /dev/null @@ -1,58 +0,0 @@ -from PyQt5 import QtCore -from typing import Optional - -from model.tag import ValidTagForm - -class TagFormEdition: - def __init__( - self, - name, - name_signal, - color, - color_signal): - - self._name = name - self._color = color - self._signal = ValidTagFormSignal() - - name_signal.connect(lambda n: self.on_name_signal(n)) - color_signal.connect(lambda d: self.on_color_signal(d)) - - def get(self) -> Optional[ValidTagForm]: - name = self._name.strip() - color = self._color.strip() - - if name and color: - return ValidTagForm( - name = name, - color = color) - else: - return None - - def on_name_signal(self, name: str): - self._name = name - self.emit() - - def on_color_signal(self, color: str): - self._color = color - self.emit() - - def emit(self): - validForm = self.get() - if validForm: - self._signal.emit(validForm) - - def signal(self): - return self._signal - -class ValidTagFormSignal(QtCore.QObject): - _signal = QtCore.pyqtSignal(ValidTagForm, name = "validTagForm") - - def __init__(self): - QtCore.QObject.__init__(self) - - def emit(self, form: Optional[ValidTagForm]): - self._signal.emit(form) - - def connect(self, f): - self._signal.connect(f) diff --git a/src/gui/tags/panel/form/widget.py b/src/gui/tags/panel/form/widget.py deleted file mode 100644 index 7079e57..0000000 --- a/src/gui/tags/panel/form/widget.py +++ /dev/null @@ -1,137 +0,0 @@ -from PyQt5 import QtWidgets, QtCore, QtGui -from typing import Optional, Tuple, List, Any - -from model.tag import Tag, ValidTagForm -from model import difficulty, priority -import gui.icon -import gui.tags.panel.form.state -import gui.color - -def widget( - parent: QtWidgets.QWidget, - action_title: str, - tag: Tag, - on_validated, - on_cancel): - - widget = QtWidgets.QWidget(parent) - layout = QtWidgets.QVBoxLayout(widget) - widget.setLayout(layout) - - grid = QtWidgets.QWidget(widget) - layout.addWidget(grid) - grid_layout = QtWidgets.QGridLayout(grid) - grid.setLayout(grid_layout) - - init_name = tag.name if tag is not None else "" - name_input = line_edit(grid, grid_layout, 0, "Name", init_name) - - init_color = tag.color if tag is not None else "#FFFFFF" - color_input = color_edit(grid, grid_layout, 1, "Color", QtGui.QColor(init_color)) - - tag_form_edition = gui.tags.panel.form.state.TagFormEdition( - init_name, - name_input.textChanged, - init_color, - color_input.textChanged) - - def on_validate(): - form = tag_form_edition.get() - if form: - on_validated(form) - - layout.addWidget(buttons( - parent = widget, - action_title = action_title, - tag_form_signal = tag_form_edition.signal(), - on_validate = on_validate, - on_cancel = on_cancel)) - - return widget - -def line_edit( - parent, - layout: QtWidgets.QGridLayout, - n: int, - label: str, - default_value: str) -> QtWidgets.QLineEdit: - - label = QtWidgets.QLabel(label, parent) - layout.addWidget(label, n, 0) - - edit = QtWidgets.QLineEdit(parent) - if default_value != None: - edit.setText(default_value) - layout.addWidget(edit, n, 1) - - return edit - -def color_edit( - parent, - layout: QtWidgets.QGridLayout, - n: int, - label: str, - init_color: QtGui.QColor) -> QtWidgets.QLineEdit: - - label = QtWidgets.QLabel(label, parent) - layout.addWidget(label, n, 0) - - edit = ColorInput(init_color, parent) - layout.addWidget(edit, n, 1) - - return edit - -class ColorInput(QtWidgets.QLineEdit): - - def __init__(self, init_color: QtGui.QColor, parent): - super().__init__(parent) - self.setReadOnly(True) - self.installEventFilter(self) - self._color = init_color - self.update(init_color) - self._is_editing = False - - def eventFilter(self, source, event): - if source is self and event.type() == QtCore.QEvent.FocusIn: - if not self._is_editing: - self._is_editing = True - color = QtWidgets.QColorDialog.getColor(self._color, self) - if color.isValid(): - self.update(color) - else: - self._is_editing = False - self.clearFocus() - return super(ColorInput, self).eventFilter(source, event) - - def update(self, color: QtGui.QColor): - self._color = color - self.setText(color.name().upper()) - palette = QtGui.QPalette() - palette.setColor(QtGui.QPalette.Base, color) - palette.setColor(QtGui.QPalette.Text, color) - self.setPalette(palette) - -def buttons(parent, action_title, tag_form_signal, on_validate, on_cancel): - widget = QtWidgets.QWidget(parent) - layout = QtWidgets.QHBoxLayout(widget) - - validate = QtWidgets.QPushButton(action_title, widget) - validate.setDisabled(True) - validate.setIcon(gui.icon.dialog_ok(validate.style())) - validate.clicked.connect(on_validate); - layout.addWidget(validate) - - def on_tag_form_signal(form: Optional[ValidTagForm]): - if form: - validate.setEnabled(True) - else: - validate.setDisabled(True) - - tag_form_signal.connect(on_tag_form_signal) - - cancel = QtWidgets.QPushButton("cancel", widget) - cancel.setIcon(gui.icon.dialog_cancel(cancel.style())) - cancel.clicked.connect(on_cancel) - layout.addWidget(cancel) - - return widget diff --git a/src/gui/tags/panel/signal.py b/src/gui/tags/panel/signal.py deleted file mode 100644 index 022abde..0000000 --- a/src/gui/tags/panel/signal.py +++ /dev/null @@ -1,27 +0,0 @@ -from PyQt5 import QtCore - -from model.tag import Tag - -class AddTag(QtCore.QObject): - _signal = QtCore.pyqtSignal(Tag, name = "addTag") - - def __init__(self): - QtCore.QObject.__init__(self) - - def emit(self, tag): - self._signal.emit(tag) - - def connect(self, f): - self._signal.connect(f) - -class UpdateTag(QtCore.QObject): - _signal = QtCore.pyqtSignal(int, Tag, name = "updateTag") - - def __init__(self): - QtCore.QObject.__init__(self) - - def emit(self, row, tag): - self._signal.emit(row, tag) - - def connect(self, f): - self._signal.connect(f) diff --git a/src/gui/tags/panel/table/menu.py b/src/gui/tags/panel/table/menu.py deleted file mode 100644 index f9ee148..0000000 --- a/src/gui/tags/panel/table/menu.py +++ /dev/null @@ -1,36 +0,0 @@ -from PyQt5 import QtWidgets, QtCore - -from model.tag import Tag, ValidTagForm -import database -import db.tags -import db.task_tags -import gui.tags.panel.dialog - -def open(table, update_tag_signal, position): - rows = set([index.row() for index in table.selectedIndexes()]) - - menu = QtWidgets.QMenu(table) - - actions = 0 - - if len(rows) == 1: - modify_action = menu.addAction(gui.icon.dialog_open(menu.style()), "modify") - actions += 1 - else: - modify_action = QtWidgets.QAction(menu) - - tags = table.model().row_ids(rows) - if not db.task_tags.one_is_used(database.cursor(), tags): - delete_action = menu.addAction(gui.icon.trash(menu.style()), "delete") - actions += 1 - else: - delete_action = QtWidgets.QAction(menu) - - if actions > 0: - action = menu.exec_(table.mapToGlobal(position + QtCore.QPoint(15, 20))) - if action == modify_action and len(rows) == 1: - row = list(rows)[0] - tag = table.model().get_at(row) - gui.tags.panel.dialog.update(table, update_tag_signal, row, tag).exec_() - elif action == delete_action: - gui.tags.panel.dialog.show_delete(table, rows) diff --git a/src/gui/tags/panel/table/model.py b/src/gui/tags/panel/table/model.py deleted file mode 100644 index 00ca785..0000000 --- a/src/gui/tags/panel/table/model.py +++ /dev/null @@ -1,104 +0,0 @@ -from PyQt5 import QtCore, QtWidgets, QtGui -from PyQt5.QtCore import Qt -from typing import List - -from model.tag import Tag -import time -import math -import util.array -import util.range -import gui.color - -columns = 1 - -headers = ["Name", "Color"] - -default_sort = (0, Qt.AscendingOrder) - -class TableModel(QtCore.QAbstractTableModel): - def __init__(self, tags): - super(TableModel, self).__init__() - self._tags = tags - - def headerData(self, section, orientation, role): - if role == Qt.DisplayRole and orientation == Qt.Horizontal: - return headers[section] - elif role == Qt.DisplayRole and orientation == Qt.Vertical: - return section + 1 - else: - return QtCore.QVariant() - - def data(self, index, role): - tag = self._tags[index.row()] - - if role == Qt.DisplayRole: - if index.column() == 0: - return tag.name - elif index.column() == 1: - return tag.color - elif role == Qt.BackgroundRole: - return QtGui.QBrush(QtGui.QColor(tag.color)) - else: - return QtCore.QVariant() - - def rowCount(self, index): - return len(self._tags) - - def columnCount(self, index): - return columns - - def get_at(self, row): - if row >= 0 and row < len(self._tags): - return self._tags[row] - - def insert_tag(self, header: QtWidgets.QHeaderView, tag: Tag) -> int: - at = self.insert_position(header, tag) - self.beginInsertRows(QtCore.QModelIndex(), at, at) - self._tags.insert(at, tag) - self.endInsertRows() - return at - - def insert_position(self, header: QtWidgets.QHeaderView, tag: Tag) -> int: - row = header.sortIndicatorSection() - order = header.sortIndicatorOrder() - is_rev = is_reversed(row, order) - return util.array.insert_position( - sort_key(tag, row, is_rev), - [sort_key(t, row, is_rev) for t in self._tags], - is_rev) - - def update_tag(self, header: QtWidgets.QHeaderView, row, tag: Tag) -> int: - self.delete_tag_range(row, 1) - return self.insert_tag(header, tag) - - def delete_tags(self, indexes): - for range in reversed(util.range.from_indexes(indexes)): - self.delete_tag_range(range.start, range.length) - return True - - def delete_tag_range(self, row, rows): - self.beginRemoveRows(QtCore.QModelIndex(), row, row + rows - 1) - self._tags = self._tags[:row] + self._tags[row + rows:] - self.endRemoveRows() - return True - - def row_ids(self, rows): - return [tag.id for i, tag in enumerate(self._tags) if i in rows] - - def sort(self, row: int, order: Qt.SortOrder): - self.layoutAboutToBeChanged.emit() - is_rev = is_reversed(row, order) - self._tags = sorted( - self._tags, - key = lambda tag: sort_key(tag, row, is_rev), - reverse = is_rev) - self.layoutChanged.emit() - -def sort_key(tag: Tag, row: int, is_reversed: bool): - if row == 0: - return tag.name.lower() - elif row == 1: - return tag.color - -def is_reversed(row: int, order: Qt.SortOrder) -> bool: - return order == Qt.DescendingOrder diff --git a/src/gui/tags/panel/table/widget.py b/src/gui/tags/panel/table/widget.py deleted file mode 100644 index 0ef67c2..0000000 --- a/src/gui/tags/panel/table/widget.py +++ /dev/null @@ -1,73 +0,0 @@ -from PyQt5 import QtWidgets -from PyQt5.QtCore import Qt - -from model.tag import Tag, ValidTagForm -import database -import db.tags -import db.task_tags -import gui.tags.panel.dialog -import gui.tags.panel.signal -import gui.tags.panel.table.menu -import gui.tags.panel.table.model - -class Widget(QtWidgets.QTableView): - - def __init__(self, parent, add_tag_signal): - super().__init__(parent) - - self._update_tag_signal = gui.tags.panel.signal.UpdateTag() - - tags = db.tags.get(database.cursor()) - table_model = gui.tags.panel.table.model.TableModel(tags) - - self.setModel(table_model) - self.sortByColumn( - gui.tags.panel.table.model.default_sort[0], - gui.tags.panel.table.model.default_sort[1]) - self.setSortingEnabled(True) - self.setSelectionBehavior(QtWidgets.QTableView.SelectRows) - self.horizontalHeader().setStretchLastSection(True) - self.resizeColumns() - - self.doubleClicked.connect(lambda index: self.on_double_click(index.row())) - - # # Menu - self.setContextMenuPolicy(Qt.CustomContextMenu) - self.customContextMenuRequested.connect(lambda position: gui.tags.panel.table.menu.open(self, self._update_tag_signal, position)) - - add_tag_signal.connect(lambda tag: self.insert(tag)) - self._update_tag_signal.connect(lambda row, tag: self.update(row, tag)) - - def insert(self, tag): - self.model().insert_tag(self.horizontalHeader(), tag) - self.resizeColumns() - - def update(self, row, tag): - row = self.model().update_tag(self.horizontalHeader(), row, tag) - self.selectRow(row) - self.resizeColumns() - - def resizeColumns(self): - for column in range(gui.tags.panel.table.model.columns): - self.resizeColumnToContents(column) - - def keyPressEvent(self, event): - super().keyPressEvent(event) - if event.key() in (Qt.Key_Return, Qt.Key_Enter): - rows = self.get_selected_rows() - if len(rows) == 1: - row = rows[0] - tag = self.model().get_at(row) - gui.tags.panel.dialog.update(self, self._update_tag_signal, row, tag).exec_() - elif event.key() == Qt.Key_Delete: - rows = self.get_selected_rows() - tags = self.model().row_ids(rows) - if not db.task_tags.one_is_used(database.cursor(), tags): - gui.tags.panel.dialog.show_delete(self, rows) - - def get_selected_rows(self): - return list(set([index.row() for index in self.selectedIndexes()])) - - def on_double_click(self, row: int): - tag = self.model().get_at(row) - gui.tags.panel.dialog.update(self, self._update_tag_signal, row, tag).exec_() diff --git a/src/gui/tags/panel/widget.py b/src/gui/tags/panel/widget.py deleted file mode 100644 index faca1da..0000000 --- a/src/gui/tags/panel/widget.py +++ /dev/null @@ -1,30 +0,0 @@ -from PyQt5 import QtWidgets - -import gui.tags.panel.dialog -import gui.tags.panel.signal -import gui.tags.panel.table.widget -import gui.icon - -def widget(parent): - widget = QtWidgets.QWidget(parent) - - layout = QtWidgets.QVBoxLayout(widget) - widget.setLayout(layout) - - layout.addSpacing(15) - - add_tag_signal = gui.tags.panel.signal.AddTag() - - add_tag_button = QtWidgets.QPushButton(" Add a tag", widget) - add_tag_button.setFixedHeight(30) - add_tag_button.setIcon(gui.icon.new_folder(widget.style())) - - add_tag_button.clicked.connect(lambda: gui.tags.panel.dialog.add(widget, add_tag_signal).exec_()) - layout.addWidget(add_tag_button) - - layout.addSpacing(20) - - table = gui.tags.panel.table.widget.Widget(widget, add_tag_signal) - layout.addWidget(table) - - return widget diff --git a/src/gui/tasks/dialog.py b/src/gui/tasks/dialog.py deleted file mode 100644 index 2bf3b6b..0000000 --- a/src/gui/tasks/dialog.py +++ /dev/null @@ -1,78 +0,0 @@ -from PyQt5 import QtCore, QtWidgets -from typing import List - -from model.tag import Tag -from model.task import Task, ValidTaskForm -from model.status import Status -import database -import db.task_tags -import db.tasks -import gui.tasks.form.widget -import service.tasks - -def add(parent_widget, status: Status, add_task_signal): - - def on_add(task_form: ValidTaskForm): - task = service.tasks.create(database.cursor(), status, task_form) - add_task_signal.emit(task, task_form.tags) - - return widget(parent_widget, "Add a task", "add", None, [], on_add) - -def update(parent_widget, update_task_signal, row: int, task: Task, tags: List[int]): - - def on_update(task_form: ValidTaskForm): - updated_task = service.tasks.update(database.cursor(), task, task_form) - update_task_signal.emit(row, updated_task, task_form.tags) - - return widget(parent_widget, "Modify a task", "modify", task, tags, on_update) - -def confirm_delete(parent, rows: List[int], on_confirm): - confirm = QtWidgets.QMessageBox.question( - parent, - "Task deletion", - "Do you really want to delete the selected tasks ?", - QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.Yes) - - if confirm == QtWidgets.QMessageBox.Yes: - on_confirm() - -def confirm_move(parent, rows: List[int], move_to: Status, on_confirm): - confirm = QtWidgets.QMessageBox.question( - parent, - "Task move", - "Do you really want to move the selected tasks ?", - QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.Yes) - - if confirm == QtWidgets.QMessageBox.Yes: - on_confirm() - -def widget( - parent: QtWidgets.QWidget, - title: str, - action_title: str, - task: Task, - tags: List[int], - on_validated): - - dialog = QtWidgets.QDialog(parent) - dialog.setWindowTitle(title) - dialog.setMinimumSize(QtCore.QSize(320, 240)) - - layout = QtWidgets.QVBoxLayout(dialog) - dialog.setLayout(layout) - - def on_dialog_validated(form): - dialog.accept() - on_validated(form) - - layout.addWidget(gui.tasks.form.widget.widget( - parent = dialog, - action_title = action_title, - task = task, - tags = tags, - on_validated = on_dialog_validated, - on_cancel = lambda: dialog.reject())) - - return dialog diff --git a/src/gui/tasks/duration.py b/src/gui/tasks/duration.py deleted file mode 100644 index dc948e6..0000000 --- a/src/gui/tasks/duration.py +++ /dev/null @@ -1,50 +0,0 @@ -from PyQt5 import QtGui -from typing import Optional -import math -import re - -import gui.color - -def format(minutes: int): - if minutes >= 60 * 24: - return "" + format_decimal(minutes / 60 / 24) + "d" - elif minutes >= 60: - return "" + format_decimal(minutes / 60) + "h" - elif minutes > 0: - return "" + str(minutes) + "m" - else: - return "" - -def format_decimal(d: float) -> str: - return "{0:.2g}".format(d) - -def parse(duration: str) -> Optional[int]: - duration = duration.strip() - if duration: - result = re.match("^(\d+)(\.(\d+))?([mhd])$", duration.strip()) - if result: - n = int(result.group(1)) - if result.group(3): - d = int(result.group(3)) * pow(10, -1 * len(result.group(3))) - else: - d = 0 - num = n + d - unit = result.group(4) - if unit == "m": - return math.floor(num) - elif unit == "h": - return math.floor(num * 60) - elif unit == "d": - return math.floor(num * 60 * 24) - else: - return None - else: - return 0 - -def color(minutes: int): - if minutes <= 15: - return gui.color.short_duration - elif minutes < 60: - return gui.color.medium_duration - else: - return gui.color.long_duration diff --git a/src/gui/tasks/form/state.py b/src/gui/tasks/form/state.py deleted file mode 100644 index 09e658e..0000000 --- a/src/gui/tasks/form/state.py +++ /dev/null @@ -1,100 +0,0 @@ -from PyQt5 import QtCore -from typing import Optional - -from model.task import ValidTaskForm -from model.difficulty import Difficulty -from model.priority import Priority -import gui.tasks.duration -import gui.tags.list - -class TaskFormEdition: - def __init__( - self, - name, - name_signal, - duration, - duration_signal, - difficulty, - difficulty_signal, - priority, - priority_signal, - tags_signal: gui.tags.list.SelectionSignal, - description, - description_signal): - - self._name = name - self._duration = duration - self._difficulty = difficulty - self._priority = priority - self._tags = [] - self._description = description - self._signal = ValidTaskFormSignal() - - name_signal.connect(lambda n: self.on_name_signal(n)) - duration_signal.connect(lambda d: self.on_duration_signal(d)) - difficulty_signal.connect(lambda d: self.on_difficulty_signal(d)) - priority_signal.connect(lambda p: self.on_priority_signal(p)) - tags_signal.connect(lambda ts: self.on_tags_signal(ts)) - description_signal.connect(lambda d: self.on_description_signal(d)) - - def get(self) -> Optional[ValidTaskForm]: - name = self._name.strip() - duration = gui.tasks.duration.parse(self._duration) - difficulty = self._difficulty - priority = self._priority - description = self._description.strip() - - if name and duration != None: - return ValidTaskForm( - name = name, - duration = duration, - difficulty = difficulty, - priority = priority, - tags = self._tags, - description = description) - else: - return None - - def on_name_signal(self, name: str): - self._name = name - self.emit() - - def on_duration_signal(self, duration: str): - self._duration = duration - self.emit() - - def on_difficulty_signal(self, index: int): - self._difficulty = Difficulty(index) - self.emit() - - def on_priority_signal(self, index: int): - self._priority = Priority(index) - self.emit() - - def on_tags_signal(self, tags: [int]): - self._tags = tags - self.emit() - - def on_description_signal(self, description: str): - self._description = description - self.emit() - - def emit(self): - validForm = self.get() - if validForm: - self._signal.emit(validForm) - - def signal(self): - return self._signal - -class ValidTaskFormSignal(QtCore.QObject): - _signal = QtCore.pyqtSignal(ValidTaskForm, name = "validTaskForm") - - def __init__(self): - QtCore.QObject.__init__(self) - - def emit(self, form: Optional[ValidTaskForm]): - self._signal.emit(form) - - def connect(self, f): - self._signal.connect(f) diff --git a/src/gui/tasks/form/widget.py b/src/gui/tasks/form/widget.py deleted file mode 100644 index 70d506d..0000000 --- a/src/gui/tasks/form/widget.py +++ /dev/null @@ -1,184 +0,0 @@ -from PyQt5 import QtWidgets, QtCore -from typing import Optional, Tuple, List, Any - -from model.task import Task, ValidTaskForm -from model.tag import Tag -from model import difficulty, priority -import gui.icon -import gui.tasks.form.state -import gui.tasks.duration -import gui.tags.list - -class TextEditSignal(QtCore.QObject): - _signal = QtCore.pyqtSignal(str, name = "textEdit") - - def __init__(self): - QtCore.QObject.__init__(self) - - def emit(self, text: str): - self._signal.emit(text) - - def connect(self, f): - self._signal.connect(f) - -def widget( - parent: QtWidgets.QWidget, - action_title: str, - task: Task, - tags: List[int], - on_validated, - on_cancel): - - widget = QtWidgets.QWidget(parent) - layout = QtWidgets.QVBoxLayout(widget) - widget.setLayout(layout) - - grid = QtWidgets.QWidget(widget) - layout.addWidget(grid) - grid_layout = QtWidgets.QGridLayout(grid) - grid.setLayout(grid_layout) - - init_name = task.name if task is not None else "" - name_input = line_edit(grid, grid_layout, 0, "Name", init_name) - - init_duration = gui.tasks.duration.format(task.duration) if task is not None else "" - duration_input = line_edit(grid, grid_layout, 1, "Duration", init_duration) - - init_difficulty = task.difficulty if task is not None else difficulty.Difficulty.NORMAL - difficulty_input = combo_box( - grid, - grid_layout, - 3, - "Difficulty", - [difficulty.format(d) for d in difficulty.values], - int(init_difficulty)) - - init_priority = task.priority if task is not None else priority.Priority.MIDDLE - priority_input = combo_box( - grid, - grid_layout, - 4, - "Priority", - [priority.format(d) for d in priority.values], - int(init_priority)) - - (tags_list_widget, tags_signal) = tags_selection(widget, tags) - layout.addWidget(tags_list_widget) - - init_description = task.description if task is not None else "" - (description_input, description_signal) = text_edit(widget, "Description", init_description) - layout.addWidget(description_input) - - task_form_edition = gui.tasks.form.state.TaskFormEdition( - init_name, - name_input.textChanged, - init_duration, - duration_input.textChanged, - init_difficulty, - difficulty_input.currentIndexChanged, - init_priority, - priority_input.currentIndexChanged, - tags_signal, - init_description, - description_signal) - - def on_validate(): - form = task_form_edition.get() - if form: - on_validated(form) - - layout.addWidget(buttons( - parent = widget, - action_title = action_title, - task_form_signal = task_form_edition.signal(), - on_validate = on_validate, - on_cancel = on_cancel)) - - return widget - -def line_edit( - parent, - layout: QtWidgets.QGridLayout, - n: int, - label: str, - default_value: str) -> QtWidgets.QLineEdit: - - label = QtWidgets.QLabel(label, parent) - layout.addWidget(label, n, 0) - - edit = QtWidgets.QLineEdit(parent) - if default_value != None: - edit.setText(default_value) - layout.addWidget(edit, n, 1) - - return edit - -def combo_box( - parent, - layout: QtWidgets.QGridLayout, - n: int, - label: str, - values: List[str], - default_value: int) -> QtWidgets.QComboBox: - - label = QtWidgets.QLabel(label, parent) - layout.addWidget(label, n, 0) - - box = QtWidgets.QComboBox(parent) - for value in values: - box.addItem(value) - if default_value != None: - box.setCurrentIndex(default_value) - layout.addWidget(box, n, 1) - - return box - -def tags_selection(parent, init_tags: List[int]) -> QtWidgets.QWidget: - return gui.tags.list.widget(parent, init_tags) - -def text_edit( - parent, - label: str, - default_value: str) -> Tuple[QtWidgets.QWidget, TextEditSignal]: - - widget = QtWidgets.QWidget(parent) - layout = QtWidgets.QVBoxLayout(widget) - - signal = TextEditSignal() - - label = QtWidgets.QLabel(label, parent) - layout.addWidget(label) - - edit = QtWidgets.QTextEdit(parent) - if default_value != None: - edit.insertPlainText(default_value) - layout.addWidget(edit) - - edit.textChanged.connect(lambda: signal.emit(edit.toPlainText())) - - return (widget, signal) - -def buttons(parent, action_title, task_form_signal, on_validate, on_cancel): - widget = QtWidgets.QWidget(parent) - layout = QtWidgets.QHBoxLayout(widget) - - validate = QtWidgets.QPushButton(action_title, widget) - validate.setDisabled(True) - validate.setIcon(gui.icon.dialog_ok(validate.style())) - validate.clicked.connect(on_validate); - layout.addWidget(validate) - - def on_task_form_signal(form: Optional[ValidTaskForm]): - if form: - validate.setEnabled(True) - else: - validate.setDisabled(True) - - task_form_signal.connect(on_task_form_signal) - - cancel = QtWidgets.QPushButton("cancel", widget) - cancel.setIcon(gui.icon.dialog_cancel(cancel.style())) - cancel.clicked.connect(on_cancel) - layout.addWidget(cancel) - - return widget diff --git a/src/gui/tasks/signal.py b/src/gui/tasks/signal.py deleted file mode 100644 index 074e8ec..0000000 --- a/src/gui/tasks/signal.py +++ /dev/null @@ -1,28 +0,0 @@ -from PyQt5 import QtCore -from typing import List - -from model.task import Task - -class AddTask(QtCore.QObject): - _signal = QtCore.pyqtSignal(Task, list, name = "addTask") - - def __init__(self): - QtCore.QObject.__init__(self) - - def emit(self, task: Task, tags: List[int]): - self._signal.emit(task, tags) - - def connect(self, f): - self._signal.connect(f) - -class UpdateTask(QtCore.QObject): - _signal = QtCore.pyqtSignal(int, Task, list, name = "updateTask") - - def __init__(self): - QtCore.QObject.__init__(self) - - def emit(self, row: int, task: Task, tags: List[int]): - self._signal.emit(row, task, tags) - - def connect(self, f): - self._signal.connect(f) diff --git a/src/gui/tasks/table/menu.py b/src/gui/tasks/table/menu.py deleted file mode 100644 index 5356be2..0000000 --- a/src/gui/tasks/table/menu.py +++ /dev/null @@ -1,49 +0,0 @@ -from PyQt5 import QtWidgets, QtCore -from typing import List - -import db.tasks -import gui.tasks.dialog -from model.status import Status -from model.task import Task, ValidTaskForm -from model.tag import Tag - -def open(table: QtWidgets.QTableWidget, status: Status, update_task_signal, position): - rows = set([index.row() for index in table.selectedIndexes()]) - - menu = QtWidgets.QMenu(table) - - if len(rows) == 1: - modify_action = menu.addAction(gui.icon.dialog_open(menu.style()), "modify") - else: - modify_action = QtWidgets.QAction(menu) - - delete_action = menu.addAction(gui.icon.trash(menu.style()), "delete") - - if status != Status.READY: - move_to_ready = menu.addAction(gui.icon.task_ready(menu.style()), "move to ready") - else: - move_to_ready = QtWidgets.QAction(menu) - - if status != Status.WAITING: - move_to_waiting = menu.addAction(gui.icon.task_waiting(menu.style()), "move to waiting") - else: - move_to_waiting = QtWidgets.QAction(menu) - - if status != Status.MAYBE: - move_to_maybe = menu.addAction(gui.icon.task_maybe(menu.style()), "move to maybe") - else: - move_to_maybe = QtWidgets.QAction(menu) - - action = menu.exec_(table.mapToGlobal(position + QtCore.QPoint(15, 20))) - if action == modify_action and len(rows) == 1: - row = list(rows)[0] - (task, tags) = table.get_at(row) - gui.tasks.dialog.update(table, update_task_signal, row, task, tags).exec_() - elif action == delete_action: - gui.tasks.dialog.confirm_delete(table, rows, lambda: table.delete_rows(rows)) - elif action == move_to_ready: - gui.tasks.dialog.confirm_move(table, rows, Status.READY, lambda: table.update_status(rows, Status.READY)) - elif action == move_to_waiting: - gui.tasks.dialog.confirm_move(table, rows, Status.WAITING, lambda: table.update_status(rows, Status.WAITING)) - elif action == move_to_maybe: - gui.tasks.dialog.confirm_move(table, rows, Status.MAYBE, lambda: table.update_status(rows, Status.MAYBE)) diff --git a/src/gui/tasks/table/widget.py b/src/gui/tasks/table/widget.py deleted file mode 100644 index aacae2f..0000000 --- a/src/gui/tasks/table/widget.py +++ /dev/null @@ -1,284 +0,0 @@ -from PyQt5 import QtWidgets, QtCore, QtGui -from PyQt5.QtCore import Qt -from typing import List, Tuple -import time -import math - -from model import difficulty, priority -from model.difficulty import Difficulty -from model.priority import Priority -from model.tag import Tag -from model.task import Task -from model.task_tag import TaskTag -from model.status import Status -import database -import db.tags -import db.task_tags -import gui.color -import gui.signal -import gui.tasks.dialog -import gui.tasks.duration -import gui.tasks.signal -import gui.tasks.signal -import gui.tasks.table.menu -import service.tasks -import util.array -import util.range - -class Widget(QtWidgets.QTableWidget): - def __init__( - self, - parent, - on_show: gui.signal.Reload, - add_task_signal: gui.tasks.signal.AddTask, - status: Status): - super().__init__(parent) - - self.init_state(status) - self.sort() - - self.setSelectionBehavior(QtWidgets.QTableView.SelectRows) - self.init_header() - self.setRowCount(len(self._tasks)) - self.setColumnCount(len(self.header_labels())) - self.setColumnWidth(1, 500) - - self.update_view() - self.horizontalHeader().setStretchLastSection(True) - - # Menu - self.setContextMenuPolicy(Qt.CustomContextMenu) - self.customContextMenuRequested.connect(lambda position: gui.tasks.table.menu.open(self, status, self._update_task_signal, position)) - - self.doubleClicked.connect(lambda index: self.on_double_click(index.row())) - add_task_signal.connect(lambda task, tags: self.insert(task, tags)) - self._update_task_signal.connect(lambda row, task, tags: self.update_task(row, task, tags)) - on_show.connect(lambda: self.on_show()) - - def init_state(self, status: Status): - self._update_task_signal = gui.tasks.signal.UpdateTask() - cursor = database.cursor() - self._status = status - self._tasks = service.tasks.get(cursor, self._status) - self._task_tags = db.task_tags.get(cursor) - self._tags = db.tags.get(cursor) - self._sort_column = 0 - self._sort_is_ascending = True - - def init_header(self): - self._header_view = QtWidgets.QHeaderView(Qt.Horizontal, self) - self._header_model = QtGui.QStandardItemModel() - self._header_model.setHorizontalHeaderLabels(self.header_labels()) - self._header_view.setModel(self._header_model) - self._header_view.setSectionsClickable(True) - self._header_view.sectionClicked.connect(self.on_header_click) - self.setHorizontalHeader(self._header_view) - - def on_show(self): - cursor = database.cursor() - self._tasks = service.tasks.get(cursor, self._status) - self._task_tags = db.task_tags.get(cursor) - self._tags = db.tags.get(cursor) - self.setRowCount(len(self._tasks)) - self.sort() - self.update_view() - - def on_header_click(self, column): - if self._sort_column == column: - self._sort_is_ascending = not self._sort_is_ascending - else: - self._sort_is_ascending = True - self._sort_column = column - self.sort() - self._header_model.setHorizontalHeaderLabels(self.header_labels()) - self.update_view() - - def sort(self): - is_rev = self.is_reversed() - self._tasks = sorted( - self._tasks, - key = lambda task: self.sort_key(task, is_rev), - reverse = is_rev) - - def update_task(self, row, task: Task, tags: List[int]): - self._tasks[row] = task - filtred_task_tags = [tt for tt in self._task_tags if tt.task_id in [t.id for t in self._tasks if t.id != task.id]] - new_task_tags = [TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tags] - self._task_tags = filtred_task_tags + new_task_tags - - # Update task in table - self.sort() - row_after_sort = [i for i in range(len(self._tasks)) if self._tasks[i].id == task.id][0] - if row_after_sort == row: - self.update_row(row) - else: - self.removeRow(row) - self.insertRow(row_after_sort) - self.update_row(row_after_sort) - self.selectRow(row_after_sort) - - def update_view(self): - for row in range(len(self._tasks)): - self.update_row(row) - - def update_row(self, row: int): - task = self._tasks[row] - self.setItem(row, 0, item(age_since(task.created_at))) - self.setItem(row, 1, item(task.name)) - self.setCellWidget(row, 2, colored_label(self, gui.tasks.duration.format(task.duration), gui.tasks.duration.color(task.duration))) - self.setCellWidget(row, 3, colored_label(self, difficulty.format(task.difficulty), difficulty_color(task.difficulty))) - self.setCellWidget(row, 4, colored_label(self, priority.format(task.priority), priority_color(task.priority))) - tag_ids = [tt.tag_id for tt in self._task_tags if tt.task_id == task.id] - res_tags = sorted([tag for tag in self._tags if tag.id in tag_ids], key=lambda t: t.name) - self.setCellWidget(row, 5, render_tags(self, res_tags)) - self.setRowHeight(row, 45) - - def insert(self, task: Task, tags: List[int]) -> int: - is_rev = self.is_reversed() - row = util.array.insert_position( - self.sort_key(task, is_rev), - [self.sort_key(t, is_rev) for t in self._tasks], - is_rev) - self._tasks.insert(row, task) - self._task_tags += [TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tags] - self.insertRow(row) - self.update_row(row) - self._task_tags += [TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tags] - self.setRowCount(len(self._tasks)) - return row - - def is_reversed(self) -> bool: - if self._sort_column == 0: - return self._sort_is_ascending - else: - return not self._sort_is_ascending - - def sort_key(self, task: Task, is_reversed: bool): - row = self._sort_column - if row == 0: - return task.created_at - elif row == 1: - return task.name.lower() - elif row == 2: - if is_reversed: - return task.duration - else: - return (task.duration == 0, task.duration) - elif row == 3: - return task.difficulty - elif row == 4: - return task.priority - elif row == 5: - tag_ids = [tt.tag_id for tt in self._task_tags if tt.task_id == task.id] - tags = sorted([tag.name.lower() for tag in self._tags if tag.id in tag_ids]) - key = "".join(tags) - if is_reversed: - return key - else: - return (key == "", key) - - def keyPressEvent(self, event): - super().keyPressEvent(event) - if event.key() in (Qt.Key_Return, Qt.Key_Enter): - rows = self.get_selected_rows() - if len(rows) == 1: - row = rows[0] - (task, tags) = self.get_at(row) - gui.tasks.dialog.update(self, self._update_task_signal, row, task, tags).exec_() - elif event.key() == Qt.Key_Delete: - rows = self.get_selected_rows() - gui.tasks.dialog.show_delete(self, rows, lambda: self.delete_rows(rows)) - - def delete_rows(self, rows: List[int]): - task_ids = [task.id for i, task in enumerate(self._tasks) if i in rows] - service.tasks.delete(database.cursor(), task_ids) - self.remove_rows_from_table(rows, task_ids) - - def update_status(self, rows: List[int], status: Status): - task_ids = [task.id for i, task in enumerate(self._tasks) if i in rows] - service.tasks.update_status(database.cursor(), task_ids, status) - self.remove_rows_from_table(rows, task_ids) - - def remove_rows_from_table(self, rows: List[int], task_ids: List[int]): - for row in sorted(rows, reverse=True): - self.removeRow(row) - self._tasks = [t for t in self._tasks if t.id not in task_ids] - self._task_tags = [tt for tt in self._task_tags if tt.task_id in [t.id for t in self._tasks]] - self.setRowCount(len(self._tasks)) - - def get_selected_rows(self): - return list(set([index.row() for index in self.selectedIndexes()])) - - def on_double_click(self, row: int): - (task, tags) = self.get_at(row) - gui.tasks.dialog.update(self, self._update_task_signal, row, task, tags).exec_() - - def get_at(self, row: int) -> Tuple[Task, List[int]]: - task = self._tasks[row] - tags = [tt.tag_id for tt in self._task_tags if tt.task_id == task.id] - return (task, tags) - - def header_labels(self): - labels = ["Age", "Name", "Duration", "Difficulty", "Priority", "Tag"] - if self._sort_is_ascending: - sign = "▼" - else: - sign = "▲" - labels[self._sort_column] = labels[self._sort_column] + " " + sign - return labels - -def item(text: str): - item = QtWidgets.QTableWidgetItem(text) - item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - return item - -def age_since(timestamp): - diff = int(time.time()) - timestamp - if diff >= 60 * 60 * 24: - return "" + str(math.floor(diff / 60 / 60 / 24)) + "d" - elif diff >= 60 * 60: - return "" + str(math.floor(diff / 60 / 60)) + "h" - elif diff >= 60: - return "" + str(math.floor(diff / 60)) + "m" - else: - return "1m" - -def colored_label(parent, text: str, color: QtGui.QColor): - label = QtWidgets.QLabel(text) - palette = QtGui.QPalette() - palette.setColor(QtGui.QPalette.Text, color) - label.setPalette(palette) - return label - -def render_tags(parent, tags: List[Tag]): - widget = QtWidgets.QWidget(parent) - - layout = QtWidgets.QHBoxLayout(widget) - widget.setLayout(layout) - - for tag in tags: - label = QtWidgets.QLabel(tag.name) - label.setContentsMargins(3, 3, 3, 3) - palette = QtGui.QPalette() - palette.setColor(QtGui.QPalette.Base, QtGui.QColor(tag.color)) - label.setAutoFillBackground(True) - label.setPalette(palette) - layout.addWidget(label) - - return widget - -def difficulty_color(d: Difficulty) -> QtGui.QColor: - if d == Difficulty.EASY: - return gui.color.easy_difficulty - elif d == Difficulty.NORMAL: - return gui.color.normal_difficulty - elif d == Difficulty.HARD: - return gui.color.hard_difficulty - -def priority_color(p: Priority) -> QtGui.QColor: - if p == Priority.LOW: - return gui.color.low_priority - elif p == Priority.MIDDLE: - return gui.color.middle_priority - elif p == Priority.HIGH: - return gui.color.high_priority diff --git a/src/gui/tasks/test_duration.py b/src/gui/tasks/test_duration.py deleted file mode 100644 index 9d5d9b8..0000000 --- a/src/gui/tasks/test_duration.py +++ /dev/null @@ -1,21 +0,0 @@ -from gui.tasks.duration import format, parse - -def test_format(): - assert format(0) == "" - assert format(0.5) == "0.5m" - assert format(35) == "35m" - assert format(60) == "1h" - assert format(61) == "1h" - assert format(90) == "1.5h" - assert format(1440) == "1d" - -def test_parse(): - assert parse("") == 0 - assert parse("42") == None - assert parse("hey") == None - assert parse("1h30") == None - assert parse("1h30m") == None - assert parse("17m") == 17 - assert parse("90m") == 90 - assert parse("1.5h") == 90 - assert parse("2d") == 2880 diff --git a/src/gui/tasks/widget.py b/src/gui/tasks/widget.py deleted file mode 100644 index 87b15d3..0000000 --- a/src/gui/tasks/widget.py +++ /dev/null @@ -1,30 +0,0 @@ -from PyQt5 import QtWidgets - -from model.status import Status -import gui.tasks.signal -import gui.tasks.table.widget -import gui.icon -import gui.signal - -def widget(parent, on_show: gui.signal.Reload, status: Status): - widget = QtWidgets.QWidget(parent) - - layout = QtWidgets.QVBoxLayout(widget) - widget.setLayout(layout) - - layout.addSpacing(15) - - add_task_signal = gui.tasks.signal.AddTask() - - add_task_button = QtWidgets.QPushButton(" Add a task", widget) - add_task_button.setFixedHeight(30) - add_task_button.setIcon(gui.icon.new_folder(widget.style())) - add_task_button.clicked.connect(lambda: gui.tasks.dialog.add(widget, status, add_task_signal).exec_()) - layout.addWidget(add_task_button) - - layout.addSpacing(20) - - table = gui.tasks.table.widget.Widget(widget, on_show, add_task_signal, status) - layout.addWidget(table) - - return widget diff --git a/src/gui/window.py b/src/gui/window.py deleted file mode 100644 index 584fda6..0000000 --- a/src/gui/window.py +++ /dev/null @@ -1,36 +0,0 @@ -from PyQt5 import QtCore, QtWidgets - -import gui.tasks.widget -import gui.tasks.widget -import gui.tags.panel.widget -import gui.signal -from model.status import Status - -def get(): - window = QtWidgets.QMainWindow() - window.setWindowTitle("todo-next") - window.setMinimumSize(QtCore.QSize(640, 480)) - - tabs = QtWidgets.QTabWidget(window) - window.setCentralWidget(tabs) - - show_ready = gui.signal.Reload() - show_waiting = gui.signal.Reload() - show_maybe = gui.signal.Reload() - - def on_current_tab_changed(index: int): - if index == 0: - show_ready.emit() - elif index == 1: - show_waiting.emit() - elif index == 2: - show_maybe.emit() - - tabs.currentChanged.connect(on_current_tab_changed) - - tabs.addTab(gui.tasks.widget.widget(tabs, show_ready, Status.READY), "Ready") - tabs.addTab(gui.tasks.widget.widget(tabs, show_waiting, Status.WAITING), "Waiting") - tabs.addTab(gui.tasks.widget.widget(tabs, show_maybe, Status.MAYBE), "Maybe") - tabs.addTab(gui.tags.panel.widget.widget(tabs), "Tags") - - return window diff --git a/src/main.py b/src/main.py deleted file mode 100644 index f89872a..0000000 --- a/src/main.py +++ /dev/null @@ -1,20 +0,0 @@ -import sys -from PyQt5 import QtCore, QtWidgets -import sqlite3 -import os.path - -import db.init -import gui.window -import arguments -import database - -args = arguments.parser().parse_args() -database.init(args.database if args.database != None else "database") -app = QtWidgets.QApplication(sys.argv) - -window = gui.window.get() -window.show() -res = app.exec_() - -database.close() -sys.exit(res) diff --git a/src/model/difficulty.py b/src/model/difficulty.py deleted file mode 100644 index 526cdb9..0000000 --- a/src/model/difficulty.py +++ /dev/null @@ -1,30 +0,0 @@ -from enum import IntEnum -from typing import Optional - -class Difficulty(IntEnum): - EASY = 0 - NORMAL = 1 - HARD = 2 - -values = [ - Difficulty.EASY, - Difficulty.NORMAL, - Difficulty.HARD] - -def format(difficulty: Difficulty) -> str: - if difficulty == Difficulty.EASY: - return "Easy" - elif difficulty == Difficulty.NORMAL: - return "Normal" - elif difficulty == Difficulty.HARD: - return "Hard" - -def parse(string: str) -> Optional[Difficulty]: - if string == "Easy": - return Difficulty.EASY - elif string == "Normal": - return Difficulty.NORMAL - elif string == "Hard": - return Difficulty.HARD - else: - return None diff --git a/src/model/priority.py b/src/model/priority.py deleted file mode 100644 index 5948104..0000000 --- a/src/model/priority.py +++ /dev/null @@ -1,30 +0,0 @@ -from enum import IntEnum -from typing import Optional - -class Priority(IntEnum): - LOW = 0 - MIDDLE = 1 - HIGH = 2 - -values = [ - Priority.LOW, - Priority.MIDDLE, - Priority.HIGH] - -def format(priority: Priority) -> str: - if priority == Priority.LOW: - return "Low" - elif priority == Priority.MIDDLE: - return "Middle" - elif priority == Priority.HIGH: - return "High" - -def parse(string: str) -> Optional[Priority]: - if string == "Low": - return Priority.LOW - elif string == "Middle": - return Priority.MIDDLE - elif string == "High": - return Priority.HIGH - else: - return None diff --git a/src/model/status.py b/src/model/status.py deleted file mode 100644 index 6881e0a..0000000 --- a/src/model/status.py +++ /dev/null @@ -1,30 +0,0 @@ -from enum import IntEnum -from typing import Optional - -class Status(IntEnum): - READY = 0 - WAITING = 1 - MAYBE = 2 - -values = [ - Status.READY, - Status.WAITING, - Status.MAYBE] - -def format(status: Status) -> str: - if status == Status.READY: - return "Ready" - elif status == Status.WAITING: - return "Waiting" - elif status == Status.MAYBE: - return "Maybe" - -def parse(string: str) -> Optional[Status]: - if string == "Ready": - return Status.READY - elif string == "Waiting": - return Status.WAITING - elif string == "Maybe": - return Status.MAYBE - else: - return None diff --git a/src/model/tag.py b/src/model/tag.py deleted file mode 100644 index 030b223..0000000 --- a/src/model/tag.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import NamedTuple - -class Tag(NamedTuple): - id: int - created_at: int - updated_at: int - name: str - color: str - -class ValidTagForm(NamedTuple): - name: str - color: str diff --git a/src/model/task.py b/src/model/task.py deleted file mode 100644 index 69f9807..0000000 --- a/src/model/task.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import NamedTuple, List - -from model.difficulty import Difficulty -from model.priority import Priority - -class Task(NamedTuple): - id: int - created_at: int - updated_at: int - name: str - duration: int - difficulty: Difficulty - priority: Priority - description: str - -class ValidTaskForm(NamedTuple): - name: str - duration: int - difficulty: Difficulty - priority: Priority - tags: List[int] - description: str diff --git a/src/model/task_tag.py b/src/model/task_tag.py deleted file mode 100644 index 0a33c66..0000000 --- a/src/model/task_tag.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import NamedTuple - -class TaskTag(NamedTuple): - task_id: int - tag_id: int diff --git a/src/service/tasks.py b/src/service/tasks.py deleted file mode 100644 index 87194c4..0000000 --- a/src/service/tasks.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import List - -from model.task import Task, ValidTaskForm -from model.status import Status -import db.tasks -import db.task_tags -import database - -def get(cursor, status: Status) -> List[Task]: - return db.tasks.get(cursor, status) - -def create(cursor, status: Status, task_form: ValidTaskForm) -> Task: - task = db.tasks.insert(cursor, status, task_form) - db.task_tags.insert_many(cursor, task.id, task_form.tags) - database.commit() - return task - -def update(cursor, task: Task, task_form: ValidTaskForm) -> Task: - db.task_tags.delete(cursor, [task.id]) - updated_task = db.tasks.update(cursor, task, task_form) - db.task_tags.insert_many(cursor, task.id, task_form.tags) - database.commit() - return updated_task - -def delete(cursor, task_ids: List[int]): - db.task_tags.delete(cursor, task_ids) - db.tasks.delete(cursor, task_ids) - database.commit() - -def update_status(cursor, task_ids: List[int], status: Status) -> List[Task]: - db.tasks.update_status(cursor, task_ids, status) - database.commit() diff --git a/src/util/array.py b/src/util/array.py deleted file mode 100644 index bb4eee3..0000000 --- a/src/util/array.py +++ /dev/null @@ -1,5 +0,0 @@ -def insert_position(x, xs, is_reversed: bool) -> int: - for i, y in enumerate(xs): - if is_reversed and x >= y or not is_reversed and x <= y: - return i - return len(xs) diff --git a/src/util/range.py b/src/util/range.py deleted file mode 100644 index bd4b27e..0000000 --- a/src/util/range.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import NamedTuple, List - -class Range(NamedTuple): - start: int - length: int - -def from_indexes(indexes: List[int]) -> List[Range]: - ranges = [] - curr_range_start = 0 - curr_range_len = 0 - - last_index = -1 - - for index in sorted(indexes): - if index == curr_range_start + curr_range_len: - curr_range_len += 1 - else: - if curr_range_len > 0: - ranges.append(Range( - start = curr_range_start, - length = curr_range_len)) - curr_range_start = index - curr_range_len = 1 - - if curr_range_len > 0: - ranges.append(Range( - start = curr_range_start, - length = curr_range_len)) - - return ranges diff --git a/src/util/test_array.py b/src/util/test_array.py deleted file mode 100644 index 38759b9..0000000 --- a/src/util/test_array.py +++ /dev/null @@ -1,12 +0,0 @@ -from array import insert_position - -def test_insert_position(): - assert insert_position(0, [], False) == 0 - assert insert_position(1, [1, 2, 3], False) == 0 - assert insert_position(2, [1, 2, 3], False) == 1 - assert insert_position(3, [1, 2, 3], False) == 2 - assert insert_position(8, [1, 2, 3], False) == 3 - assert insert_position(8, [3, 2, 1], True) == 0 - assert insert_position(3, [3, 2, 1], True) == 0 - assert insert_position(2, [3, 2, 1], True) == 1 - assert insert_position(1, [3, 2, 1], True) == 2 diff --git a/src/util/test_range.py b/src/util/test_range.py deleted file mode 100644 index 0bd909b..0000000 --- a/src/util/test_range.py +++ /dev/null @@ -1,6 +0,0 @@ -from range import from_indexes, Range - -def test_from_indexes(): - assert from_indexes([]) == [] - assert from_indexes([1]) == [Range(1, 1)] - assert from_indexes([9, 6, 0, 10]) == [Range(0, 1), Range(6, 1), Range(9, 2)] diff --git a/todo/__init__.py b/todo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/arguments.py b/todo/arguments.py new file mode 100644 index 0000000..bb60dce --- /dev/null +++ b/todo/arguments.py @@ -0,0 +1,13 @@ +import argparse + +def parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + usage = "%(prog)s [OPTION]", + description = "Manage a context-base next-action list." + ) + parser.add_argument( + "-v", "--version", action = "version", + version = f"{parser.prog} version 1.0.0" + ) + parser.add_argument("-d", "--database") + return parser diff --git a/todo/database.py b/todo/database.py new file mode 100644 index 0000000..b571e88 --- /dev/null +++ b/todo/database.py @@ -0,0 +1,19 @@ +import todo.db.init + +_database = None + +def init(path): + global _database + _database = todo.db.init.init(path) + +def cursor(): + global _database + return _database.cursor() + +def commit(): + global _database + _database.commit() + +def close(): + global _database + _database.close() diff --git a/todo/db/__init__.py b/todo/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/db/init.py b/todo/db/init.py new file mode 100644 index 0000000..5d847a3 --- /dev/null +++ b/todo/db/init.py @@ -0,0 +1,51 @@ +import sqlite3 +import os.path +import time + +def init(path): + + is_db_new = not os.path.isfile(path) + + database = sqlite3.connect(path) + + cursor = database.cursor() + + if is_db_new: + + cursor.execute( + " CREATE TABLE IF NOT EXISTS tasks(" + " id INTEGER PRIMARY KEY," + " created_at INTEGER NOT NULL," + " updated_at INTEGER NOT NULL," + " name TEXT NOT NULL," + " duration INTEGER," + " difficulty INT," + " priority INT," + " description TEXT," + " status TEXT" + " )") + + cursor.execute( + " CREATE TABLE IF NOT EXISTS tags(" + " id INTEGER PRIMARY KEY," + " created_at INTEGER NOT NULL," + " updated_at INTEGER NOT NULL," + " name TEXT NOT NULL," + " color TEXT NOT NULL" + " )") + + cursor.execute( + " CREATE TABLE IF NOT EXISTS task_tags(" + " task_id INTEGER NOT NULL," + " tag_id INTEGER NOT NULL," + " created_at INTEGER NOT NULL," + " FOREIGN KEY (task_id) REFERENCES tasks(id)," + " FOREIGN KEY (tag_id) REFERENCES tags(id)," + " PRIMARY KEY (task_id, tag_id)" + " )") + + cursor.execute("PRAGMA foreign_keys = ON") + + database.commit() + + return database diff --git a/todo/db/tags.py b/todo/db/tags.py new file mode 100644 index 0000000..c5ce33c --- /dev/null +++ b/todo/db/tags.py @@ -0,0 +1,72 @@ +from sqlite3 import Cursor +import time +from typing import List + +from todo.model.tag import Tag, ValidTagForm + +def get(cursor: Cursor) -> List[Tag]: + cursor.execute( + " SELECT" + " id," + " created_at," + " updated_at," + " name," + " color" + " FROM tags") + + res = [] + + for tag in cursor.fetchall(): + res.append(Tag( + id = tag[0], + created_at = tag[1], + updated_at = tag[2], + name = tag[3], + color = tag[4] + )) + + return res + +def insert(cursor: Cursor, form: ValidTagForm): + now = int(time.time()) + cursor.execute( + " INSERT INTO tags(" + " created_at," + " updated_at," + " name," + " color" + " ) VALUES (?, ?, ?, ?)", + (now, now, form.name, form.color)) + + return Tag( + id = cursor.lastrowid, + created_at = now, + updated_at = now, + name = form.name, + color = form.color + ) + +def update(cursor: Cursor, tag: Tag, form: ValidTagForm): + now = int(time.time()) + + cursor.execute( + " UPDATE tags SET" + " updated_at = ?," + " name = ?," + " color = ?" + " WHERE id = ?", + (now, form.name, form.color, tag.id)) + + return Tag( + id = tag.id, + created_at = tag.created_at, + updated_at = now, + name = form.name, + color = form.color + ) + +def delete(cursor: Cursor, ids): + if len(ids) >= 1: + cursor.execute( + "DELETE FROM tags WHERE id IN (%s)" % ",".join("?"*len(ids)), + ids) diff --git a/todo/db/task_tags.py b/todo/db/task_tags.py new file mode 100644 index 0000000..0fae5f9 --- /dev/null +++ b/todo/db/task_tags.py @@ -0,0 +1,39 @@ +from sqlite3 import Cursor +import time +from typing import List + +from todo.model.task_tag import TaskTag + +def one_is_used(cursor: Cursor, tag_ids: List[int]) -> bool: + if len(tag_ids) >= 1: + cursor.execute( + "SELECT task_id FROM task_tags WHERE tag_id IN (%s) LIMIT 1" % ",".join("?"*len(tag_ids)), + tag_ids) + return len(cursor.fetchall()) == 1 + else: + return False + +def get(cursor: Cursor) -> List[TaskTag]: + cursor.execute("SELECT task_id, tag_id FROM task_tags") + return [TaskTag(r[0], r[1]) for r in cursor.fetchall()] + +def insert_many(cursor: Cursor, task_id: int, tag_ids: List[int]) -> List[TaskTag] : + now = int(time.time()) + + task_tags = [TaskTag(task_id = task_id, tag_id = tag) for tag in tag_ids] + + cursor.executemany( + " INSERT INTO task_tags(" + " task_id," + " tag_id," + " created_at" + " ) VALUES (?, ?, ?)", + [(t.task_id, t.tag_id, now) for t in task_tags]) + + return task_tags + +def delete(cursor: Cursor, task_ids: List[int]): + if len(task_ids) >= 1: + cursor.execute( + "DELETE FROM task_tags WHERE task_id IN (%s)" % ",".join("?"*len(task_ids)), + task_ids) diff --git a/todo/db/tasks.py b/todo/db/tasks.py new file mode 100644 index 0000000..fc23bf0 --- /dev/null +++ b/todo/db/tasks.py @@ -0,0 +1,103 @@ +from sqlite3 import Cursor +import time +from typing import List + +from todo.model.task import Task, ValidTaskForm +from todo.model.status import Status +from todo.model import difficulty, priority, status + +def get(cursor: Cursor, s: Status) -> List[Task]: + cursor.execute( + " SELECT" + " id," + " created_at," + " updated_at," + " name," + " duration," + " difficulty," + " priority," + " description" + " FROM" + " tasks" + " WHERE" + " status = ?", + (status.format(s),)) + + res = [] + + for task in cursor.fetchall(): + res.append(Task( + id = task[0], + created_at = task[1], + updated_at = task[2], + name = task[3], + duration = task[4], + difficulty = difficulty.parse(task[5]), + priority = priority.parse(task[6]), + description = task[7] + )) + + return res + +def insert(cursor: Cursor, s: Status, form: ValidTaskForm): + now = int(time.time()) + cursor.execute( + " INSERT INTO tasks(" + " created_at," + " updated_at," + " name," + " duration," + " difficulty," + " priority," + " description," + " status" + " ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (now, now, form.name, form.duration, difficulty.format(form.difficulty), priority.format(form.priority), form.description, status.format(s))) + + return Task( + id = cursor.lastrowid, + created_at = now, + updated_at = now, + name = form.name, + duration = form.duration, + difficulty = form.difficulty, + priority = form.priority, + description = form.description + ) + +def update(cursor: Cursor, task: Task, form: ValidTaskForm): + now = int(time.time()) + + cursor.execute( + " UPDATE tasks SET" + " updated_at = ?," + " name = ?," + " duration = ?," + " difficulty = ?," + " priority = ?," + " description = ?" + " WHERE id = ?", + (now, form.name, form.duration, difficulty.format(form.difficulty), priority.format(form.priority), form.description, task.id)) + + return Task( + id = task.id, + created_at = task.created_at, + updated_at = now, + name = form.name, + duration = form.duration, + difficulty = form.difficulty, + priority = form.priority, + description = form.description + ) + +def delete(cursor: Cursor, ids: List[int]): + if len(ids) >= 1: + cursor.execute( + "DELETE FROM tasks WHERE id IN (%s)" % ",".join("?"*len(ids)), + ids) + +def update_status(cursor: Cursor, ids: List[int], s: Status): + if len(ids) >= 1: + cursor.execute( + "UPDATE tasks SET status = ? WHERE id IN (%s)" % ",".join("?"*len(ids)), + [status.format(s)] + ids) diff --git a/todo/gui/__init__.py b/todo/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/gui/color.py b/todo/gui/color.py new file mode 100644 index 0000000..cc7e5a8 --- /dev/null +++ b/todo/gui/color.py @@ -0,0 +1,21 @@ +from PyQt5 import QtGui + +black = QtGui.QColor(0, 0, 0) +red = QtGui.QColor(200, 30, 30) +orange = QtGui.QColor(200, 100, 30) +green = QtGui.QColor(30, 180, 30) +blue = QtGui.QColor(30, 30, 200) + +text = black + +easy_difficulty = green +normal_difficulty = orange +hard_difficulty = red + +low_priority = green +middle_priority = orange +high_priority = red + +short_duration = green +medium_duration = orange +long_duration = red diff --git a/todo/gui/icon.py b/todo/gui/icon.py new file mode 100644 index 0000000..7e2156d --- /dev/null +++ b/todo/gui/icon.py @@ -0,0 +1,27 @@ +from PyQt5 import QtWidgets + +# List of icons: https://joekuan.wordpress.com/2015/09/23/list-of-qt-icons/ + +def task_ready(style): + return style.standardIcon(QtWidgets.QStyle.SP_DialogApplyButton) + +def task_waiting(style): + return style.standardIcon(QtWidgets.QStyle.SP_BrowserReload) + +def task_maybe(style): + return style.standardIcon(QtWidgets.QStyle.SP_TitleBarContextHelpButton) + +def new_folder(style): + return style.standardIcon(QtWidgets.QStyle.SP_FileDialogNewFolder) + +def dialog_open(style): + return style.standardIcon(QtWidgets.QStyle.SP_DialogOpenButton) + +def dialog_ok(style): + return style.standardIcon(QtWidgets.QStyle.SP_DialogOkButton) + +def dialog_cancel(style): + return style.standardIcon(QtWidgets.QStyle.SP_DialogCancelButton) + +def trash(style): + return style.standardIcon(QtWidgets.QStyle.SP_TrashIcon) diff --git a/todo/gui/signal.py b/todo/gui/signal.py new file mode 100644 index 0000000..99100f1 --- /dev/null +++ b/todo/gui/signal.py @@ -0,0 +1,13 @@ +from PyQt5 import QtCore + +class Reload(QtCore.QObject): + _signal = QtCore.pyqtSignal(name = "reload") + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self): + self._signal.emit() + + def connect(self, f): + self._signal.connect(f) diff --git a/todo/gui/tags/__init__.py b/todo/gui/tags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/gui/tags/list.py b/todo/gui/tags/list.py new file mode 100644 index 0000000..d0374ed --- /dev/null +++ b/todo/gui/tags/list.py @@ -0,0 +1,47 @@ +from PyQt5 import QtWidgets, QtCore +from typing import List, Tuple + +from todo.model.tag import Tag +import todo.db.tags +import todo.database + +class SelectionSignal(QtCore.QObject): + _signal = QtCore.pyqtSignal(list, name = "selection") + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, tag_ids): + self._signal.emit(tag_ids) + + def connect(self, f): + self._signal.connect(f) + +def widget(parent, init_tags: List[int]) -> Tuple[QtWidgets.QWidget, SelectionSignal]: + widget = QtWidgets.QWidget(parent) + signal = SelectionSignal() + + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + + layout.addWidget(QtWidgets.QLabel("Tags")) + + list_widget = QtWidgets.QListWidget() + list_widget.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) + layout.addWidget(list_widget) + + tags = todo.db.tags.get(todo.database.cursor()) + + for tag in tags: + item = QtWidgets.QListWidgetItem(tag.name) + list_widget.addItem(item) + if tag.id in init_tags: + item.setSelected(True) + + def on_item_selection_changed(): + tag_texts = [item.text() for item in list_widget.selectedItems()] + signal.emit([tag.id for tag in tags if tag.name in tag_texts]) + + list_widget.itemSelectionChanged.connect(on_item_selection_changed) + + return (widget, signal) diff --git a/todo/gui/tags/panel/__init__.py b/todo/gui/tags/panel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/gui/tags/panel/dialog.py b/todo/gui/tags/panel/dialog.py new file mode 100644 index 0000000..f1a669c --- /dev/null +++ b/todo/gui/tags/panel/dialog.py @@ -0,0 +1,64 @@ +from PyQt5 import QtCore, QtWidgets + +from todo.model.tag import Tag, ValidTagForm +import todo.db.tags +import todo.gui.tags.panel.form.widget +import todo.database + +def add(parent_widget, add_tag_signal): + + def on_add(form: ValidTagForm): + tag = todo.db.tags.insert(todo.database.cursor(), form) + todo.database.commit() + add_tag_signal.emit(tag) + + return widget(parent_widget, "Add a tag", "add", None, on_add) + +def update(parent_widget, update_tag_signal, row, tag): + + def on_update(form: ValidTagForm): + updated_tag = todo.db.tags.update(todo.database.cursor(), tag, form) + update_tag_signal.emit(row, updated_tag) + todo.database.commit() + + return widget(parent_widget, "Modify a tag", "modify", tag, on_update) + +def show_delete(table, rows): + confirm = QtWidgets.QMessageBox.question( + table, + "Tag deletion", + "Do you really want to delete the selected tags ?", + QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, + QtWidgets.QMessageBox.Yes) + + if confirm == QtWidgets.QMessageBox.Yes: + todo.db.tags.delete(todo.database.cursor(), table.model().row_ids(rows)) + todo.database.commit() + table.model().delete_tags(rows) + +def widget( + parent: QtWidgets.QWidget, + title: str, + action_title: str, + tag: Tag, + on_validated): + + dialog = QtWidgets.QDialog(parent) + dialog.setWindowTitle(title) + dialog.setMinimumSize(QtCore.QSize(320, 240)) + + layout = QtWidgets.QVBoxLayout(dialog) + dialog.setLayout(layout) + + def on_dialog_validated(form): + dialog.accept() + on_validated(form) + + layout.addWidget(todo.gui.tags.panel.form.widget.widget( + parent = dialog, + action_title = action_title, + tag = tag, + on_validated = on_dialog_validated, + on_cancel = lambda: dialog.reject())) + + return dialog diff --git a/todo/gui/tags/panel/form/__init__.py b/todo/gui/tags/panel/form/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/gui/tags/panel/form/state.py b/todo/gui/tags/panel/form/state.py new file mode 100644 index 0000000..f898ac4 --- /dev/null +++ b/todo/gui/tags/panel/form/state.py @@ -0,0 +1,58 @@ +from PyQt5 import QtCore +from typing import Optional + +from todo.model.tag import ValidTagForm + +class TagFormEdition: + def __init__( + self, + name, + name_signal, + color, + color_signal): + + self._name = name + self._color = color + self._signal = ValidTagFormSignal() + + name_signal.connect(lambda n: self.on_name_signal(n)) + color_signal.connect(lambda d: self.on_color_signal(d)) + + def get(self) -> Optional[ValidTagForm]: + name = self._name.strip() + color = self._color.strip() + + if name and color: + return ValidTagForm( + name = name, + color = color) + else: + return None + + def on_name_signal(self, name: str): + self._name = name + self.emit() + + def on_color_signal(self, color: str): + self._color = color + self.emit() + + def emit(self): + validForm = self.get() + if validForm: + self._signal.emit(validForm) + + def signal(self): + return self._signal + +class ValidTagFormSignal(QtCore.QObject): + _signal = QtCore.pyqtSignal(ValidTagForm, name = "validTagForm") + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, form: Optional[ValidTagForm]): + self._signal.emit(form) + + def connect(self, f): + self._signal.connect(f) diff --git a/todo/gui/tags/panel/form/widget.py b/todo/gui/tags/panel/form/widget.py new file mode 100644 index 0000000..9ac4eb1 --- /dev/null +++ b/todo/gui/tags/panel/form/widget.py @@ -0,0 +1,137 @@ +from PyQt5 import QtWidgets, QtCore, QtGui +from typing import Optional, Tuple, List, Any + +from todo.model.tag import Tag, ValidTagForm +from todo.model import difficulty, priority +import todo.gui.icon +import todo.gui.tags.panel.form.state +import todo.gui.color + +def widget( + parent: QtWidgets.QWidget, + action_title: str, + tag: Tag, + on_validated, + on_cancel): + + widget = QtWidgets.QWidget(parent) + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + + grid = QtWidgets.QWidget(widget) + layout.addWidget(grid) + grid_layout = QtWidgets.QGridLayout(grid) + grid.setLayout(grid_layout) + + init_name = tag.name if tag is not None else "" + name_input = line_edit(grid, grid_layout, 0, "Name", init_name) + + init_color = tag.color if tag is not None else "#FFFFFF" + color_input = color_edit(grid, grid_layout, 1, "Color", QtGui.QColor(init_color)) + + tag_form_edition = todo.gui.tags.panel.form.state.TagFormEdition( + init_name, + name_input.textChanged, + init_color, + color_input.textChanged) + + def on_validate(): + form = tag_form_edition.get() + if form: + on_validated(form) + + layout.addWidget(buttons( + parent = widget, + action_title = action_title, + tag_form_signal = tag_form_edition.signal(), + on_validate = on_validate, + on_cancel = on_cancel)) + + return widget + +def line_edit( + parent, + layout: QtWidgets.QGridLayout, + n: int, + label: str, + default_value: str) -> QtWidgets.QLineEdit: + + label = QtWidgets.QLabel(label, parent) + layout.addWidget(label, n, 0) + + edit = QtWidgets.QLineEdit(parent) + if default_value != None: + edit.setText(default_value) + layout.addWidget(edit, n, 1) + + return edit + +def color_edit( + parent, + layout: QtWidgets.QGridLayout, + n: int, + label: str, + init_color: QtGui.QColor) -> QtWidgets.QLineEdit: + + label = QtWidgets.QLabel(label, parent) + layout.addWidget(label, n, 0) + + edit = ColorInput(init_color, parent) + layout.addWidget(edit, n, 1) + + return edit + +class ColorInput(QtWidgets.QLineEdit): + + def __init__(self, init_color: QtGui.QColor, parent): + super().__init__(parent) + self.setReadOnly(True) + self.installEventFilter(self) + self._color = init_color + self.update(init_color) + self._is_editing = False + + def eventFilter(self, source, event): + if source is self and event.type() == QtCore.QEvent.FocusIn: + if not self._is_editing: + self._is_editing = True + color = QtWidgets.QColorDialog.getColor(self._color, self) + if color.isValid(): + self.update(color) + else: + self._is_editing = False + self.clearFocus() + return super(ColorInput, self).eventFilter(source, event) + + def update(self, color: QtGui.QColor): + self._color = color + self.setText(color.name().upper()) + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Base, color) + palette.setColor(QtGui.QPalette.Text, color) + self.setPalette(palette) + +def buttons(parent, action_title, tag_form_signal, on_validate, on_cancel): + widget = QtWidgets.QWidget(parent) + layout = QtWidgets.QHBoxLayout(widget) + + validate = QtWidgets.QPushButton(action_title, widget) + validate.setDisabled(True) + validate.setIcon(todo.gui.icon.dialog_ok(validate.style())) + validate.clicked.connect(on_validate); + layout.addWidget(validate) + + def on_tag_form_signal(form: Optional[ValidTagForm]): + if form: + validate.setEnabled(True) + else: + validate.setDisabled(True) + + tag_form_signal.connect(on_tag_form_signal) + + cancel = QtWidgets.QPushButton("cancel", widget) + cancel.setIcon(todo.gui.icon.dialog_cancel(cancel.style())) + cancel.clicked.connect(on_cancel) + layout.addWidget(cancel) + + return widget diff --git a/todo/gui/tags/panel/signal.py b/todo/gui/tags/panel/signal.py new file mode 100644 index 0000000..97065cd --- /dev/null +++ b/todo/gui/tags/panel/signal.py @@ -0,0 +1,27 @@ +from PyQt5 import QtCore + +from todo.model.tag import Tag + +class AddTag(QtCore.QObject): + _signal = QtCore.pyqtSignal(Tag, name = "addTag") + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, tag): + self._signal.emit(tag) + + def connect(self, f): + self._signal.connect(f) + +class UpdateTag(QtCore.QObject): + _signal = QtCore.pyqtSignal(int, Tag, name = "updateTag") + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, row, tag): + self._signal.emit(row, tag) + + def connect(self, f): + self._signal.connect(f) diff --git a/todo/gui/tags/panel/table/__init__.py b/todo/gui/tags/panel/table/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/gui/tags/panel/table/menu.py b/todo/gui/tags/panel/table/menu.py new file mode 100644 index 0000000..e286051 --- /dev/null +++ b/todo/gui/tags/panel/table/menu.py @@ -0,0 +1,35 @@ +from PyQt5 import QtWidgets, QtCore + +from todo.model.tag import Tag, ValidTagForm +import todo.database +import todo.db.task_tags +import todo.gui.tags.panel.dialog + +def open(table, update_tag_signal, position): + rows = set([index.row() for index in table.selectedIndexes()]) + + menu = QtWidgets.QMenu(table) + + actions = 0 + + if len(rows) == 1: + modify_action = menu.addAction(todo.gui.icon.dialog_open(menu.style()), "modify") + actions += 1 + else: + modify_action = QtWidgets.QAction(menu) + + tags = table.model().row_ids(rows) + if not todo.db.task_tags.one_is_used(todo.database.cursor(), tags): + delete_action = menu.addAction(todo.gui.icon.trash(menu.style()), "delete") + actions += 1 + else: + delete_action = QtWidgets.QAction(menu) + + if actions > 0: + action = menu.exec_(table.mapToGlobal(position + QtCore.QPoint(15, 20))) + if action == modify_action and len(rows) == 1: + row = list(rows)[0] + tag = table.model().get_at(row) + todo.gui.tags.panel.dialog.update(table, update_tag_signal, row, tag).exec_() + elif action == delete_action: + todo.gui.tags.panel.dialog.show_delete(table, rows) diff --git a/todo/gui/tags/panel/table/model.py b/todo/gui/tags/panel/table/model.py new file mode 100644 index 0000000..6f9d71a --- /dev/null +++ b/todo/gui/tags/panel/table/model.py @@ -0,0 +1,103 @@ +from PyQt5 import QtCore, QtWidgets, QtGui +from PyQt5.QtCore import Qt +from typing import List +import time +import math + +from todo.model.tag import Tag +import todo.util.array +import todo.util.range + +columns = 1 + +headers = ["Name", "Color"] + +default_sort = (0, Qt.AscendingOrder) + +class TableModel(QtCore.QAbstractTableModel): + def __init__(self, tags): + super(TableModel, self).__init__() + self._tags = tags + + def headerData(self, section, orientation, role): + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + return headers[section] + elif role == Qt.DisplayRole and orientation == Qt.Vertical: + return section + 1 + else: + return QtCore.QVariant() + + def data(self, index, role): + tag = self._tags[index.row()] + + if role == Qt.DisplayRole: + if index.column() == 0: + return tag.name + elif index.column() == 1: + return tag.color + elif role == Qt.BackgroundRole: + return QtGui.QBrush(QtGui.QColor(tag.color)) + else: + return QtCore.QVariant() + + def rowCount(self, index): + return len(self._tags) + + def columnCount(self, index): + return columns + + def get_at(self, row): + if row >= 0 and row < len(self._tags): + return self._tags[row] + + def insert_tag(self, header: QtWidgets.QHeaderView, tag: Tag) -> int: + at = self.insert_position(header, tag) + self.beginInsertRows(QtCore.QModelIndex(), at, at) + self._tags.insert(at, tag) + self.endInsertRows() + return at + + def insert_position(self, header: QtWidgets.QHeaderView, tag: Tag) -> int: + row = header.sortIndicatorSection() + order = header.sortIndicatorOrder() + is_rev = is_reversed(row, order) + return todo.util.array.insert_position( + sort_key(tag, row, is_rev), + [sort_key(t, row, is_rev) for t in self._tags], + is_rev) + + def update_tag(self, header: QtWidgets.QHeaderView, row, tag: Tag) -> int: + self.delete_tag_range(row, 1) + return self.insert_tag(header, tag) + + def delete_tags(self, indexes): + for range in reversed(todo.util.range.from_indexes(indexes)): + self.delete_tag_range(range.start, range.length) + return True + + def delete_tag_range(self, row, rows): + self.beginRemoveRows(QtCore.QModelIndex(), row, row + rows - 1) + self._tags = self._tags[:row] + self._tags[row + rows:] + self.endRemoveRows() + return True + + def row_ids(self, rows): + return [tag.id for i, tag in enumerate(self._tags) if i in rows] + + def sort(self, row: int, order: Qt.SortOrder): + self.layoutAboutToBeChanged.emit() + is_rev = is_reversed(row, order) + self._tags = sorted( + self._tags, + key = lambda tag: sort_key(tag, row, is_rev), + reverse = is_rev) + self.layoutChanged.emit() + +def sort_key(tag: Tag, row: int, is_reversed: bool): + if row == 0: + return tag.name.lower() + elif row == 1: + return tag.color + +def is_reversed(row: int, order: Qt.SortOrder) -> bool: + return order == Qt.DescendingOrder diff --git a/todo/gui/tags/panel/table/widget.py b/todo/gui/tags/panel/table/widget.py new file mode 100644 index 0000000..b36759d --- /dev/null +++ b/todo/gui/tags/panel/table/widget.py @@ -0,0 +1,73 @@ +from PyQt5 import QtWidgets +from PyQt5.QtCore import Qt + +from todo.model.tag import Tag, ValidTagForm +import todo.database +import todo.db.tags +import todo.db.task_tags +import todo.gui.tags.panel.dialog +import todo.gui.tags.panel.signal +import todo.gui.tags.panel.table.menu +import todo.gui.tags.panel.table.model + +class Widget(QtWidgets.QTableView): + + def __init__(self, parent, add_tag_signal): + super().__init__(parent) + + self._update_tag_signal = todo.gui.tags.panel.signal.UpdateTag() + + tags = todo.db.tags.get(todo.database.cursor()) + table_model = todo.gui.tags.panel.table.model.TableModel(tags) + + self.setModel(table_model) + self.sortByColumn( + todo.gui.tags.panel.table.model.default_sort[0], + todo.gui.tags.panel.table.model.default_sort[1]) + self.setSortingEnabled(True) + self.setSelectionBehavior(QtWidgets.QTableView.SelectRows) + self.horizontalHeader().setStretchLastSection(True) + self.resizeColumns() + + self.doubleClicked.connect(lambda index: self.on_double_click(index.row())) + + # # Menu + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(lambda position: todo.gui.tags.panel.table.menu.open(self, self._update_tag_signal, position)) + + add_tag_signal.connect(lambda tag: self.insert(tag)) + self._update_tag_signal.connect(lambda row, tag: self.update(row, tag)) + + def insert(self, tag): + self.model().insert_tag(self.horizontalHeader(), tag) + self.resizeColumns() + + def update(self, row, tag): + row = self.model().update_tag(self.horizontalHeader(), row, tag) + self.selectRow(row) + self.resizeColumns() + + def resizeColumns(self): + for column in range(todo.gui.tags.panel.table.model.columns): + self.resizeColumnToContents(column) + + def keyPressEvent(self, event): + super().keyPressEvent(event) + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + rows = self.get_selected_rows() + if len(rows) == 1: + row = rows[0] + tag = self.model().get_at(row) + todo.gui.tags.panel.dialog.update(self, self._update_tag_signal, row, tag).exec_() + elif event.key() == Qt.Key_Delete: + rows = self.get_selected_rows() + tags = self.model().row_ids(rows) + if not db.task_tags.one_is_used(todo.database.cursor(), tags): + todo.gui.tags.panel.dialog.show_delete(self, rows) + + def get_selected_rows(self): + return list(set([index.row() for index in self.selectedIndexes()])) + + def on_double_click(self, row: int): + tag = self.model().get_at(row) + todo.gui.tags.panel.dialog.update(self, self._update_tag_signal, row, tag).exec_() diff --git a/todo/gui/tags/panel/widget.py b/todo/gui/tags/panel/widget.py new file mode 100644 index 0000000..071442e --- /dev/null +++ b/todo/gui/tags/panel/widget.py @@ -0,0 +1,30 @@ +from PyQt5 import QtWidgets + +import todo.gui.tags.panel.dialog +import todo.gui.tags.panel.signal +import todo.gui.tags.panel.table.widget +import todo.gui.icon + +def widget(parent): + widget = QtWidgets.QWidget(parent) + + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + + layout.addSpacing(15) + + add_tag_signal = todo.gui.tags.panel.signal.AddTag() + + add_tag_button = QtWidgets.QPushButton(" Add a tag", widget) + add_tag_button.setFixedHeight(30) + add_tag_button.setIcon(todo.gui.icon.new_folder(widget.style())) + + add_tag_button.clicked.connect(lambda: todo.gui.tags.panel.dialog.add(widget, add_tag_signal).exec_()) + layout.addWidget(add_tag_button) + + layout.addSpacing(20) + + table = todo.gui.tags.panel.table.widget.Widget(widget, add_tag_signal) + layout.addWidget(table) + + return widget diff --git a/todo/gui/tasks/__init__.py b/todo/gui/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/gui/tasks/dialog.py b/todo/gui/tasks/dialog.py new file mode 100644 index 0000000..c2bd164 --- /dev/null +++ b/todo/gui/tasks/dialog.py @@ -0,0 +1,76 @@ +from PyQt5 import QtCore, QtWidgets +from typing import List + +from todo.model.tag import Tag +from todo.model.task import Task, ValidTaskForm +from todo.model.status import Status +import todo.database +import todo.gui.tasks.form.widget +import todo.service.tasks + +def add(parent_widget, status: Status, add_task_signal): + + def on_add(task_form: ValidTaskForm): + task = todo.service.tasks.create(todo.database.cursor(), status, task_form) + add_task_signal.emit(task, task_form.tags) + + return widget(parent_widget, "Add a task", "add", None, [], on_add) + +def update(parent_widget, update_task_signal, row: int, task: Task, tags: List[int]): + + def on_update(task_form: ValidTaskForm): + updated_task = todo.service.tasks.update(todo.database.cursor(), task, task_form) + update_task_signal.emit(row, updated_task, task_form.tags) + + return widget(parent_widget, "Modify a task", "modify", task, tags, on_update) + +def confirm_delete(parent, rows: List[int], on_confirm): + confirm = QtWidgets.QMessageBox.question( + parent, + "Task deletion", + "Do you really want to delete the selected tasks ?", + QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, + QtWidgets.QMessageBox.Yes) + + if confirm == QtWidgets.QMessageBox.Yes: + on_confirm() + +def confirm_move(parent, rows: List[int], move_to: Status, on_confirm): + confirm = QtWidgets.QMessageBox.question( + parent, + "Task move", + "Do you really want to move the selected tasks ?", + QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, + QtWidgets.QMessageBox.Yes) + + if confirm == QtWidgets.QMessageBox.Yes: + on_confirm() + +def widget( + parent: QtWidgets.QWidget, + title: str, + action_title: str, + task: Task, + tags: List[int], + on_validated): + + dialog = QtWidgets.QDialog(parent) + dialog.setWindowTitle(title) + dialog.setMinimumSize(QtCore.QSize(320, 240)) + + layout = QtWidgets.QVBoxLayout(dialog) + dialog.setLayout(layout) + + def on_dialog_validated(form): + dialog.accept() + on_validated(form) + + layout.addWidget(todo.gui.tasks.form.widget.widget( + parent = dialog, + action_title = action_title, + task = task, + tags = tags, + on_validated = on_dialog_validated, + on_cancel = lambda: dialog.reject())) + + return dialog diff --git a/todo/gui/tasks/duration.py b/todo/gui/tasks/duration.py new file mode 100644 index 0000000..81db661 --- /dev/null +++ b/todo/gui/tasks/duration.py @@ -0,0 +1,50 @@ +from PyQt5 import QtGui +from typing import Optional +import math +import re + +import todo.gui.color + +def format(minutes: int): + if minutes >= 60 * 24: + return "" + format_decimal(minutes / 60 / 24) + "d" + elif minutes >= 60: + return "" + format_decimal(minutes / 60) + "h" + elif minutes > 0: + return "" + str(minutes) + "m" + else: + return "" + +def format_decimal(d: float) -> str: + return "{0:.2g}".format(d) + +def parse(duration: str) -> Optional[int]: + duration = duration.strip() + if duration: + result = re.match("^(\d+)(\.(\d+))?([mhd])$", duration.strip()) + if result: + n = int(result.group(1)) + if result.group(3): + d = int(result.group(3)) * pow(10, -1 * len(result.group(3))) + else: + d = 0 + num = n + d + unit = result.group(4) + if unit == "m": + return math.floor(num) + elif unit == "h": + return math.floor(num * 60) + elif unit == "d": + return math.floor(num * 60 * 24) + else: + return None + else: + return 0 + +def color(minutes: int): + if minutes <= 15: + return todo.gui.color.short_duration + elif minutes < 60: + return todo.gui.color.medium_duration + else: + return todo.gui.color.long_duration diff --git a/todo/gui/tasks/form/__init__.py b/todo/gui/tasks/form/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/gui/tasks/form/state.py b/todo/gui/tasks/form/state.py new file mode 100644 index 0000000..33b0873 --- /dev/null +++ b/todo/gui/tasks/form/state.py @@ -0,0 +1,100 @@ +from PyQt5 import QtCore +from typing import Optional + +from todo.model.task import ValidTaskForm +from todo.model.difficulty import Difficulty +from todo.model.priority import Priority +import todo.gui.tasks.duration +import todo.gui.tags.list + +class TaskFormEdition: + def __init__( + self, + name, + name_signal, + duration, + duration_signal, + difficulty, + difficulty_signal, + priority, + priority_signal, + tags_signal: todo.gui.tags.list.SelectionSignal, + description, + description_signal): + + self._name = name + self._duration = duration + self._difficulty = difficulty + self._priority = priority + self._tags = [] + self._description = description + self._signal = ValidTaskFormSignal() + + name_signal.connect(lambda n: self.on_name_signal(n)) + duration_signal.connect(lambda d: self.on_duration_signal(d)) + difficulty_signal.connect(lambda d: self.on_difficulty_signal(d)) + priority_signal.connect(lambda p: self.on_priority_signal(p)) + tags_signal.connect(lambda ts: self.on_tags_signal(ts)) + description_signal.connect(lambda d: self.on_description_signal(d)) + + def get(self) -> Optional[ValidTaskForm]: + name = self._name.strip() + duration = todo.gui.tasks.duration.parse(self._duration) + difficulty = self._difficulty + priority = self._priority + description = self._description.strip() + + if name and duration != None: + return ValidTaskForm( + name = name, + duration = duration, + difficulty = difficulty, + priority = priority, + tags = self._tags, + description = description) + else: + return None + + def on_name_signal(self, name: str): + self._name = name + self.emit() + + def on_duration_signal(self, duration: str): + self._duration = duration + self.emit() + + def on_difficulty_signal(self, index: int): + self._difficulty = Difficulty(index) + self.emit() + + def on_priority_signal(self, index: int): + self._priority = Priority(index) + self.emit() + + def on_tags_signal(self, tags: [int]): + self._tags = tags + self.emit() + + def on_description_signal(self, description: str): + self._description = description + self.emit() + + def emit(self): + validForm = self.get() + if validForm: + self._signal.emit(validForm) + + def signal(self): + return self._signal + +class ValidTaskFormSignal(QtCore.QObject): + _signal = QtCore.pyqtSignal(ValidTaskForm, name = "validTaskForm") + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, form: Optional[ValidTaskForm]): + self._signal.emit(form) + + def connect(self, f): + self._signal.connect(f) diff --git a/todo/gui/tasks/form/widget.py b/todo/gui/tasks/form/widget.py new file mode 100644 index 0000000..15be21b --- /dev/null +++ b/todo/gui/tasks/form/widget.py @@ -0,0 +1,184 @@ +from PyQt5 import QtWidgets, QtCore +from typing import Optional, Tuple, List, Any + +from todo.model.task import Task, ValidTaskForm +from todo.model.tag import Tag +from todo.model import difficulty, priority +import todo.gui.icon +import todo.gui.tasks.form.state +import todo.gui.tasks.duration +import todo.gui.tags.list + +class TextEditSignal(QtCore.QObject): + _signal = QtCore.pyqtSignal(str, name = "textEdit") + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, text: str): + self._signal.emit(text) + + def connect(self, f): + self._signal.connect(f) + +def widget( + parent: QtWidgets.QWidget, + action_title: str, + task: Task, + tags: List[int], + on_validated, + on_cancel): + + widget = QtWidgets.QWidget(parent) + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + + grid = QtWidgets.QWidget(widget) + layout.addWidget(grid) + grid_layout = QtWidgets.QGridLayout(grid) + grid.setLayout(grid_layout) + + init_name = task.name if task is not None else "" + name_input = line_edit(grid, grid_layout, 0, "Name", init_name) + + init_duration = todo.gui.tasks.duration.format(task.duration) if task is not None else "" + duration_input = line_edit(grid, grid_layout, 1, "Duration", init_duration) + + init_difficulty = task.difficulty if task is not None else difficulty.Difficulty.NORMAL + difficulty_input = combo_box( + grid, + grid_layout, + 3, + "Difficulty", + [difficulty.format(d) for d in difficulty.values], + int(init_difficulty)) + + init_priority = task.priority if task is not None else priority.Priority.MIDDLE + priority_input = combo_box( + grid, + grid_layout, + 4, + "Priority", + [priority.format(d) for d in priority.values], + int(init_priority)) + + (tags_list_widget, tags_signal) = tags_selection(widget, tags) + layout.addWidget(tags_list_widget) + + init_description = task.description if task is not None else "" + (description_input, description_signal) = text_edit(widget, "Description", init_description) + layout.addWidget(description_input) + + task_form_edition = todo.gui.tasks.form.state.TaskFormEdition( + init_name, + name_input.textChanged, + init_duration, + duration_input.textChanged, + init_difficulty, + difficulty_input.currentIndexChanged, + init_priority, + priority_input.currentIndexChanged, + tags_signal, + init_description, + description_signal) + + def on_validate(): + form = task_form_edition.get() + if form: + on_validated(form) + + layout.addWidget(buttons( + parent = widget, + action_title = action_title, + task_form_signal = task_form_edition.signal(), + on_validate = on_validate, + on_cancel = on_cancel)) + + return widget + +def line_edit( + parent, + layout: QtWidgets.QGridLayout, + n: int, + label: str, + default_value: str) -> QtWidgets.QLineEdit: + + label = QtWidgets.QLabel(label, parent) + layout.addWidget(label, n, 0) + + edit = QtWidgets.QLineEdit(parent) + if default_value != None: + edit.setText(default_value) + layout.addWidget(edit, n, 1) + + return edit + +def combo_box( + parent, + layout: QtWidgets.QGridLayout, + n: int, + label: str, + values: List[str], + default_value: int) -> QtWidgets.QComboBox: + + label = QtWidgets.QLabel(label, parent) + layout.addWidget(label, n, 0) + + box = QtWidgets.QComboBox(parent) + for value in values: + box.addItem(value) + if default_value != None: + box.setCurrentIndex(default_value) + layout.addWidget(box, n, 1) + + return box + +def tags_selection(parent, init_tags: List[int]) -> QtWidgets.QWidget: + return todo.gui.tags.list.widget(parent, init_tags) + +def text_edit( + parent, + label: str, + default_value: str) -> Tuple[QtWidgets.QWidget, TextEditSignal]: + + widget = QtWidgets.QWidget(parent) + layout = QtWidgets.QVBoxLayout(widget) + + signal = TextEditSignal() + + label = QtWidgets.QLabel(label, parent) + layout.addWidget(label) + + edit = QtWidgets.QTextEdit(parent) + if default_value != None: + edit.insertPlainText(default_value) + layout.addWidget(edit) + + edit.textChanged.connect(lambda: signal.emit(edit.toPlainText())) + + return (widget, signal) + +def buttons(parent, action_title, task_form_signal, on_validate, on_cancel): + widget = QtWidgets.QWidget(parent) + layout = QtWidgets.QHBoxLayout(widget) + + validate = QtWidgets.QPushButton(action_title, widget) + validate.setDisabled(True) + validate.setIcon(todo.gui.icon.dialog_ok(validate.style())) + validate.clicked.connect(on_validate); + layout.addWidget(validate) + + def on_task_form_signal(form: Optional[ValidTaskForm]): + if form: + validate.setEnabled(True) + else: + validate.setDisabled(True) + + task_form_signal.connect(on_task_form_signal) + + cancel = QtWidgets.QPushButton("cancel", widget) + cancel.setIcon(todo.gui.icon.dialog_cancel(cancel.style())) + cancel.clicked.connect(on_cancel) + layout.addWidget(cancel) + + return widget diff --git a/todo/gui/tasks/signal.py b/todo/gui/tasks/signal.py new file mode 100644 index 0000000..52f65d1 --- /dev/null +++ b/todo/gui/tasks/signal.py @@ -0,0 +1,28 @@ +from PyQt5 import QtCore +from typing import List + +from todo.model.task import Task + +class AddTask(QtCore.QObject): + _signal = QtCore.pyqtSignal(Task, list, name = "addTask") + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, task: Task, tags: List[int]): + self._signal.emit(task, tags) + + def connect(self, f): + self._signal.connect(f) + +class UpdateTask(QtCore.QObject): + _signal = QtCore.pyqtSignal(int, Task, list, name = "updateTask") + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, row: int, task: Task, tags: List[int]): + self._signal.emit(row, task, tags) + + def connect(self, f): + self._signal.connect(f) diff --git a/todo/gui/tasks/table/__init__.py b/todo/gui/tasks/table/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/gui/tasks/table/menu.py b/todo/gui/tasks/table/menu.py new file mode 100644 index 0000000..bc039b2 --- /dev/null +++ b/todo/gui/tasks/table/menu.py @@ -0,0 +1,48 @@ +from PyQt5 import QtWidgets, QtCore +from typing import List + +import todo.gui.tasks.dialog +from todo.model.status import Status +from todo.model.task import Task, ValidTaskForm +from todo.model.tag import Tag + +def open(table: QtWidgets.QTableWidget, status: Status, update_task_signal, position): + rows = set([index.row() for index in table.selectedIndexes()]) + + menu = QtWidgets.QMenu(table) + + if len(rows) == 1: + modify_action = menu.addAction(todo.gui.icon.dialog_open(menu.style()), "modify") + else: + modify_action = QtWidgets.QAction(menu) + + delete_action = menu.addAction(todo.gui.icon.trash(menu.style()), "delete") + + if status != Status.READY: + move_to_ready = menu.addAction(todo.gui.icon.task_ready(menu.style()), "move to ready") + else: + move_to_ready = QtWidgets.QAction(menu) + + if status != Status.WAITING: + move_to_waiting = menu.addAction(todo.gui.icon.task_waiting(menu.style()), "move to waiting") + else: + move_to_waiting = QtWidgets.QAction(menu) + + if status != Status.MAYBE: + move_to_maybe = menu.addAction(todo.gui.icon.task_maybe(menu.style()), "move to maybe") + else: + move_to_maybe = QtWidgets.QAction(menu) + + action = menu.exec_(table.mapToGlobal(position + QtCore.QPoint(15, 20))) + if action == modify_action and len(rows) == 1: + row = list(rows)[0] + (task, tags) = table.get_at(row) + todo.gui.tasks.dialog.update(table, update_task_signal, row, task, tags).exec_() + elif action == delete_action: + todo.gui.tasks.dialog.confirm_delete(table, rows, lambda: table.delete_rows(rows)) + elif action == move_to_ready: + todo.gui.tasks.dialog.confirm_move(table, rows, Status.READY, lambda: table.update_status(rows, Status.READY)) + elif action == move_to_waiting: + todo.gui.tasks.dialog.confirm_move(table, rows, Status.WAITING, lambda: table.update_status(rows, Status.WAITING)) + elif action == move_to_maybe: + todo.gui.tasks.dialog.confirm_move(table, rows, Status.MAYBE, lambda: table.update_status(rows, Status.MAYBE)) diff --git a/todo/gui/tasks/table/widget.py b/todo/gui/tasks/table/widget.py new file mode 100644 index 0000000..e06c921 --- /dev/null +++ b/todo/gui/tasks/table/widget.py @@ -0,0 +1,283 @@ +from PyQt5 import QtWidgets, QtCore, QtGui +from PyQt5.QtCore import Qt +from typing import List, Tuple +import time +import math + +from todo.model import difficulty, priority +from todo.model.difficulty import Difficulty +from todo.model.priority import Priority +from todo.model.tag import Tag +from todo.model.task import Task +from todo.model.task_tag import TaskTag +from todo.model.status import Status +import todo.database +import todo.db.tags +import todo.db.task_tags +import todo.gui.color +import todo.gui.signal +import todo.gui.tasks.dialog +import todo.gui.tasks.duration +import todo.gui.tasks.signal +import todo.gui.tasks.signal +import todo.gui.tasks.table.menu +import todo.service.tasks +import todo.util.array + +class Widget(QtWidgets.QTableWidget): + def __init__( + self, + parent, + on_show: todo.gui.signal.Reload, + add_task_signal: todo.gui.tasks.signal.AddTask, + status: Status): + super().__init__(parent) + + self.init_state(status) + self.sort() + + self.setSelectionBehavior(QtWidgets.QTableView.SelectRows) + self.init_header() + self.setRowCount(len(self._tasks)) + self.setColumnCount(len(self.header_labels())) + self.setColumnWidth(1, 500) + + self.update_view() + self.horizontalHeader().setStretchLastSection(True) + + # Menu + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(lambda position: todo.gui.tasks.table.menu.open(self, status, self._update_task_signal, position)) + + self.doubleClicked.connect(lambda index: self.on_double_click(index.row())) + add_task_signal.connect(lambda task, tags: self.insert(task, tags)) + self._update_task_signal.connect(lambda row, task, tags: self.update_task(row, task, tags)) + on_show.connect(lambda: self.on_show()) + + def init_state(self, status: Status): + self._update_task_signal = todo.gui.tasks.signal.UpdateTask() + cursor = todo.database.cursor() + self._status = status + self._tasks = todo.service.tasks.get(cursor, self._status) + self._task_tags = todo.db.task_tags.get(cursor) + self._tags = todo.db.tags.get(cursor) + self._sort_column = 0 + self._sort_is_ascending = True + + def init_header(self): + self._header_view = QtWidgets.QHeaderView(Qt.Horizontal, self) + self._header_model = QtGui.QStandardItemModel() + self._header_model.setHorizontalHeaderLabels(self.header_labels()) + self._header_view.setModel(self._header_model) + self._header_view.setSectionsClickable(True) + self._header_view.sectionClicked.connect(self.on_header_click) + self.setHorizontalHeader(self._header_view) + + def on_show(self): + cursor = todo.database.cursor() + self._tasks = todo.service.tasks.get(cursor, self._status) + self._task_tags = todo.db.task_tags.get(cursor) + self._tags = todo.db.tags.get(cursor) + self.setRowCount(len(self._tasks)) + self.sort() + self.update_view() + + def on_header_click(self, column): + if self._sort_column == column: + self._sort_is_ascending = not self._sort_is_ascending + else: + self._sort_is_ascending = True + self._sort_column = column + self.sort() + self._header_model.setHorizontalHeaderLabels(self.header_labels()) + self.update_view() + + def sort(self): + is_rev = self.is_reversed() + self._tasks = sorted( + self._tasks, + key = lambda task: self.sort_key(task, is_rev), + reverse = is_rev) + + def update_task(self, row, task: Task, tags: List[int]): + self._tasks[row] = task + filtred_task_tags = [tt for tt in self._task_tags if tt.task_id in [t.id for t in self._tasks if t.id != task.id]] + new_task_tags = [TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tags] + self._task_tags = filtred_task_tags + new_task_tags + + # Update task in table + self.sort() + row_after_sort = [i for i in range(len(self._tasks)) if self._tasks[i].id == task.id][0] + if row_after_sort == row: + self.update_row(row) + else: + self.removeRow(row) + self.insertRow(row_after_sort) + self.update_row(row_after_sort) + self.selectRow(row_after_sort) + + def update_view(self): + for row in range(len(self._tasks)): + self.update_row(row) + + def update_row(self, row: int): + task = self._tasks[row] + self.setItem(row, 0, item(age_since(task.created_at))) + self.setItem(row, 1, item(task.name)) + self.setCellWidget(row, 2, colored_label(self, todo.gui.tasks.duration.format(task.duration), todo.gui.tasks.duration.color(task.duration))) + self.setCellWidget(row, 3, colored_label(self, difficulty.format(task.difficulty), difficulty_color(task.difficulty))) + self.setCellWidget(row, 4, colored_label(self, priority.format(task.priority), priority_color(task.priority))) + tag_ids = [tt.tag_id for tt in self._task_tags if tt.task_id == task.id] + res_tags = sorted([tag for tag in self._tags if tag.id in tag_ids], key=lambda t: t.name) + self.setCellWidget(row, 5, render_tags(self, res_tags)) + self.setRowHeight(row, 45) + + def insert(self, task: Task, tags: List[int]) -> int: + is_rev = self.is_reversed() + row = todo.util.array.insert_position( + self.sort_key(task, is_rev), + [self.sort_key(t, is_rev) for t in self._tasks], + is_rev) + self._tasks.insert(row, task) + self._task_tags += [TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tags] + self.insertRow(row) + self.update_row(row) + self._task_tags += [TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tags] + self.setRowCount(len(self._tasks)) + return row + + def is_reversed(self) -> bool: + if self._sort_column == 0: + return self._sort_is_ascending + else: + return not self._sort_is_ascending + + def sort_key(self, task: Task, is_reversed: bool): + row = self._sort_column + if row == 0: + return task.created_at + elif row == 1: + return task.name.lower() + elif row == 2: + if is_reversed: + return task.duration + else: + return (task.duration == 0, task.duration) + elif row == 3: + return task.difficulty + elif row == 4: + return task.priority + elif row == 5: + tag_ids = [tt.tag_id for tt in self._task_tags if tt.task_id == task.id] + tags = sorted([tag.name.lower() for tag in self._tags if tag.id in tag_ids]) + key = "".join(tags) + if is_reversed: + return key + else: + return (key == "", key) + + def keyPressEvent(self, event): + super().keyPressEvent(event) + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + rows = self.get_selected_rows() + if len(rows) == 1: + row = rows[0] + (task, tags) = self.get_at(row) + todo.gui.tasks.dialog.update(self, self._update_task_signal, row, task, tags).exec_() + elif event.key() == Qt.Key_Delete: + rows = self.get_selected_rows() + todo.gui.tasks.dialog.show_delete(self, rows, lambda: self.delete_rows(rows)) + + def delete_rows(self, rows: List[int]): + task_ids = [task.id for i, task in enumerate(self._tasks) if i in rows] + todo.service.tasks.delete(todo.database.cursor(), task_ids) + self.remove_rows_from_table(rows, task_ids) + + def update_status(self, rows: List[int], status: Status): + task_ids = [task.id for i, task in enumerate(self._tasks) if i in rows] + todo.service.tasks.update_status(todo.database.cursor(), task_ids, status) + self.remove_rows_from_table(rows, task_ids) + + def remove_rows_from_table(self, rows: List[int], task_ids: List[int]): + for row in sorted(rows, reverse=True): + self.removeRow(row) + self._tasks = [t for t in self._tasks if t.id not in task_ids] + self._task_tags = [tt for tt in self._task_tags if tt.task_id in [t.id for t in self._tasks]] + self.setRowCount(len(self._tasks)) + + def get_selected_rows(self): + return list(set([index.row() for index in self.selectedIndexes()])) + + def on_double_click(self, row: int): + (task, tags) = self.get_at(row) + todo.gui.tasks.dialog.update(self, self._update_task_signal, row, task, tags).exec_() + + def get_at(self, row: int) -> Tuple[Task, List[int]]: + task = self._tasks[row] + tags = [tt.tag_id for tt in self._task_tags if tt.task_id == task.id] + return (task, tags) + + def header_labels(self): + labels = ["Age", "Name", "Duration", "Difficulty", "Priority", "Tag"] + if self._sort_is_ascending: + sign = "▼" + else: + sign = "▲" + labels[self._sort_column] = labels[self._sort_column] + " " + sign + return labels + +def item(text: str): + item = QtWidgets.QTableWidgetItem(text) + item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + return item + +def age_since(timestamp): + diff = int(time.time()) - timestamp + if diff >= 60 * 60 * 24: + return "" + str(math.floor(diff / 60 / 60 / 24)) + "d" + elif diff >= 60 * 60: + return "" + str(math.floor(diff / 60 / 60)) + "h" + elif diff >= 60: + return "" + str(math.floor(diff / 60)) + "m" + else: + return "1m" + +def colored_label(parent, text: str, color: QtGui.QColor): + label = QtWidgets.QLabel(text) + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Text, color) + label.setPalette(palette) + return label + +def render_tags(parent, tags: List[Tag]): + widget = QtWidgets.QWidget(parent) + + layout = QtWidgets.QHBoxLayout(widget) + widget.setLayout(layout) + + for tag in tags: + label = QtWidgets.QLabel(tag.name) + label.setContentsMargins(3, 3, 3, 3) + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Base, QtGui.QColor(tag.color)) + label.setAutoFillBackground(True) + label.setPalette(palette) + layout.addWidget(label) + + return widget + +def difficulty_color(d: Difficulty) -> QtGui.QColor: + if d == Difficulty.EASY: + return todo.gui.color.easy_difficulty + elif d == Difficulty.NORMAL: + return todo.gui.color.normal_difficulty + elif d == Difficulty.HARD: + return todo.gui.color.hard_difficulty + +def priority_color(p: Priority) -> QtGui.QColor: + if p == Priority.LOW: + return todo.gui.color.low_priority + elif p == Priority.MIDDLE: + return todo.gui.color.middle_priority + elif p == Priority.HIGH: + return todo.gui.color.high_priority diff --git a/todo/gui/tasks/test_duration.py b/todo/gui/tasks/test_duration.py new file mode 100644 index 0000000..1435e2d --- /dev/null +++ b/todo/gui/tasks/test_duration.py @@ -0,0 +1,21 @@ +from todo.gui.tasks.duration import format, parse + +def test_format(): + assert format(0) == "" + assert format(0.5) == "0.5m" + assert format(35) == "35m" + assert format(60) == "1h" + assert format(61) == "1h" + assert format(90) == "1.5h" + assert format(1440) == "1d" + +def test_parse(): + assert parse("") == 0 + assert parse("42") == None + assert parse("hey") == None + assert parse("1h30") == None + assert parse("1h30m") == None + assert parse("17m") == 17 + assert parse("90m") == 90 + assert parse("1.5h") == 90 + assert parse("2d") == 2880 diff --git a/todo/gui/tasks/widget.py b/todo/gui/tasks/widget.py new file mode 100644 index 0000000..cc7fe96 --- /dev/null +++ b/todo/gui/tasks/widget.py @@ -0,0 +1,30 @@ +from PyQt5 import QtWidgets + +from todo.model.status import Status +import todo.gui.tasks.signal +import todo.gui.tasks.table.widget +import todo.gui.icon +import todo.gui.signal + +def widget(parent, on_show: todo.gui.signal.Reload, status: Status): + widget = QtWidgets.QWidget(parent) + + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + + layout.addSpacing(15) + + add_task_signal = todo.gui.tasks.signal.AddTask() + + add_task_button = QtWidgets.QPushButton(" Add a task", widget) + add_task_button.setFixedHeight(30) + add_task_button.setIcon(todo.gui.icon.new_folder(widget.style())) + add_task_button.clicked.connect(lambda: todo.gui.tasks.dialog.add(widget, status, add_task_signal).exec_()) + layout.addWidget(add_task_button) + + layout.addSpacing(20) + + table = todo.gui.tasks.table.widget.Widget(widget, on_show, add_task_signal, status) + layout.addWidget(table) + + return widget diff --git a/todo/gui/window.py b/todo/gui/window.py new file mode 100644 index 0000000..0391ee9 --- /dev/null +++ b/todo/gui/window.py @@ -0,0 +1,36 @@ +from PyQt5 import QtCore, QtWidgets + +import todo.gui.tasks.widget +import todo.gui.tasks.widget +import todo.gui.tags.panel.widget +import todo.gui.signal +from todo.model.status import Status + +def get(): + window = QtWidgets.QMainWindow() + window.setWindowTitle("todo") + window.setMinimumSize(QtCore.QSize(640, 480)) + + tabs = QtWidgets.QTabWidget(window) + window.setCentralWidget(tabs) + + show_ready = todo.gui.signal.Reload() + show_waiting = todo.gui.signal.Reload() + show_maybe = todo.gui.signal.Reload() + + def on_current_tab_changed(index: int): + if index == 0: + show_ready.emit() + elif index == 1: + show_waiting.emit() + elif index == 2: + show_maybe.emit() + + tabs.currentChanged.connect(on_current_tab_changed) + + tabs.addTab(todo.gui.tasks.widget.widget(tabs, show_ready, Status.READY), "Ready") + tabs.addTab(todo.gui.tasks.widget.widget(tabs, show_waiting, Status.WAITING), "Waiting") + tabs.addTab(todo.gui.tasks.widget.widget(tabs, show_maybe, Status.MAYBE), "Maybe") + tabs.addTab(todo.gui.tags.panel.widget.widget(tabs), "Tags") + + return window diff --git a/todo/model/__init__.py b/todo/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/model/difficulty.py b/todo/model/difficulty.py new file mode 100644 index 0000000..526cdb9 --- /dev/null +++ b/todo/model/difficulty.py @@ -0,0 +1,30 @@ +from enum import IntEnum +from typing import Optional + +class Difficulty(IntEnum): + EASY = 0 + NORMAL = 1 + HARD = 2 + +values = [ + Difficulty.EASY, + Difficulty.NORMAL, + Difficulty.HARD] + +def format(difficulty: Difficulty) -> str: + if difficulty == Difficulty.EASY: + return "Easy" + elif difficulty == Difficulty.NORMAL: + return "Normal" + elif difficulty == Difficulty.HARD: + return "Hard" + +def parse(string: str) -> Optional[Difficulty]: + if string == "Easy": + return Difficulty.EASY + elif string == "Normal": + return Difficulty.NORMAL + elif string == "Hard": + return Difficulty.HARD + else: + return None diff --git a/todo/model/priority.py b/todo/model/priority.py new file mode 100644 index 0000000..5948104 --- /dev/null +++ b/todo/model/priority.py @@ -0,0 +1,30 @@ +from enum import IntEnum +from typing import Optional + +class Priority(IntEnum): + LOW = 0 + MIDDLE = 1 + HIGH = 2 + +values = [ + Priority.LOW, + Priority.MIDDLE, + Priority.HIGH] + +def format(priority: Priority) -> str: + if priority == Priority.LOW: + return "Low" + elif priority == Priority.MIDDLE: + return "Middle" + elif priority == Priority.HIGH: + return "High" + +def parse(string: str) -> Optional[Priority]: + if string == "Low": + return Priority.LOW + elif string == "Middle": + return Priority.MIDDLE + elif string == "High": + return Priority.HIGH + else: + return None diff --git a/todo/model/status.py b/todo/model/status.py new file mode 100644 index 0000000..6881e0a --- /dev/null +++ b/todo/model/status.py @@ -0,0 +1,30 @@ +from enum import IntEnum +from typing import Optional + +class Status(IntEnum): + READY = 0 + WAITING = 1 + MAYBE = 2 + +values = [ + Status.READY, + Status.WAITING, + Status.MAYBE] + +def format(status: Status) -> str: + if status == Status.READY: + return "Ready" + elif status == Status.WAITING: + return "Waiting" + elif status == Status.MAYBE: + return "Maybe" + +def parse(string: str) -> Optional[Status]: + if string == "Ready": + return Status.READY + elif string == "Waiting": + return Status.WAITING + elif string == "Maybe": + return Status.MAYBE + else: + return None diff --git a/todo/model/tag.py b/todo/model/tag.py new file mode 100644 index 0000000..030b223 --- /dev/null +++ b/todo/model/tag.py @@ -0,0 +1,12 @@ +from typing import NamedTuple + +class Tag(NamedTuple): + id: int + created_at: int + updated_at: int + name: str + color: str + +class ValidTagForm(NamedTuple): + name: str + color: str diff --git a/todo/model/task.py b/todo/model/task.py new file mode 100644 index 0000000..f20cbc9 --- /dev/null +++ b/todo/model/task.py @@ -0,0 +1,22 @@ +from typing import NamedTuple, List + +from todo.model.difficulty import Difficulty +from todo.model.priority import Priority + +class Task(NamedTuple): + id: int + created_at: int + updated_at: int + name: str + duration: int + difficulty: Difficulty + priority: Priority + description: str + +class ValidTaskForm(NamedTuple): + name: str + duration: int + difficulty: Difficulty + priority: Priority + tags: List[int] + description: str diff --git a/todo/model/task_tag.py b/todo/model/task_tag.py new file mode 100644 index 0000000..0a33c66 --- /dev/null +++ b/todo/model/task_tag.py @@ -0,0 +1,5 @@ +from typing import NamedTuple + +class TaskTag(NamedTuple): + task_id: int + tag_id: int diff --git a/todo/service/__init__.py b/todo/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/service/tasks.py b/todo/service/tasks.py new file mode 100644 index 0000000..2cebf00 --- /dev/null +++ b/todo/service/tasks.py @@ -0,0 +1,32 @@ +from typing import List + +from todo.model.task import Task, ValidTaskForm +from todo.model.status import Status +import todo.db.tasks +import todo.db.task_tags +import todo.database + +def get(cursor, status: Status) -> List[Task]: + return todo.db.tasks.get(cursor, status) + +def create(cursor, status: Status, task_form: ValidTaskForm) -> Task: + task = todo.db.tasks.insert(cursor, status, task_form) + todo.db.task_tags.insert_many(cursor, task.id, task_form.tags) + todo.database.commit() + return task + +def update(cursor, task: Task, task_form: ValidTaskForm) -> Task: + todo.db.task_tags.delete(cursor, [task.id]) + updated_task = todo.db.tasks.update(cursor, task, task_form) + todo.db.task_tags.insert_many(cursor, task.id, task_form.tags) + todo.database.commit() + return updated_task + +def delete(cursor, task_ids: List[int]): + todo.db.task_tags.delete(cursor, task_ids) + todo.db.tasks.delete(cursor, task_ids) + todo.database.commit() + +def update_status(cursor, task_ids: List[int], status: Status) -> List[Task]: + todo.db.tasks.update_status(cursor, task_ids, status) + todo.database.commit() diff --git a/todo/util/__init__.py b/todo/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/util/array.py b/todo/util/array.py new file mode 100644 index 0000000..bb4eee3 --- /dev/null +++ b/todo/util/array.py @@ -0,0 +1,5 @@ +def insert_position(x, xs, is_reversed: bool) -> int: + for i, y in enumerate(xs): + if is_reversed and x >= y or not is_reversed and x <= y: + return i + return len(xs) diff --git a/todo/util/range.py b/todo/util/range.py new file mode 100644 index 0000000..bd4b27e --- /dev/null +++ b/todo/util/range.py @@ -0,0 +1,30 @@ +from typing import NamedTuple, List + +class Range(NamedTuple): + start: int + length: int + +def from_indexes(indexes: List[int]) -> List[Range]: + ranges = [] + curr_range_start = 0 + curr_range_len = 0 + + last_index = -1 + + for index in sorted(indexes): + if index == curr_range_start + curr_range_len: + curr_range_len += 1 + else: + if curr_range_len > 0: + ranges.append(Range( + start = curr_range_start, + length = curr_range_len)) + curr_range_start = index + curr_range_len = 1 + + if curr_range_len > 0: + ranges.append(Range( + start = curr_range_start, + length = curr_range_len)) + + return ranges diff --git a/todo/util/test_array.py b/todo/util/test_array.py new file mode 100644 index 0000000..0186403 --- /dev/null +++ b/todo/util/test_array.py @@ -0,0 +1,12 @@ +from todo.util.array import insert_position + +def test_insert_position(): + assert insert_position(0, [], False) == 0 + assert insert_position(1, [1, 2, 3], False) == 0 + assert insert_position(2, [1, 2, 3], False) == 1 + assert insert_position(3, [1, 2, 3], False) == 2 + assert insert_position(8, [1, 2, 3], False) == 3 + assert insert_position(8, [3, 2, 1], True) == 0 + assert insert_position(3, [3, 2, 1], True) == 0 + assert insert_position(2, [3, 2, 1], True) == 1 + assert insert_position(1, [3, 2, 1], True) == 2 diff --git a/todo/util/test_range.py b/todo/util/test_range.py new file mode 100644 index 0000000..8a96636 --- /dev/null +++ b/todo/util/test_range.py @@ -0,0 +1,6 @@ +from todo.util.range import from_indexes, Range + +def test_from_indexes(): + assert from_indexes([]) == [] + assert from_indexes([1]) == [Range(1, 1)] + assert from_indexes([9, 6, 0, 10]) == [Range(0, 1), Range(6, 1), Range(9, 2)] -- cgit v1.2.3