diff --git a/.gitignore b/.gitignore index 083d732..bee8a64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ __pycache__ -.idea diff --git a/main.py b/main.py old mode 100755 new mode 100644 index d5360fc..8e72b80 --- a/main.py +++ b/main.py @@ -1,241 +1,157 @@ #!/usr/bin/env python3 -import logging -import os import sys -import time -import webbrowser +import os +import logging +import threading -from PySide2.QtCore import QObject, QThread, Signal, Slot - -from PySide2.QtGui import QPalette, QColor - -from PySide2.QtWidgets import ( - QApplication, - QDialog, - QFrame, - QGroupBox, - QHBoxLayout, - QLabel, - QLineEdit, - QProgressBar, - QPushButton, - QScrollArea, - QScroller, - QTabBar, - QVBoxLayout, -) - -import doc_manager +import task_manager +APP_TITLE = "Org-mob" DOCS_PATH = os.environ["ORG_PATH"] -MAX_SEARCH_NODES = 100 +STYLE_FILE_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "style.css") +MIN_TITLE_WIDTH_CHARS = 10 -class LoadDoneSignal(QObject): - sig = Signal(doc_manager.DocumentManager) +import gi +gi.require_version("Gtk", "4.0") +gi.require_version('Polkit', '1.0') +gi.require_version(namespace='Adw', version='1') -class DocumentLoader(QThread): - def __init__(self, manager): - QThread.__init__(self, None) - self.manager = manager - self.signal = LoadDoneSignal() +from gi.repository import Gtk, Polkit, GObject, Gio, Adw, Gdk - def run(self): - self.manager.load() - self.signal.sig.emit(self.manager) +class MainWindow(Gtk.Window): + ## Setup + def __init__(self, *, title, application, task_manager, with_titlebar=True): + super().__init__(title=title, application=application) + self.application = application + self.task_manager = task_manager + self.loading = 0 -class Dialog(QDialog): - def __init__(self): - super(Dialog, self).__init__() + if with_titlebar: + self.header_bar = Gtk.HeaderBar() + # self.header_bar.set_show_close_button(True) + # self.header_bar.props.title = APP_TITLE + self.set_titlebar(self.header_bar) - palette = self.palette() - palette.setColor(QPalette.Window, QColor(255, 255, 255)) - self.setPalette(palette) - self.setAutoFillBackground(True) - - self.setWindowTitle("OrgEditor") - scrSize = self.screen().size() - self.resize(scrSize.width() / 1.5, scrSize.height() / 1.5) - self.loader = None - self.manager = doc_manager.DocumentManager(DOCS_PATH) - - layout = QVBoxLayout() - - # Edit box - self.progressBar = QProgressBar() - self.progressBar.setRange(0, 0) # Make undetermined - layout.addWidget(self.progressBar) - - self.edit = QLineEdit("", placeholderText="Search for notes") - self.edit.textEdited.connect(self.on_text_edited) - layout.addWidget(self.edit) - - layout.setSpacing(0) - - self.results = QScrollArea(widgetResizable=True) - layout.addWidget(self.results) - QScroller.grabGesture( - self.results.viewport(), - QScroller.LeftMouseButtonGesture, - ) - - # Options - self.tabBar = QTabBar(shape=QTabBar.RoundedSouth) - - self.tabBar.addTab("Agenda") - self.tabBar.addTab("Notes") - self.tabBar.addTab("Tasks") - self.tabBar.addTab("History") - - self.tabBar.currentChanged.connect(self.update_tab) - - layout.addWidget(self.tabBar) - - self.setLayout(layout) - self.startLoad() - - @Slot() - def on_text_edited(self): - if self.tabBar.currentIndex() != 1: - self.tabBar.setCurrentIndex(1) + self.progress_spinner = Gtk.Spinner() + self.progress_spinner.start() + self.header_bar.pack_end(self.progress_spinner) else: - self.loadNotes() + self.header_bar = None + self.progress_spinner = None - @Slot() - def update_tab(self): - tabIndex = self.tabBar.currentIndex() - if tabIndex == 0: - self.loadAgenda() - elif tabIndex == 1: - self.loadNotes() - elif tabIndex == 2: - self.loadTasks() - elif tabIndex == 3: - self.loadHistory() + # self.main_box = Gtk.Box(name='main-box', vexpand=True, hexpand=True) + self.scrollview = Gtk.ScrolledWindow(vexpand=True, hexpand=True) - def startLoad(self): - self.edit.setDisabled(True) - self.edit.setVisible(False) - self.tabBar.setDisabled(True) - self.progressBar.setVisible(True) + self.task_list = Gtk.ListBox(name='task-list') + self.scrollview.set_child(self.task_list) - self.loader = DocumentLoader(self.manager) - self.loader.signal.sig.connect(self.longoperationcomplete) - self.loading_start_time = time.time() - self.loader.start() + # self.main_box.props.valign = Gtk.Align.CENTER + # self.main_box.props.halign = Gtk.Align.CENTER + # self.main_box.append(self.scrollview) + # self.set_child(self.main_box) + self.set_child(self.scrollview) - def endLoad(self): - self.edit.setDisabled(False) - self.edit.setVisible(True) - self.tabBar.setDisabled(False) - self.progressBar.setVisible(False) + self.loading += 1 + self.task_manager.get_task_list(self.on_task_list_ready) - self.update_tab() + ## Rendering + def build_agenda_task_row(self, task): + row = Gtk.ListBoxRow() + hbox = Gtk.Box() - def longoperationcomplete(self, data): - logging.info( - "Loading complete in {:.3f}s".format(time.time() - self.loading_start_time) - ) - self.endLoad() + state_button = Gtk.Button.new_with_label(task.state or '') + state_button.props.css_classes = ('state-button',) + state_button.connect("clicked", self.on_status_button_clicked) + hbox.append(state_button) - def loadAgenda(self): - agenda = self.manager.get_agenda() - old = self.results.layout() + clock_button = Gtk.Button.new_with_label('C') + clock_button.props.css_classes = ('clock-button',) + clock_button.connect("clicked", self.on_clock_button_clicked) + hbox.append(clock_button) - if old: - print("Deleting old") - old.deleteLater() + # task_name_label = Gtk.Entry(text=task.title, width_chars=max(MIN_TITLE_WIDTH_CHARS, len(task.title))) + task_name_label = Gtk.Label() + task_name_label.set_text(task.title) + task_name_label.props.css_classes = ('task-name',) + hbox.append(task_name_label) - layout = QVBoxLayout() + row.set_child(hbox) + + return row + + def on_ready(self): + self.loading -= 1 + if self.loading < 0: + self.loading = 0 + elif self.loading == 0: + if self.progress_spinner is not None: + self.progress_spinner.stop() + + ## Callbacks + def on_task_list_ready(self, agenda): + # TODO: Avoid reconstructing the whole list every time + i = 0 + child = self.task_list.get_first_child() + while child is not None: + was = child + child = child.get_next_sibling() + i += 1 + self.task_list.remove(was) for item in agenda.with_hour: - layout.addWidget(self.build_agenda_task_widget(item)) - - # if len(agenda.with_hour) > 0 and len(agenda.no_hour) > 0: - # layout.addWidget(QSplitter()) + self.task_list.append(self.build_agenda_task_row(item)) for item in agenda.no_hour: - layout.addWidget(self.build_agenda_task_widget(item)) + self.task_list.append(self.build_agenda_task_row(item)) + self.on_ready() - layout.addStretch() + ## Reactions + def on_status_button_clicked(self, button): + print('Status button clicked: {}'.format(button)) - frame = QFrame(self.results) - frame.setLayout(layout) - self.results.setWidget(frame) - - def build_agenda_task_widget(self, item): - box = QHBoxLayout() - frame = QFrame() - frame.setLayout(box) - - state_button = QPushButton(text=f"{item.state or '-'}", maximumWidth=60) - if item.is_done: - state_button.setFlat(True) - box.addWidget(state_button) - - box.addWidget(QLabel(text=f"{item.scheduled.time}", maximumWidth=200)) - box.addWidget(QLabel(text=f"{item.title}")) - - def on_clicked(): - state_button.setText("DONE") - # state_button.setFlat(True) - # item.state = 'DONE' - - if not item.is_done: - state_button.clicked.connect(on_clicked) - - return frame - - def build_note_task_widget(self, item): - box = QHBoxLayout() - frame = QGroupBox() - frame.setLayout(box) - - titleButton = QPushButton(text=f"{item.title}") - box.addWidget(titleButton) - - def on_clicked(): - webbrowser.open("org-protocol://org-id?id=" + item.id) - - titleButton.clicked.connect(on_clicked) - - return frame - - def loadNotes(self): - query = self.edit.text() - notes = self.manager.get_notes(query.split()) - old = self.results.layout() - - if old: - print("Deleting old") - old.deleteLater() - - layout = QVBoxLayout() - - for note in notes[:MAX_SEARCH_NODES]: - layout.addWidget(self.build_note_task_widget(note)) - - layout.addStretch() - - frame = QFrame(self.results) - frame.setLayout(layout) - self.results.setWidget(frame) - - def loadTasks(self): - logging.warning("loadTasks not yet implemented") - - def loadHistory(self): - logging.warning("loadHistory not yet implemented") + def on_clock_button_clicked(self, button): + print('Clock button clicked: {}'.format(button)) -# Create the Qt Application -if __name__ == "__main__": +class Application(Gtk.Application): + """ Main Aplication class """ + + def __init__(self): + super().__init__(application_id='com.codigoparallevar.gtk4-organizer', + flags=Gio.ApplicationFlags.FLAGS_NONE) + self.task_manager = task_manager.TaskManager(DOCS_PATH) + + def do_activate(self): + win = self.props.active_window + if not win: + if os.path.exists(STYLE_FILE_PATH): + style_provider = Gtk.CssProvider() + Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), style_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + style_provider.load_from_path(STYLE_FILE_PATH) + + win = MainWindow( + title=APP_TITLE, + application=self, + task_manager=self.task_manager, + ) + win.set_default_size(720, 1024) # PinePhone screen is 720x1440 + + win.present() + + +def main(): + """ Run the main application""" + # GObject.threads_init() logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s") + app = Application() + return app.run(sys.argv) - app = QApplication(sys.argv) - dialog = Dialog() - sys.exit(dialog.exec_()) +if __name__ == '__main__': + main() diff --git a/org-mode.svg b/org-mode.svg deleted file mode 100644 index 917cf3a..0000000 --- a/org-mode.svg +++ /dev/null @@ -1,149 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/style.css b/style.css new file mode 100644 index 0000000..e54b961 --- /dev/null +++ b/style.css @@ -0,0 +1,20 @@ +#task-list { + border: 1px solid #cdc7c2; + padding: 1ex; + margin: 1ex; +} + +#task-list .state-button, +#task-list .clock-button { + margin-right: 1ex; +} + +window { + background-color: #d6d5d4; +} + +#task-list .task-name { + border: none; + border-bottom: 1px solid #ccc; + border-radius: 0; +} \ No newline at end of file diff --git a/doc_manager.py b/task_manager.py similarity index 70% rename from doc_manager.py rename to task_manager.py index 1777b9c..4671408 100644 --- a/doc_manager.py +++ b/task_manager.py @@ -1,6 +1,7 @@ import logging import os import sys +import threading from datetime import datetime from typing import List @@ -9,6 +10,8 @@ from org_rw import OrgDoc, OrgTime EXTENSIONS = (".org", ".org.txt") +from gi.repository import GObject + def is_today(ot: OrgTime): now = datetime.now() @@ -39,17 +42,22 @@ class Agenda: for item in self.no_hour: print(item.scheduled.time, item.state, item.title) +class TaskManager: + docs: List[OrgDoc] + threads: List[threading.Thread] -class DocumentManager: - docs: list[OrgDoc] - - def __init__(self, base_path: os.PathLike): - self.base_path = base_path + def __init__(self, docs_path: os.PathLike): + self.docs_path = docs_path + self.threads = [] + self.docs = None def load(self): - top = os.path.abspath(self.base_path) + t0 = datetime.now() + top = os.path.abspath(self.docs_path) docs = [] + if self.docs is None: + self.docs = docs for root, dirs, files in os.walk(top): # Prune dirs @@ -68,8 +76,9 @@ class DocumentManager: path = os.path.join(root, name) try: - doc = org_rw.load(open(path), extra_cautious=True) + doc = org_rw.load(open(path), extra_cautious=False) docs.append(doc) + yield doc except Exception as err: import traceback @@ -77,10 +86,32 @@ class DocumentManager: print(f"== On {path}") sys.exit(1) - logging.info("Loaded {} files".format(len(docs))) + t1 = datetime.now() + logging.info("Loaded {} files in {}s".format(len(docs), t1 - t0)) self.docs = docs + def get_task_list(self, callback): + def aux(): + if self.docs is None: + last_result = None + for doc in self.load(): + result = self.get_agenda() + if ((last_result is None) + or (len(result.with_hour) != len(last_result.with_hour)) + or (len(result.no_hour) != len(last_result.no_hour))): + print("Loaded:", doc._path) + GObject.idle_add(callback, result) + print("Load completed") + else: + result = self.get_agenda() + print("Result", result) + GObject.idle_add(callback, result) + + thread = threading.Thread(target=aux) + thread.start() + self.threads.append(thread) + def get_agenda(self) -> Agenda: headline_count = 0 items_in_agenda = [] @@ -122,25 +153,3 @@ class DocumentManager: with_hour=sorted(items_with_hour, key=lambda x: x.scheduled.time), no_hour=other_items, ) - - def get_notes(self, query) -> List[org_rw.Headline]: - headline_count = 0 - t0 = datetime.now() - notes = [] - query = [q.lower() for q in query] - - for doc in self.docs: - for hl in doc.getAllHeadlines(): - headline_count += 1 - - data = "\n".join(hl.get_contents("raw")).lower() - if all([q in data for q in query]): - notes.append(hl) - - logging.info( - "Filtered {} to {} items in {:.3f}s".format( - headline_count, len(notes), (datetime.now() - t0).total_seconds() - ) - ) - - return notes