From 8a6e10d401eea8db0947f8c4b309b8a6256f9748 Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 10 May 2020 20:24:24 +0200 Subject: Add tags panel --- src/gui/color.py | 11 ++-- src/gui/tags/dialog.py | 64 ++++++++++++++++++++ src/gui/tags/form/state.py | 58 +++++++++++++++++++ src/gui/tags/form/widget.py | 135 +++++++++++++++++++++++++++++++++++++++++++ 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/form/state.py | 3 +- src/gui/tasks/form/widget.py | 1 - src/gui/tasks/table/menu.py | 2 +- src/gui/tasks/table/model.py | 1 - src/gui/tasks/widget.py | 3 +- src/gui/window.py | 8 ++- 15 files changed, 525 insertions(+), 13 deletions(-) create mode 100644 src/gui/tags/dialog.py create mode 100644 src/gui/tags/form/state.py create mode 100644 src/gui/tags/form/widget.py create mode 100644 src/gui/tags/signal.py create mode 100644 src/gui/tags/table/menu.py create mode 100644 src/gui/tags/table/model.py create mode 100644 src/gui/tags/table/widget.py create mode 100644 src/gui/tags/widget.py (limited to 'src/gui') diff --git a/src/gui/color.py b/src/gui/color.py index f02fbae..cc7e5a8 100644 --- a/src/gui/color.py +++ b/src/gui/color.py @@ -1,9 +1,12 @@ from PyQt5 import QtGui -red: QtGui.QColor = QtGui.QColor(200, 30, 30) -orange: QtGui.QColor = QtGui.QColor(200, 100, 30) -green: QtGui.QColor = QtGui.QColor(30, 180, 30) -blue: QtGui.QColor = QtGui.QColor(30, 30, 200) +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 diff --git a/src/gui/tags/dialog.py b/src/gui/tags/dialog.py new file mode 100644 index 0000000..1dd99fa --- /dev/null +++ b/src/gui/tags/dialog.py @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000..931e67a --- /dev/null +++ b/src/gui/tags/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/form/widget.py b/src/gui/tags/form/widget.py new file mode 100644 index 0000000..92a5db2 --- /dev/null +++ b/src/gui/tags/form/widget.py @@ -0,0 +1,135 @@ +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/signal.py b/src/gui/tags/signal.py new file mode 100644 index 0000000..57ec696 --- /dev/null +++ b/src/gui/tags/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 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 new file mode 100644 index 0000000..7acdda1 --- /dev/null +++ b/src/gui/tags/table/menu.py @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..7c66b5d --- /dev/null +++ b/src/gui/tags/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/table/widget.py b/src/gui/tags/table/widget.py new file mode 100644 index 0000000..89c9990 --- /dev/null +++ b/src/gui/tags/table/widget.py @@ -0,0 +1,71 @@ +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 new file mode 100644 index 0000000..a9d870e --- /dev/null +++ b/src/gui/tags/widget.py @@ -0,0 +1,26 @@ +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/form/state.py b/src/gui/tasks/form/state.py index 727bedd..9bd3ae3 100644 --- a/src/gui/tasks/form/state.py +++ b/src/gui/tasks/form/state.py @@ -1,6 +1,5 @@ -import re from PyQt5 import QtCore -from typing import NamedTuple, Optional +from typing import Optional from model.task import ValidTaskForm from model.difficulty import Difficulty diff --git a/src/gui/tasks/form/widget.py b/src/gui/tasks/form/widget.py index adaf757..9feaad0 100644 --- a/src/gui/tasks/form/widget.py +++ b/src/gui/tasks/form/widget.py @@ -1,7 +1,6 @@ from PyQt5 import QtWidgets, QtCore from typing import Optional, Tuple, List, Any -import db.tasks from model.task import Task, ValidTaskForm from model import difficulty, priority import gui.icon diff --git a/src/gui/tasks/table/menu.py b/src/gui/tasks/table/menu.py index edc9833..435ff25 100644 --- a/src/gui/tasks/table/menu.py +++ b/src/gui/tasks/table/menu.py @@ -14,7 +14,7 @@ def open(database, table, update_task_signal, position): else: modify_action = QtWidgets.QAction(menu) - delete_action = menu.addAction(gui.icon.trash(menu.style()), 'Delete') + delete_action = menu.addAction(gui.icon.trash(menu.style()), 'delete') action = menu.exec_(table.mapToGlobal(position)) if action == modify_action and len(rows) == 1: diff --git a/src/gui/tasks/table/model.py b/src/gui/tasks/table/model.py index bf9e386..ab969ec 100644 --- a/src/gui/tasks/table/model.py +++ b/src/gui/tasks/table/model.py @@ -35,7 +35,6 @@ class TableModel(QtCore.QAbstractTableModel): task = self._tasks[index.row()] if role == Qt.DisplayRole: - task = self._tasks[index.row()] if index.column() == 0: return age_since(task.created_at) elif index.column() == 1: diff --git a/src/gui/tasks/widget.py b/src/gui/tasks/widget.py index 61ad605..ea4acb9 100644 --- a/src/gui/tasks/widget.py +++ b/src/gui/tasks/widget.py @@ -1,9 +1,8 @@ -from PyQt5 import QtWidgets, QtCore +from PyQt5 import QtWidgets import gui.tasks.signal import gui.tasks.table.widget import gui.icon -from model.task import ValidTaskForm def widget(database, parent): widget = QtWidgets.QWidget(parent) diff --git a/src/gui/window.py b/src/gui/window.py index aa22f7e..622f65d 100644 --- a/src/gui/window.py +++ b/src/gui/window.py @@ -1,13 +1,17 @@ from PyQt5 import QtCore, QtWidgets import gui.tasks.widget +import gui.tags.widget def get(database): window = QtWidgets.QMainWindow() window.setWindowTitle("todo-next") window.setMinimumSize(QtCore.QSize(640, 480)) - centralWidget = QtWidgets.QWidget(window) - window.setCentralWidget(gui.tasks.widget.widget(database, centralWidget)) + 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') return window -- cgit v1.2.3