commit 729949182544a5f1ad9ec15b6e570ef344abfd97 Author: Faerbit Date: Wed Feb 19 23:12:56 2020 +0100 Inital commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b58ada1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,196 @@ + +# Created by https://www.gitignore.io/api/python,pycharm+all +# Edit at https://www.gitignore.io/?templates=python,pycharm+all + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# End of https://www.gitignore.io/api/python,pycharm+all diff --git a/data.py b/data.py new file mode 100644 index 0000000..4fad075 --- /dev/null +++ b/data.py @@ -0,0 +1,103 @@ +import os +import json +import atexit +from threading import Thread, Event +from collections.abc import MutableMapping + +from PySide2 import QtCore + +data_dir_path = os.path.join(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.AppDataLocation), + "fimefracking") + +tasks_path = os.path.join(data_dir_path, "tasks.json") + +data_path = os.path.join(data_dir_path, "data_{}.json") +save_delay = 3 * 60 + + +class Tasks: + def __init__(self): + if not os.path.exists(data_dir_path): + os.mkdir(data_dir_path) + if os.path.exists(tasks_path): + with open(tasks_path, "r") as f: + self._tasks = json.loads(f.read()) + else: + self._tasks = [] + + @property + def tasks(self): + return self._tasks + + @tasks.setter + def tasks(self, tasks): + self._tasks = tasks + self._save() + + def _save(self): + print("...saving tasks...") + with open(tasks_path, "w+") as f: + f.write(json.dumps(self._tasks)) + + +class Data(MutableMapping): + def __init__(self): + if not os.path.exists(data_dir_path): + os.mkdir(data_dir_path) + self._cache = {} + self._hot_keys = [] + self._running = False + self._tevent = Event() + self._thread = None + + def cleanup(): + self._running = False + self._tevent.set() + if self._thread: + self._thread.join() + atexit.register(cleanup) + + def __getitem__(self, key): + dpath = data_path.format(key) + if key not in self._cache and os.path.exists(dpath): + with open(dpath, "r") as f: + self._cache[key] = f.read() + return self._cache[key] + + def __setitem__(self, key, value): + self._cache[key] = value + self._hot_keys.append(key) + self._schedule_save() + + def _schedule_save(self): + if self._running: + return + self._running = True + self._thread = Thread(target=self._executor, daemon=True) + self._thread.start() + + def _executor(self): + while self._running: + self._tevent.wait(save_delay) + self._save() + + def _save(self): + for key in self._hot_keys: + print(f"... saving dict {key} ...") + to_write = self._cache[key] # apparently thread-safe + with open(data_path.format(key), "w+") as f: + f.write(json.dumps(to_write)) + self._hot_keys = [] + self._saving = False + + def __delitem__(self, key): + print("WARNING: deletion of items not supported") + + def __iter__(self): + return iter(self._cache) + + def __len__(self): + return len(self._cache) + + def __repr__(self): + return f"{type(self).__name__}({self._cache})" diff --git a/main.py b/main.py new file mode 100644 index 0000000..3147c14 --- /dev/null +++ b/main.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import sys +import signal +from functools import partial + +from PySide2 import QtCore, QtGui, QtWidgets + +from data import Tasks, Data +from task_edit import TaskEdit + + +class App: + def __init__(self): + self.app = QtWidgets.QApplication(sys.argv) + + self.tasks = Tasks() + self.active_task = "Nothing" + self.data = Data() + + icon = QtGui.QIcon.fromTheme("appointment-new") + + self.menu = QtWidgets.QMenu() + + self.tray = QtWidgets.QSystemTrayIcon() + self.tray.setIcon(icon) + self.tray.setContextMenu(self.menu) + self.tray.show() + self.tray.setToolTip("fimefracking") + self.update_tray_menu() + + self.taskEdit = TaskEdit(None) + self.taskEdit.accepted.connect(self.tasks_edited) + + @QtCore.Slot() + def tasks_edited(self): + self.tasks.tasks = self.taskEdit.tasks + self.update_tray_menu() + + def change_task(self, task): + self.tasks.active = task + self.update_tray_menu() + + def update_tray_menu(self): + self.menu.clear() + tasks = list(self.tasks.tasks) + tasks.append("Nothing") + + for t in tasks: + a = self.menu.addAction(t) + a.triggered.connect(partial(self.change_task, t)) + if t == self.active_task: + a.setIcon(QtGui.QIcon.fromTheme("go-next")) + + self.menu.addSeparator() + + new_action = self.menu.addAction("Edit tasks") + new_action.triggered.connect(self.edit_tasks) + + report_action = self.menu.addAction("Report") + report_action.triggered.connect(self.report) + + self.menu.addSeparator() + + exit_action = self.menu.addAction("Close") + exit_action.triggered.connect(self.app.quit) + + def sigterm_handler(self, _signo, _frame): + self.app.quit() + + def run(self): + timer = QtCore.QTimer() + # interrupt event loop regularly for signal handling + timer.timeout.connect(lambda: None) + timer.start(500) + signal.signal(signal.SIGTERM, self.sigterm_handler) + signal.signal(signal.SIGINT, self.sigterm_handler) + self.app.exec_() + + @QtCore.Slot() + def report(self): + pass + + @QtCore.Slot() + def edit_tasks(self): + self.taskEdit.tasks = self.tasks.tasks + self.taskEdit.show() + + +if __name__ == "__main__": + app = App() + app.run() diff --git a/task_edit.py b/task_edit.py new file mode 100644 index 0000000..6927b28 --- /dev/null +++ b/task_edit.py @@ -0,0 +1,63 @@ +from PySide2 import QtCore, QtGui, QtWidgets + + +class TaskEdit(QtWidgets.QDialog): + def __init__(self, parent, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.setWindowTitle("Edit Tasks") + self.list = QtCore.QStringListModel() + + self.tableView = QtWidgets.QTableView() + self.tableView.setModel(self.list) + self.tableView.horizontalHeader().hide() + self.tableView.verticalHeader().hide() + + new_button = QtWidgets.QPushButton() + new_button.setText("New item") + new_button.setIcon(QtGui.QIcon.fromTheme("list-add")) + new_button.pressed.connect(self.new_task) + + del_button = QtWidgets.QPushButton() + del_button.setText("Delete item") + del_button.setIcon(QtGui.QIcon.fromTheme("list-remove")) + del_button.pressed.connect(self.del_task) + + ok_button = QtWidgets.QPushButton() + ok_button.setText("OK") + ok_button.setIcon(QtGui.QIcon.fromTheme("dialog-ok-apply")) + ok_button.pressed.connect(self.accept) + + blayout = QtWidgets.QHBoxLayout() + blayout.addWidget(new_button) + blayout.addWidget(del_button) + blayout.addWidget(ok_button) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.tableView) + layout.addLayout(blayout) + self.setLayout(layout) + + @QtCore.Slot() + def new_task(self): + l = self.list.stringList() + l.append("") + self.list.setStringList(l) + i = self.list.index(len(l)-1) + self.tableView.setCurrentIndex(i) + self.tableView.edit(i) + + @QtCore.Slot() + def del_task(self): + l = self.list.stringList() + del l[self.tableView.currentIndex().row()] + self.list.setStringList(l) + + @property + def tasks(self): + ret = self.list.stringList() + return list(filter(None, ret)) # filter empty strings + + @tasks.setter + def tasks(self, tasks): + self.list.setStringList(tasks) +