From 0f1610333324d58acafee8c0fa9d9c9bc293b219 Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 31 May 2020 13:59:35 +0200 Subject: Use defined tags for tasks --- src/database.py | 19 +++ src/db/init.py | 11 +- src/db/tags.py | 3 +- src/db/task_tags.py | 30 +++++ src/db/tasks.py | 21 ++- src/gui/icon.py | 2 +- src/gui/signal.py | 13 ++ src/gui/tags/dialog.py | 64 --------- src/gui/tags/form/state.py | 58 -------- src/gui/tags/form/widget.py | 135 ------------------- 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 | 25 ++++ src/gui/tags/panel/table/model.py | 103 +++++++++++++++ src/gui/tags/panel/table/widget.py | 70 ++++++++++ src/gui/tags/panel/widget.py | 25 ++++ src/gui/tags/signal.py | 27 ---- src/gui/tags/table/menu.py | 25 ---- src/gui/tags/table/model.py | 103 --------------- src/gui/tags/table/widget.py | 71 ---------- src/gui/tags/widget.py | 26 ---- src/gui/tasks/dialog.py | 38 +++--- src/gui/tasks/form/state.py | 19 ++- src/gui/tasks/form/widget.py | 15 ++- src/gui/tasks/signal.py | 21 +-- src/gui/tasks/table/menu.py | 10 +- src/gui/tasks/table/model.py | 165 ----------------------- src/gui/tasks/table/widget.py | 263 +++++++++++++++++++++++++++++++------ src/gui/tasks/widget.py | 8 +- src/gui/window.py | 16 ++- src/main.py | 5 +- src/model/task.py | 5 +- src/model/task_tag.py | 5 + src/service/tasks.py | 27 ++++ 37 files changed, 974 insertions(+), 788 deletions(-) create mode 100644 src/database.py create mode 100644 src/db/task_tags.py create mode 100644 src/gui/signal.py delete mode 100644 src/gui/tags/dialog.py delete mode 100644 src/gui/tags/form/state.py delete mode 100644 src/gui/tags/form/widget.py create mode 100644 src/gui/tags/list.py create mode 100644 src/gui/tags/panel/dialog.py create mode 100644 src/gui/tags/panel/form/state.py create mode 100644 src/gui/tags/panel/form/widget.py create mode 100644 src/gui/tags/panel/signal.py create mode 100644 src/gui/tags/panel/table/menu.py create mode 100644 src/gui/tags/panel/table/model.py create mode 100644 src/gui/tags/panel/table/widget.py create mode 100644 src/gui/tags/panel/widget.py delete mode 100644 src/gui/tags/signal.py delete mode 100644 src/gui/tags/table/menu.py delete mode 100644 src/gui/tags/table/model.py delete mode 100644 src/gui/tags/table/widget.py delete mode 100644 src/gui/tags/widget.py delete mode 100644 src/gui/tasks/table/model.py create mode 100644 src/model/task_tag.py create mode 100644 src/service/tasks.py diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..478f62e --- /dev/null +++ b/src/database.py @@ -0,0 +1,19 @@ +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 index 8292dfc..6b4cbea 100644 --- a/src/db/init.py +++ b/src/db/init.py @@ -19,7 +19,6 @@ def init(path): " updated_at INTEGER NOT NULL," " name TEXT NOT NULL," " duration INTEGER," - " tag TEXT," " difficulty INT," " priority INT," " description TEXT" @@ -34,6 +33,16 @@ def init(path): " 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)" + " )") + database.commit() return database diff --git a/src/db/tags.py b/src/db/tags.py index 0f0d345..76a276d 100644 --- a/src/db/tags.py +++ b/src/db/tags.py @@ -1,9 +1,10 @@ from sqlite3 import Cursor import time +from typing import List from model.tag import Tag, ValidTagForm -def get(cursor: Cursor) -> Tag: +def get(cursor: Cursor) -> List[Tag]: cursor.execute( " SELECT" " id," diff --git a/src/db/task_tags.py b/src/db/task_tags.py new file mode 100644 index 0000000..34366e0 --- /dev/null +++ b/src/db/task_tags.py @@ -0,0 +1,30 @@ +from sqlite3 import Cursor +import time +from typing import List + +from model.task_tag import TaskTag + +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 index 29d3ba6..b72965b 100644 --- a/src/db/tasks.py +++ b/src/db/tasks.py @@ -1,9 +1,10 @@ from sqlite3 import Cursor import time +from typing import List from model.task import Task, ValidTaskForm -def get(cursor: Cursor) -> Task: +def get(cursor: Cursor) -> List[Task]: cursor.execute( " SELECT" " id," @@ -11,7 +12,6 @@ def get(cursor: Cursor) -> Task: " updated_at," " name," " duration," - " tag," " difficulty," " priority," " description" @@ -26,10 +26,9 @@ def get(cursor: Cursor) -> Task: updated_at = task[2], name = task[3], duration = task[4], - tag = task[5], - difficulty = task[6], - priority = task[7], - description = task[8] + difficulty = task[5], + priority = task[6], + description = task[7] )) return res @@ -42,12 +41,11 @@ def insert(cursor: Cursor, form: ValidTaskForm): " updated_at," " name," " duration," - " tag," " difficulty," " priority," " description" - " ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (now, now, form.name, form.duration, form.tag, int(form.difficulty), int(form.priority), form.description)) + " ) VALUES (?, ?, ?, ?, ?, ?, ?)", + (now, now, form.name, form.duration, int(form.difficulty), int(form.priority), form.description)) return Task( id = cursor.lastrowid, @@ -55,7 +53,6 @@ def insert(cursor: Cursor, form: ValidTaskForm): updated_at = now, name = form.name, duration = form.duration, - tag = form.tag, difficulty = form.difficulty, priority = form.priority, description = form.description @@ -69,12 +66,11 @@ def update(cursor: Cursor, task: Task, form: ValidTaskForm): " updated_at = ?," " name = ?," " duration = ?," - " tag = ?," " difficulty = ?," " priority = ?," " description = ?" " WHERE id = ?", - (now, form.name, form.duration, form.tag, int(form.difficulty), int(form.priority), form.description, task.id)) + (now, form.name, form.duration, int(form.difficulty), int(form.priority), form.description, task.id)) return Task( id = task.id, @@ -82,7 +78,6 @@ def update(cursor: Cursor, task: Task, form: ValidTaskForm): updated_at = now, name = form.name, duration = form.duration, - tag = form.tag, difficulty = form.difficulty, priority = form.priority, description = form.description diff --git a/src/gui/icon.py b/src/gui/icon.py index 2f8830e..c6584cb 100644 --- a/src/gui/icon.py +++ b/src/gui/icon.py @@ -7,7 +7,7 @@ def dialog_open(style): return style.standardIcon(QtWidgets.QStyle.SP_DialogOpenButton) def dialog_apply(style): - return style.standardIcon(QtWidgets.QStyle.SP_DialogApplyButton) + return style.standardIcon(QtWidgets.QStyle.SP_DialogOkButton) def dialog_cancel(style): return style.standardIcon(QtWidgets.QStyle.SP_DialogCancelButton) diff --git a/src/gui/signal.py b/src/gui/signal.py new file mode 100644 index 0000000..b604929 --- /dev/null +++ b/src/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/src/gui/tags/dialog.py b/src/gui/tags/dialog.py deleted file mode 100644 index 1dd99fa..0000000 --- a/src/gui/tags/dialog.py +++ /dev/null @@ -1,64 +0,0 @@ -from PyQt5 import QtCore, QtWidgets - -from model.tag import Tag, ValidTagForm - -import db.tags -import gui.tags.form.widget - -def add(database, 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(database, 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(database, 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.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/form/state.py b/src/gui/tags/form/state.py deleted file mode 100644 index 931e67a..0000000 --- a/src/gui/tags/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/form/widget.py b/src/gui/tags/form/widget.py deleted file mode 100644 index 92a5db2..0000000 --- a/src/gui/tags/form/widget.py +++ /dev/null @@ -1,135 +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.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.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) - self.update(color) - else: - self._is_editing = False - self.clearFocus() - return super(ColorInput, self).eventFilter(source, event) - - def update(self, color: QtGui.QColor): - 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_apply(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/list.py b/src/gui/tags/list.py new file mode 100644 index 0000000..ad70dd0 --- /dev/null +++ b/src/gui/tags/list.py @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..7a6d3bc --- /dev/null +++ b/src/gui/tags/panel/dialog.py @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..931e67a --- /dev/null +++ b/src/gui/tags/panel/form/state.py @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..5627e3b --- /dev/null +++ b/src/gui/tags/panel/form/widget.py @@ -0,0 +1,137 @@ +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_apply(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 new file mode 100644 index 0000000..9417530 --- /dev/null +++ b/src/gui/tags/panel/signal.py @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..f95c097 --- /dev/null +++ b/src/gui/tags/panel/table/menu.py @@ -0,0 +1,25 @@ +from PyQt5 import QtWidgets + +import db.tags +import gui.tags.panel.dialog +from model.tag import Tag, ValidTagForm + +def open(table, update_tag_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') + + action = menu.exec_(table.mapToGlobal(position)) + 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 new file mode 100644 index 0000000..7c66b5d --- /dev/null +++ b/src/gui/tags/panel/table/model.py @@ -0,0 +1,103 @@ +from PyQt5 import QtCore, QtWidgets, QtGui +from PyQt5.QtCore import Qt + +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 new file mode 100644 index 0000000..f0bf82c --- /dev/null +++ b/src/gui/tags/panel/table/widget.py @@ -0,0 +1,70 @@ +from PyQt5 import QtWidgets +from PyQt5.QtCore import Qt + +import db.tags +import gui.tags.panel.signal +import gui.tags.panel.table.menu +import gui.tags.panel.table.model +import gui.tags.panel.dialog +from model.tag import Tag, ValidTagForm +import database + +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() + 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 new file mode 100644 index 0000000..3da55c9 --- /dev/null +++ b/src/gui/tags/panel/widget.py @@ -0,0 +1,25 @@ +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) + + add_tag_signal = gui.tags.panel.signal.AddTag() + + add_tag_button = QtWidgets.QPushButton('Add a tag', widget) + 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) + + table = gui.tags.panel.table.widget.Widget(widget, add_tag_signal) + layout.addWidget(table) + + return widget diff --git a/src/gui/tags/signal.py b/src/gui/tags/signal.py deleted file mode 100644 index 57ec696..0000000 --- a/src/gui/tags/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 get(self): - return self._signal - -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 get(self): - return self._signal diff --git a/src/gui/tags/table/menu.py b/src/gui/tags/table/menu.py deleted file mode 100644 index 7acdda1..0000000 --- a/src/gui/tags/table/menu.py +++ /dev/null @@ -1,25 +0,0 @@ -from PyQt5 import QtWidgets - -import db.tags -import gui.tags.dialog -from model.tag import Tag, ValidTagForm - -def open(database, table, update_tag_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') - - action = menu.exec_(table.mapToGlobal(position)) - if action == modify_action and len(rows) == 1: - row = list(rows)[0] - tag = table.model().get_at(row) - gui.tags.dialog.update(database, table, update_tag_signal, row, tag).exec_() - elif action == delete_action: - gui.tags.dialog.show_delete(database, table, rows) diff --git a/src/gui/tags/table/model.py b/src/gui/tags/table/model.py deleted file mode 100644 index 7c66b5d..0000000 --- a/src/gui/tags/table/model.py +++ /dev/null @@ -1,103 +0,0 @@ -from PyQt5 import QtCore, QtWidgets, QtGui -from PyQt5.QtCore import Qt - -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/table/widget.py b/src/gui/tags/table/widget.py deleted file mode 100644 index 89c9990..0000000 --- a/src/gui/tags/table/widget.py +++ /dev/null @@ -1,71 +0,0 @@ -from PyQt5 import QtWidgets -from PyQt5.QtCore import Qt - -import db.tags -import gui.tags.signal -import gui.tags.table.menu -import gui.tags.table.model -import gui.tags.dialog -from model.tag import Tag, ValidTagForm - -class Widget(QtWidgets.QTableView): - - def __init__(self, database, parent, add_tag_signal): - super().__init__(parent) - - self._database = database - self._update_tag_signal = gui.tags.signal.UpdateTag() - - tags = db.tags.get(self._database.cursor()) - table_model = gui.tags.table.model.TableModel(tags) - - self.setModel(table_model) - self.sortByColumn( - gui.tags.table.model.default_sort[0], - gui.tags.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.table.menu.open(self._database, self, self._update_tag_signal, position)) - - add_tag_signal.get().connect(lambda tag: self.insert(tag)) - self._update_tag_signal.get().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.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.dialog.update( - self._database, self, self._update_tag_signal, row, tag).exec_() - elif event.key() == Qt.Key_Delete: - rows = self.get_selected_rows() - gui.tags.dialog.show_delete(self._database, 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.dialog.update(self._database, self, self._update_tag_signal, row, tag).exec_() diff --git a/src/gui/tags/widget.py b/src/gui/tags/widget.py deleted file mode 100644 index a9d870e..0000000 --- a/src/gui/tags/widget.py +++ /dev/null @@ -1,26 +0,0 @@ -from PyQt5 import QtWidgets - -import gui.tags.dialog -import gui.tags.signal -import gui.tags.table.widget -import gui.icon - -def widget(database, parent): - widget = QtWidgets.QWidget(parent) - - layout = QtWidgets.QVBoxLayout(widget) - widget.setLayout(layout) - - add_tag_signal = gui.tags.signal.AddTag() - - add_tag_button = QtWidgets.QPushButton('Add a tag', widget) - add_tag_button.setIcon(gui.icon.new_folder(widget.style())) - - add_tag_button.clicked.connect(lambda: gui.tags.dialog.add( - database, widget, add_tag_signal).exec_()) - layout.addWidget(add_tag_button) - - table = gui.tags.table.widget.Widget(database, widget, add_tag_signal) - layout.addWidget(table) - - return widget diff --git a/src/gui/tasks/dialog.py b/src/gui/tasks/dialog.py index 2e0d8d4..a28ecb0 100644 --- a/src/gui/tasks/dialog.py +++ b/src/gui/tasks/dialog.py @@ -1,46 +1,47 @@ from PyQt5 import QtCore, QtWidgets +from typing import List +from model.tag import Tag from model.task import Task, ValidTaskForm - +import database +import db.task_tags import db.tasks import gui.tasks.form.widget +import service.tasks -def add(database, parent_widget, add_task_signal): +def add(parent_widget, add_task_signal): - def on_add(form: ValidTaskForm): - task = db.tasks.insert(database.cursor(), form) - database.commit() - add_task_signal.emit(task) + def on_add(task_form: ValidTaskForm): + task = service.tasks.create(database.cursor(), task_form) + add_task_signal.emit(task, task_form.tags) - return widget(parent_widget, 'Add a task', 'add', None, on_add) + return widget(parent_widget, 'Add a task', 'add', None, [], on_add) -def update(database, parent_widget, update_task_signal, row, task): +def update(parent_widget, update_task_signal, row: int, task: Task, tags: List[int]): - def on_update(form: ValidTaskForm): - updated_task = db.tasks.update(database.cursor(), task, form) - update_task_signal.emit(row, updated_task) - database.commit() + 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, on_update) + return widget(parent_widget, 'Modify a task', 'modify', task, tags, on_update) -def show_delete(database, table, rows): +def show_delete(parent, rows: List[int], on_delete): confirm = QtWidgets.QMessageBox.question( - table, + 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: - db.tasks.delete(database.cursor(), table.model().row_ids(rows)) - database.commit() - table.model().delete_tasks(rows) + on_delete() def widget( parent: QtWidgets.QWidget, title: str, action_title: str, task: Task, + tags: List[int], on_validated): dialog = QtWidgets.QDialog(parent) @@ -58,6 +59,7 @@ def widget( parent = dialog, action_title = action_title, task = task, + tags = tags, on_validated = on_dialog_validated, on_cancel = lambda: dialog.reject())) diff --git a/src/gui/tasks/form/state.py b/src/gui/tasks/form/state.py index 9bd3ae3..5b48cd9 100644 --- a/src/gui/tasks/form/state.py +++ b/src/gui/tasks/form/state.py @@ -5,6 +5,7 @@ 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__( @@ -13,34 +14,32 @@ class TaskFormEdition: name_signal, duration, duration_signal, - tag, - tag_signal, difficulty, difficulty_signal, priority, priority_signal, + tags_signal: gui.tags.list.SelectionSignal, description, description_signal): self._name = name self._duration = duration - self._tag = tag 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)) - tag_signal.connect(lambda t: self.on_tag_signal(t)) 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) - tag = self._tag.strip() difficulty = self._difficulty priority = self._priority description = self._description.strip() @@ -49,9 +48,9 @@ class TaskFormEdition: return ValidTaskForm( name = name, duration = duration, - tag = tag, difficulty = difficulty, priority = priority, + tags = self._tags, description = description) else: return None @@ -64,10 +63,6 @@ class TaskFormEdition: self._duration = duration self.emit() - def on_tag_signal(self, tag: str): - self._tag = tag - self.emit() - def on_difficulty_signal(self, index: int): self._difficulty = Difficulty(index) self.emit() @@ -76,6 +71,10 @@ class TaskFormEdition: 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() diff --git a/src/gui/tasks/form/widget.py b/src/gui/tasks/form/widget.py index 9feaad0..49339e1 100644 --- a/src/gui/tasks/form/widget.py +++ b/src/gui/tasks/form/widget.py @@ -2,10 +2,12 @@ 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') @@ -23,6 +25,7 @@ def widget( parent: QtWidgets.QWidget, action_title: str, task: Task, + tags: List[int], on_validated, on_cancel): @@ -41,9 +44,6 @@ def widget( 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_tag = task.tag if task is not None else '' - tag_input = line_edit(grid, grid_layout, 2, 'Tag', init_tag) - init_difficulty = task.difficulty if task is not None else difficulty.Difficulty.NORMAL difficulty_input = combo_box( grid, @@ -62,6 +62,9 @@ def widget( [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) @@ -71,12 +74,11 @@ def widget( name_input.textChanged, init_duration, duration_input.textChanged, - init_tag, - tag_input.textChanged, init_difficulty, difficulty_input.currentIndexChanged, init_priority, priority_input.currentIndexChanged, + tags_signal, init_description, description_signal) @@ -131,6 +133,9 @@ def combo_box( 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, diff --git a/src/gui/tasks/signal.py b/src/gui/tasks/signal.py index 7d926e1..e62a838 100644 --- a/src/gui/tasks/signal.py +++ b/src/gui/tasks/signal.py @@ -1,27 +1,28 @@ from PyQt5 import QtCore +from typing import List from model.task import Task class AddTask(QtCore.QObject): - _signal = QtCore.pyqtSignal(Task, name = 'addTask') + _signal = QtCore.pyqtSignal(Task, list, name = 'addTask') def __init__(self): QtCore.QObject.__init__(self) - def emit(self, task): - self._signal.emit(task) + def emit(self, task: Task, tags: List[int]): + self._signal.emit(task, tags) - def get(self): - return self._signal + def connect(self, f): + self._signal.connect(f) class UpdateTask(QtCore.QObject): - _signal = QtCore.pyqtSignal(int, Task, name = 'updateTask') + _signal = QtCore.pyqtSignal(int, Task, list, name = 'updateTask') def __init__(self): QtCore.QObject.__init__(self) - def emit(self, row, task): - self._signal.emit(row, task) + def emit(self, row: int, task: Task, tags: List[int]): + self._signal.emit(row, task, tags) - def get(self): - return self._signal + def connect(self, f): + self._signal.connect(f) diff --git a/src/gui/tasks/table/menu.py b/src/gui/tasks/table/menu.py index 435ff25..f22176c 100644 --- a/src/gui/tasks/table/menu.py +++ b/src/gui/tasks/table/menu.py @@ -1,10 +1,12 @@ from PyQt5 import QtWidgets +from typing import List import db.tasks import gui.tasks.dialog from model.task import Task, ValidTaskForm +from model.tag import Tag -def open(database, table, update_task_signal, position): +def open(table, update_task_signal, position): rows = set([index.row() for index in table.selectedIndexes()]) menu = QtWidgets.QMenu(table) @@ -19,7 +21,7 @@ def open(database, table, update_task_signal, position): action = menu.exec_(table.mapToGlobal(position)) if action == modify_action and len(rows) == 1: row = list(rows)[0] - task = table.model().get_at(row) - gui.tasks.dialog.update(database, table, update_task_signal, row, task).exec_() + (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.show_delete(database, table, rows) + gui.tasks.dialog.show_delete(table, rows, lambda: table.delete_rows(rows)) diff --git a/src/gui/tasks/table/model.py b/src/gui/tasks/table/model.py deleted file mode 100644 index ab969ec..0000000 --- a/src/gui/tasks/table/model.py +++ /dev/null @@ -1,165 +0,0 @@ -from PyQt5 import QtCore, QtWidgets, QtGui -from PyQt5.QtCore import Qt - -from model.task import Task -from model.difficulty import Difficulty -from model.priority import Priority -from model import difficulty, priority -import time -import math -import util.array -import util.range -import gui.tasks.duration -import gui.color - -columns = 6 - -headers = ['Age', 'Name', 'Duration', 'Difficulty', 'Priority', 'Tag'] - -default_sort = (0, Qt.AscendingOrder) - -class TableModel(QtCore.QAbstractTableModel): - def __init__(self, tasks): - super(TableModel, self).__init__() - self._tasks = tasks - - 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): - task = self._tasks[index.row()] - - if role == Qt.DisplayRole: - if index.column() == 0: - return age_since(task.created_at) - elif index.column() == 1: - return task.name - elif index.column() == 2: - return gui.tasks.duration.format(task.duration) - elif index.column() == 3: - return difficulty.format(task.difficulty) - elif index.column() == 4: - return priority.format(task.priority) - elif index.column() == 5: - return task.tag - elif role == Qt.ForegroundRole: - if index.column() == 2: - return QtGui.QBrush(gui.tasks.duration.color(task.duration)) - elif index.column() == 3: - return QtGui.QBrush(difficulty_color(task.difficulty)) - elif index.column() == 4: - return QtGui.QBrush(priority_color(task.priority)) - else: - return QtCore.QVariant() - - def rowCount(self, index): - return len(self._tasks) - - def columnCount(self, index): - return columns - - def get_at(self, row): - if row >= 0 and row < len(self._tasks): - return self._tasks[row] - - - def insert_task(self, header: QtWidgets.QHeaderView, task: Task) -> int: - at = self.insert_position(header, task) - self.beginInsertRows(QtCore.QModelIndex(), at, at) - self._tasks.insert(at, task) - self.endInsertRows() - return at - - def insert_position(self, header: QtWidgets.QHeaderView, task: Task) -> int: - row = header.sortIndicatorSection() - order = header.sortIndicatorOrder() - is_rev = is_reversed(row, order) - return util.array.insert_position( - sort_key(task, row, is_rev), - [sort_key(t, row, is_rev) for t in self._tasks], - is_rev) - - def update_task(self, header: QtWidgets.QHeaderView, row, task: Task) -> int: - self.delete_task_range(row, 1) - return self.insert_task(header, task) - - def delete_tasks(self, indexes): - for range in reversed(util.range.from_indexes(indexes)): - self.delete_task_range(range.start, range.length) - return True - - def delete_task_range(self, row, rows): - self.beginRemoveRows(QtCore.QModelIndex(), row, row + rows - 1) - self._tasks = self._tasks[:row] + self._tasks[row + rows:] - self.endRemoveRows() - return True - - def row_ids(self, rows): - return [task.id for i, task in enumerate(self._tasks) if i in rows] - - def sort(self, row: int, order: Qt.SortOrder): - self.layoutAboutToBeChanged.emit() - is_rev = is_reversed(row, order) - self._tasks = sorted( - self._tasks, - key = lambda task: sort_key(task, row, is_rev), - reverse = is_rev) - self.layoutChanged.emit() - -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 sort_key(task: Task, row: int, is_reversed: bool): - 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: - if is_reversed: - return task.tag.lower() - else: - return (task.tag == '', task.tag.lower()) - -def is_reversed(row: int, order: Qt.SortOrder) -> bool: - if row == 0: - return order == Qt.AscendingOrder - else: - return order == Qt.DescendingOrder - -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/table/widget.py b/src/gui/tasks/table/widget.py index 95ebe44..0a8d216 100644 --- a/src/gui/tasks/table/widget.py +++ b/src/gui/tasks/table/widget.py @@ -1,54 +1,173 @@ -from PyQt5 import QtWidgets +from PyQt5 import QtWidgets, QtCore, QtGui from PyQt5.QtCore import Qt +from typing import List, Tuple +import time +import math -import db.tasks +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 +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 gui.tasks.table.model -import gui.tasks.dialog -from model.task import Task, ValidTaskForm +import service.tasks +import util.array +import util.range -class Widget(QtWidgets.QTableView): +header_labels = ["Age", "Name", "Duration", "Difficulty", "Priority", "Tag"] - def __init__(self, database, parent, add_task_signal): - super().__init__(parent) +class Widget(QtWidgets.QTableWidget): + def __init__( + self, + parent, + on_show: gui.signal.Reload, + add_task_signal: gui.tasks.signal.AddTask): - self._database = database - self._update_task_signal = gui.tasks.signal.UpdateTask() + super().__init__(parent) - tasks = db.tasks.get(self._database.cursor()) - table_model = gui.tasks.table.model.TableModel(tasks) + self.init_state() + self.sort() - self.setModel(table_model) - self.sortByColumn( - gui.tasks.table.model.default_sort[0], - gui.tasks.table.model.default_sort[1]) - self.setSortingEnabled(True) self.setSelectionBehavior(QtWidgets.QTableView.SelectRows) - self.horizontalHeader().setStretchLastSection(True) - self.resizeColumns() + self.init_header() + self.setRowCount(len(self._tasks)) + self.setColumnCount(len(header_labels)) + self.setColumnWidth(1, 500) - self.doubleClicked.connect(lambda index: self.on_double_click(index.row())) + self.update_view() + self.horizontalHeader().setStretchLastSection(True) # Menu self.setContextMenuPolicy(Qt.CustomContextMenu) - self.customContextMenuRequested.connect(lambda position: gui.tasks.table.menu.open(self._database, self, self._update_task_signal, position)) + self.customContextMenuRequested.connect(lambda position: gui.tasks.table.menu.open(self, 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 on_show(self): + self._tags = db.tags.get(database.cursor()) + self.update_view() + + def init_state(self): + self._update_task_signal = gui.tasks.signal.UpdateTask() + cursor = database.cursor() + self._tasks = service.tasks.get(cursor) + self._task_tags = db.task_tags.get(cursor) + self._tags = db.tags.get(cursor) + self._sort_column = 0 + self._sort_is_ascending = True + - add_task_signal.get().connect(lambda task: self.insert(task)) - self._update_task_signal.get().connect(lambda row, task: self.update(row, task)) + def init_header(self): + h = QtWidgets.QHeaderView(Qt.Horizontal, self) + self._header_model = QtGui.QStandardItemModel() + self._header_model.setHorizontalHeaderLabels(header_labels) + h.setModel(self._header_model) + h.setSectionsClickable(True) + h.sectionClicked.connect(self.on_header_click) + self.setHorizontalHeader(h) + # header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) - def insert(self, task): - self.model().insert_task(self.horizontalHeader(), task) - self.resizeColumns() + 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.setItem(column, QtGui.QStandardItem('Hey!')) + self.update_view() - def update(self, row, task): - row = self.model().update_task(self.horizontalHeader(), row, task) + 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]): + # TODO: just update if sort order is not impacted + # self._tasks[row] = task + # task_ids = [t.id for t in self._tasks] + # filtred_task_tags = [tt for tt in self._task_tags if tt.task_id in task_ids] + # 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 + # self.update_row(row) + self.delete_rows([row]) + row = self.insert(task, tags) self.selectRow(row) - self.resizeColumns() - def resizeColumns(self): - for column in range(gui.tasks.table.model.columns): - self.resizeColumnToContents(column) + 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] + 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) @@ -56,16 +175,84 @@ class Widget(QtWidgets.QTableView): rows = self.get_selected_rows() if len(rows) == 1: row = rows[0] - task = self.model().get_at(row) - gui.tasks.dialog.update( - self._database, self, self._update_task_signal, row, task).exec_() + (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._database, self, 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._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]] + for row in sorted(rows, reverse=True): + self.removeRow(row) def get_selected_rows(self): return list(set([index.row() for index in self.selectedIndexes()])) def on_double_click(self, row: int): - task = self.model().get_at(row) - gui.tasks.dialog.update(self._database, self, self._update_task_signal, row, task).exec_() + (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 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/widget.py b/src/gui/tasks/widget.py index ea4acb9..36fb743 100644 --- a/src/gui/tasks/widget.py +++ b/src/gui/tasks/widget.py @@ -3,8 +3,9 @@ from PyQt5 import QtWidgets import gui.tasks.signal import gui.tasks.table.widget import gui.icon +import gui.signal -def widget(database, parent): +def widget(parent, on_show: gui.signal.Reload): widget = QtWidgets.QWidget(parent) layout = QtWidgets.QVBoxLayout(widget) @@ -14,11 +15,10 @@ def widget(database, parent): add_task_button = QtWidgets.QPushButton('Add a task', widget) add_task_button.setIcon(gui.icon.new_folder(widget.style())) - add_task_button.clicked.connect(lambda: gui.tasks.dialog.add( - database, widget, add_task_signal).exec_()) + add_task_button.clicked.connect(lambda: gui.tasks.dialog.add(widget, add_task_signal).exec_()) layout.addWidget(add_task_button) - table = gui.tasks.table.widget.Widget(database, widget, add_task_signal) + table = gui.tasks.table.widget.Widget(widget, on_show, add_task_signal) layout.addWidget(table) return widget diff --git a/src/gui/window.py b/src/gui/window.py index 622f65d..4865edf 100644 --- a/src/gui/window.py +++ b/src/gui/window.py @@ -1,9 +1,11 @@ from PyQt5 import QtCore, QtWidgets import gui.tasks.widget -import gui.tags.widget +import gui.tasks.widget +import gui.tags.panel.widget +import gui.signal -def get(database): +def get(): window = QtWidgets.QMainWindow() window.setWindowTitle("todo-next") window.setMinimumSize(QtCore.QSize(640, 480)) @@ -11,7 +13,13 @@ def get(database): tabs = QtWidgets.QTabWidget(window) window.setCentralWidget(tabs) - tabs.addTab(gui.tasks.widget.widget(database, tabs), 'Tasks') - tabs.addTab(gui.tags.widget.widget(database, tabs), 'Tags') + open_tasks = gui.signal.Reload() + def on_current_tab_changed(index: int): + if index == 0: + open_tasks.emit() + tabs.currentChanged.connect(on_current_tab_changed) + + tabs.addTab(gui.tasks.widget.widget(tabs, open_tasks), 'Tasks') + tabs.addTab(gui.tags.panel.widget.widget(tabs), 'Tags') return window diff --git a/src/main.py b/src/main.py index c4ff4e9..104063b 100644 --- a/src/main.py +++ b/src/main.py @@ -6,12 +6,13 @@ import os.path import db.init import gui.window import arguments +import database args = arguments.parser().parse_args() -database = db.init.init(args.database if args.database != None else 'database') +database.init(args.database if args.database != None else 'database') app = QtWidgets.QApplication(sys.argv) -window = gui.window.get(database) +window = gui.window.get() window.show() res = app.exec_() diff --git a/src/model/task.py b/src/model/task.py index 4bb9a89..69f9807 100644 --- a/src/model/task.py +++ b/src/model/task.py @@ -1,4 +1,4 @@ -from typing import NamedTuple +from typing import NamedTuple, List from model.difficulty import Difficulty from model.priority import Priority @@ -9,7 +9,6 @@ class Task(NamedTuple): updated_at: int name: str duration: int - tag: str difficulty: Difficulty priority: Priority description: str @@ -17,7 +16,7 @@ class Task(NamedTuple): class ValidTaskForm(NamedTuple): name: str duration: int - tag: str difficulty: Difficulty priority: Priority + tags: List[int] description: str diff --git a/src/model/task_tag.py b/src/model/task_tag.py new file mode 100644 index 0000000..0a33c66 --- /dev/null +++ b/src/model/task_tag.py @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..6c3444b --- /dev/null +++ b/src/service/tasks.py @@ -0,0 +1,27 @@ +from typing import List + +from model.task import Task, ValidTaskForm +import db.tasks +import db.task_tags +import database + +def get(cursor) -> List[Task]: + return db.tasks.get(cursor) + +def create(cursor, task_form: ValidTaskForm) -> Task: + task = db.tasks.insert(cursor, task_form) + new_task_tags = db.task_tags.insert_many(cursor, task.id, task_form.tags) + database.commit() + return task + +def update(cursor, task: Task, task_form: ValidTaskForm) -> Task: + updated_task = db.tasks.update(cursor, task, task_form) + db.task_tags.delete(cursor, [task.id]) + new_task_tags = db.task_tags.insert_many(cursor, task.id, task_form.tags) + database.commit() + return updated_task + +def delete(cursor, task_ids: List[int]): + db.tasks.delete(cursor, task_ids) + db.task_tags.delete(cursor, task_ids) + database.commit() -- cgit v1.2.3