From df828c4b141f84f731afffbe17c80618cacf9480 Mon Sep 17 00:00:00 2001 From: Joris Date: Fri, 8 May 2020 14:12:47 +0200 Subject: Bootstrap todo-next --- .gitignore | 2 + README.md | 15 +++++ shell.nix | 19 ++++++ src/db/init.py | 18 ++++++ src/db/tasks.py | 50 +++++++++++++++ src/gui/icons.py | 16 +++++ src/gui/tasks/main.py | 39 ++++++++++++ src/gui/tasks/modal.py | 143 +++++++++++++++++++++++++++++++++++++++++++ src/gui/tasks/signal.py | 27 ++++++++ src/gui/tasks/table/main.py | 46 ++++++++++++++ src/gui/tasks/table/menu.py | 43 +++++++++++++ src/gui/tasks/table/model.py | 116 +++++++++++++++++++++++++++++++++++ src/gui/window.py | 13 ++++ src/main.py | 22 +++++++ src/model/task.py | 13 ++++ src/util/array.py | 5 ++ src/util/range.py | 30 +++++++++ 17 files changed, 617 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 shell.nix create mode 100644 src/db/init.py create mode 100644 src/db/tasks.py create mode 100644 src/gui/icons.py create mode 100644 src/gui/tasks/main.py create mode 100644 src/gui/tasks/modal.py create mode 100644 src/gui/tasks/signal.py create mode 100644 src/gui/tasks/table/main.py create mode 100644 src/gui/tasks/table/menu.py create mode 100644 src/gui/tasks/table/model.py create mode 100644 src/gui/window.py create mode 100644 src/main.py create mode 100644 src/model/task.py create mode 100644 src/util/array.py create mode 100644 src/util/range.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42b5d44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +database diff --git a/README.md b/README.md new file mode 100644 index 0000000..1040b41 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# todo-next + +Manage a context-based next-action list, compatible with the +[GTD](https://en.wikipedia.org/wiki/Getting_Things_Done) method. + +## Getting started + +```bash +nix-shell --run "python src/main.py" +``` + +## Links + +- [Python standard library](https://docs.python.org/3.8/library/index.html) +- [Qt documentation](https://doc.qt.io/qt-5/qtwidgets-module.html) diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..608e7c1 --- /dev/null +++ b/shell.nix @@ -0,0 +1,19 @@ +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 + ]; + +} diff --git a/src/db/init.py b/src/db/init.py new file mode 100644 index 0000000..9517714 --- /dev/null +++ b/src/db/init.py @@ -0,0 +1,18 @@ +import sqlite3 +import os.path +import time + +def init(path): + is_db_new = not os.path.isfile(path) + database = sqlite3.connect('database') + if is_db_new: + database.cursor().execute( + " CREATE TABLE IF NOT EXISTS tasks(" + " id INTEGER PRIMARY KEY," + " created_at INTEGER NOT NULL," + " modified_at INTEGER NOT NULL," + " name TEXT NOT NULL," + " tag TEXT" + " )") + database.commit() + return database diff --git a/src/db/tasks.py b/src/db/tasks.py new file mode 100644 index 0000000..26a430a --- /dev/null +++ b/src/db/tasks.py @@ -0,0 +1,50 @@ +from sqlite3 import Cursor +import time + +from model.task import Task, TaskForm + +def get(cursor: Cursor) -> Task: + cursor.execute('SELECT id, created_at, modified_at, name, tag FROM tasks') + + res = [] + + for task in cursor.fetchall(): + res.append(Task( + id = task[0], + created_at = task[1], + modified_at = task[2], + name = task[3], + tag = task[4] + )) + + return res + +def insert(cursor: Cursor, taskForm): + now = int(time.time()) + cursor.execute('INSERT INTO tasks(created_at, modified_at, name, tag) VALUES (?, ?, ?, ?)', (now, now, taskForm.name, taskForm.tag)) + return Task( + id = cursor.lastrowid, + created_at = now, + modified_at = now, + name = taskForm.name, + tag = taskForm.tag + ) + +def update(cursor: Cursor, task: Task, taskForm: TaskForm): + now = int(time.time()) + + cursor.execute( + 'UPDATE tasks SET modified_at = ?, name = ?, tag = ? WHERE id = ?', + (now, taskForm.name, taskForm.tag, task.id)) + + return Task( + id = task.id, + created_at = task.created_at, + modified_at = now, + name = taskForm.name, + tag = taskForm.tag + ) + +def delete(cursor: Cursor, ids): + if len(ids) >= 1: + cursor.execute('DELETE FROM tasks WHERE id IN (%s)' % ','.join('?'*len(ids)), ids) diff --git a/src/gui/icons.py b/src/gui/icons.py new file mode 100644 index 0000000..2f8830e --- /dev/null +++ b/src/gui/icons.py @@ -0,0 +1,16 @@ +from PyQt5 import QtWidgets + +def new_folder(style): + return style.standardIcon(QtWidgets.QStyle.SP_FileDialogNewFolder) + +def dialog_open(style): + return style.standardIcon(QtWidgets.QStyle.SP_DialogOpenButton) + +def dialog_apply(style): + return style.standardIcon(QtWidgets.QStyle.SP_DialogApplyButton) + +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/tasks/main.py b/src/gui/tasks/main.py new file mode 100644 index 0000000..3c7d3db --- /dev/null +++ b/src/gui/tasks/main.py @@ -0,0 +1,39 @@ +from PyQt5 import QtWidgets, QtCore + +import db.tasks +import gui.tasks.signal +import gui.tasks.table.main +import gui.icons +from model.task import TaskForm + +def widget(database, parent): + widget = QtWidgets.QWidget(parent) + + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + + add_task_signal = gui.tasks.signal.AddTask() + + add_task_button = QtWidgets.QPushButton('Add a task', widget) + add_task_button.setIcon(gui.icons.new_folder(widget.style())) + add_task_button.clicked.connect(lambda: show_add_dialog(database, widget, add_task_signal)) + layout.addWidget(add_task_button) + + table = gui.tasks.table.main.widget(database, widget, add_task_signal) + layout.addWidget(table) + + return widget + +def show_add_dialog(database, parent_widget, add_task_signal): + dialog = gui.tasks.modal.dialog( + parent_widget, + 'Add a task', + 'add', + None, + lambda taskForm: on_add(database, taskForm, add_task_signal)) + dialog.exec_() + +def on_add(database, taskForm: TaskForm, add_task_signal): + task = db.tasks.insert(database.cursor(), taskForm) + database.commit() + add_task_signal.emit(task) diff --git a/src/gui/tasks/modal.py b/src/gui/tasks/modal.py new file mode 100644 index 0000000..3ccf56e --- /dev/null +++ b/src/gui/tasks/modal.py @@ -0,0 +1,143 @@ +from PyQt5 import QtCore, QtWidgets + +import db.tasks +from model.task import Task, TaskForm + +import gui.icons + +def dialog( + parent: QtWidgets.QWidget, + title: str, + action_title: str, + task: Task, + on_validated): + + dialog = QtWidgets.QDialog(parent) + dialog.setWindowTitle(title) + dialog.setMinimumSize(QtCore.QSize(320, 240)) + + layout = QtWidgets.QVBoxLayout(dialog) + dialog.setLayout(layout) + + init_name = task.name if task is not None else '' + (name_labelled_input, name_input) = labelled_input(dialog, 'Name', init_name) + layout.addWidget(name_labelled_input) + + init_tag = task.tag if task is not None else '' + (tag_labelled_input, tag_input) = labelled_input(dialog, 'Tag', init_tag) + layout.addWidget(tag_labelled_input) + + task_form_edition = TaskFormEdition( + init_name, + name_input.textChanged, + init_tag, + tag_input.textChanged) + + layout.addWidget(buttons( + parent = dialog, + action_title = action_title, + task_form_signal = task_form_edition.signal(), + on_validate = lambda: validate(dialog, task_form_edition.get(), on_validated), + on_cancel = lambda: dialog.reject())) + + return dialog + +# Use grid ? +def labelled_input(parent, label: str, default_value: str): + widget = QtWidgets.QWidget(parent) + + layout = QtWidgets.QHBoxLayout(widget) + widget.setLayout(layout) + + label = QtWidgets.QLabel(label, widget) + layout.addWidget(label) + + line_edit = QtWidgets.QLineEdit(widget) + if default_value != None: + line_edit.setText(default_value) + layout.addWidget(line_edit) + + return (widget, line_edit) + +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.icons.dialog_apply(validate.style())) + validate.clicked.connect(on_validate); + layout.addWidget(validate) + + def on_task_form_signal(task_form): + if validate_form(task_form): + validate.setEnabled(True) + else: + validate.setDisabled(True) + + task_form_signal.connect(on_task_form_signal) + + cancel = QtWidgets.QPushButton('cancel', widget) + cancel.setIcon(gui.icons.dialog_cancel(cancel.style())) + cancel.clicked.connect(on_cancel) + layout.addWidget(cancel) + + return widget + +def validate(dialog, task_form: TaskForm, on_validated): + valid_form = validate_form(task_form) + if valid_form: + on_validated(valid_form) + dialog.accept() + +def clean_form(task_form: TaskForm): + return TaskForm( + name = task_form.name.strip(), + tag = task_form.tag.strip()) + +def validate_form(task_form: TaskForm): + task_form = clean_form(task_form) + if task_form.name: + return task_form + else: + return None + +##################### +# Task form edition # +##################### + +class TaskFormEdition: + def __init__(self, name, name_signal, tag, tag_signal): + self._name = name + self._tag = tag + self._signal = TaskFormSignal() + name_signal.connect(lambda name: self.on_name_signal(name)) + tag_signal.connect(lambda tag: self.on_tag_signal(tag)) + + def get(self): + return TaskForm( + name = self._name, + tag = self._tag) + + def on_name_signal(self, name: str): + self._name = name + self._signal.emit(self.get()) + + def on_tag_signal(self, tag: str): + self._tag = tag + self._signal.emit(self.get()) + + def signal(self): + return self._signal + +class TaskFormSignal(QtCore.QObject): + _signal = QtCore.pyqtSignal(TaskForm, name = 'taskForm') + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, taskForm): + self._signal.emit(taskForm) + + def connect(self, f): + self._signal.connect(f) diff --git a/src/gui/tasks/signal.py b/src/gui/tasks/signal.py new file mode 100644 index 0000000..7d926e1 --- /dev/null +++ b/src/gui/tasks/signal.py @@ -0,0 +1,27 @@ +from PyQt5 import QtCore + +from model.task import Task + +class AddTask(QtCore.QObject): + _signal = QtCore.pyqtSignal(Task, name = 'addTask') + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, task): + self._signal.emit(task) + + def get(self): + return self._signal + +class UpdateTask(QtCore.QObject): + _signal = QtCore.pyqtSignal(int, Task, name = 'updateTask') + + def __init__(self): + QtCore.QObject.__init__(self) + + def emit(self, row, task): + self._signal.emit(row, task) + + def get(self): + return self._signal diff --git a/src/gui/tasks/table/main.py b/src/gui/tasks/table/main.py new file mode 100644 index 0000000..a990c0e --- /dev/null +++ b/src/gui/tasks/table/main.py @@ -0,0 +1,46 @@ +from PyQt5 import QtWidgets +from PyQt5.QtCore import Qt + +import db.tasks +import gui.tasks.signal +import gui.tasks.table.menu +import gui.tasks.table.model + +def widget(database, parent, add_task_signal): + table = QtWidgets.QTableView(parent) + + tasks = db.tasks.get(database.cursor()) + table_model = gui.tasks.table.model.TableModel(tasks) + + table.setModel(table_model) + table.sortByColumn( + gui.tasks.table.model.default_sort[0], + gui.tasks.table.model.default_sort[1]) + table.setSortingEnabled(True) + table.setSelectionBehavior(QtWidgets.QTableView.SelectRows) + table.horizontalHeader().setStretchLastSection(True) + resizeColumns(table) + + update_task_signal = gui.tasks.signal.UpdateTask() + + # Menu + table.setContextMenuPolicy(Qt.CustomContextMenu) + table.customContextMenuRequested.connect(lambda position: gui.tasks.table.menu.open(database, table, update_task_signal, position)) + + add_task_signal.get().connect(lambda task: insert(table, task)) + update_task_signal.get().connect(lambda row, task: update(table, row, task)) + + return table + +def insert(table, task): + table.model().insert_task(table.horizontalHeader(), task) + resizeColumns(table) + +def update(table, row, task): + row = table.model().update_task(table.horizontalHeader(), row, task) + table.selectRow(row) + resizeColumns(table) + +def resizeColumns(table): + for column in range(gui.tasks.table.model.columns): + table.resizeColumnToContents(column) diff --git a/src/gui/tasks/table/menu.py b/src/gui/tasks/table/menu.py new file mode 100644 index 0000000..4366c25 --- /dev/null +++ b/src/gui/tasks/table/menu.py @@ -0,0 +1,43 @@ +from PyQt5 import QtWidgets + +import db.tasks +import gui.tasks.modal +from model.task import Task, TaskForm + +def open(database, table, 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.icons.dialog_open(menu.style()), 'modify') + else: + modify_action = QtWidgets.QAction(menu) + + delete_action = menu.addAction(gui.icons.trash(menu.style()), 'Delete') + + action = menu.exec_(table.mapToGlobal(position)) + if action == modify_action and len(rows) == 1: + row = list(rows)[0] + task = table.model().get_at(row) + show_update_dialog(database, table, update_task_signal, row, task) + elif action == delete_action: + confirm = QtWidgets.QMessageBox.question(table, '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) + +def show_update_dialog(database, parent_widget, update_task_signal, row, task): + dialog = gui.tasks.modal.dialog( + parent_widget, + 'Modify a task', + 'modify', + task, + lambda taskForm: on_update(database, update_task_signal, row, task, taskForm)) + dialog.exec_() + +def on_update(database, update_task_signal, row, task: Task, taskForm: TaskForm): + task = db.tasks.update(database.cursor(), task, taskForm) + update_task_signal.emit(row, task) + database.commit() diff --git a/src/gui/tasks/table/model.py b/src/gui/tasks/table/model.py new file mode 100644 index 0000000..90bcc4c --- /dev/null +++ b/src/gui/tasks/table/model.py @@ -0,0 +1,116 @@ +from PyQt5 import QtCore, QtWidgets +from PyQt5.QtCore import Qt + +from model.task import Task +import time +import math +import util.array +import util.range + +columns = 3 + +headers = ['Age', 'Name', '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): + if role == Qt.DisplayRole: + task = self._tasks[index.row()] + if index.column() == 0: + return age_since(task.created_at) + if index.column() == 1: + return task.name + if index.column() == 2: + return task.tag + 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() + return util.array.insert_position( + sort_key(task, row), + [sort_key(t, row) for t in self._tasks], + is_reversed(row, order)) + + 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() + self._tasks = sorted( + self._tasks, + key = lambda task: sort_key(task, row), + reverse = is_reversed(row, order)) + 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): + if row == 0: + return task.created_at + elif row == 1: + return task.name.lower() + elif row == 2: + return task.tag.lower() + +def is_reversed(row: int, order: Qt.SortOrder) -> bool: + if row == 0: + return order == Qt.AscendingOrder + else: + return order == Qt.DescendingOrder diff --git a/src/gui/window.py b/src/gui/window.py new file mode 100644 index 0000000..67d1dea --- /dev/null +++ b/src/gui/window.py @@ -0,0 +1,13 @@ +from PyQt5 import QtCore, QtWidgets + +import gui.tasks.main + +def get(database): + window = QtWidgets.QMainWindow() + window.setWindowTitle("todo-next") + window.setMinimumSize(QtCore.QSize(640, 480)) + + centralWidget = QtWidgets.QWidget(window) + window.setCentralWidget(gui.tasks.main.widget(database, centralWidget)) + + return window diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..293ec8d --- /dev/null +++ b/src/main.py @@ -0,0 +1,22 @@ +import sys +from PyQt5 import QtCore, QtWidgets +import sqlite3 +import os.path + +import db.init +import gui.window + +database = db.init.init('database') +app = QtWidgets.QApplication(sys.argv) + +# # Allows to catch Ctrl-C event +# timer = QtCore.QTimer() +# timer.timeout.connect(lambda: None) +# timer.start(100) + +window = gui.window.get(database) +window.show() +res = app.exec_() + +database.close() +sys.exit(res) diff --git a/src/model/task.py b/src/model/task.py new file mode 100644 index 0000000..26496c4 --- /dev/null +++ b/src/model/task.py @@ -0,0 +1,13 @@ +from typing import NamedTuple +from datetime import datetime + +class Task(NamedTuple): + id: int + created_at: int + modified_at: int + name: str + tag: str + +class TaskForm(NamedTuple): + name: str + tag: str diff --git a/src/util/array.py b/src/util/array.py new file mode 100644 index 0000000..7dd0357 --- /dev/null +++ b/src/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/src/util/range.py b/src/util/range.py new file mode 100644 index 0000000..c75232c --- /dev/null +++ b/src/util/range.py @@ -0,0 +1,30 @@ +from typing import NamedTuple + +class Range(NamedTuple): + start: int + length: int + +def from_indexes(indexes): + 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 -- cgit v1.2.3