diff options
| -rw-r--r-- | mbgui/executor.py | 49 | ||||
| -rw-r--r-- | mbgui/main.py | 395 | ||||
| -rw-r--r-- | mbgui/main.ui | 56 | ||||
| -rw-r--r-- | mbgui/mblaze.py | 21 | ||||
| -rw-r--r-- | mbgui/tkapp.py | 59 | ||||
| -rw-r--r-- | setup.py | 3 |
6 files changed, 302 insertions, 281 deletions
diff --git a/mbgui/executor.py b/mbgui/executor.py new file mode 100644 index 0000000..9f3b420 --- /dev/null +++ b/mbgui/executor.py @@ -0,0 +1,49 @@ +import collections +import concurrent.futures +import threading +import typing + +import PySide6.QtCore + + +class Executor(PySide6.QtCore.QObject): + + def __init__(self): + super().__init__() + self._executor = concurrent.futures.ThreadPoolExecutor() + self._call_main_queue = collections.deque() + self._call_main_lock = threading.Lock() + + def call_main(self, + fn: typing.Callable, + *args): + with self._call_main_lock: + self._call_main_queue.append((fn, args)) + + PySide6.QtCore.QMetaObject.invokeMethod( + self, "_on_main_call", PySide6.QtCore.Qt.QueuedConnection) + + def call_worker(self, + fn: typing.Callable, + *args, + done_cb: typing.Callable = None, + ) -> concurrent.futures.Future: + future = self._executor.submit(fn, *args) + + if done_cb: + future.add_done_callback( + lambda f: self.call_main(done_cb, f.result())) + + return future + + @PySide6.QtCore.Slot() + def _on_main_call(self): + with self._call_main_lock: + if not self._call_main_queue: + return + + queue = self._call_main_queue + self._call_main_queue = collections.deque() + + for fn, args in queue: + fn(*args) diff --git a/mbgui/main.py b/mbgui/main.py index a944ade..f6263c2 100644 --- a/mbgui/main.py +++ b/mbgui/main.py @@ -1,252 +1,227 @@ from pathlib import Path -from tkinter import ttk import sys -import tkinter as tk -import tkinter.font + +import PySide6.QtCore +import PySide6.QtGui +import PySide6.QtUiTools +import PySide6.QtWidgets from mbgui import mblaze -from mbgui.tkapp import TkApp +from mbgui.executor import Executor -conf = {'fonts': {'regular': {'family': 'Inter', - 'size': 12, - 'weight': 'normal'}, - 'monospace': {'family': 'Roboto Mono', - 'size': 12, - 'weight': 'normal'}}} +icons_path = Path(__file__).parent / 'icons' +main_ui_path = Path(__file__).parent / 'main.ui' def main(): - app = App(conf) - app.run(sys.argv[1:]) - - -class App: - - def __init__(self, conf): - self._directories = set() - self._selected_directory = None - self._selected_message = None - - self._app = TkApp() - - self._fonts = {name: tk.font.Font(name=f'{name}Font', **values) - for name, values in conf['fonts'].items()} - - self._icons = { - path.stem: tk.PhotoImage(file=str(path)) - for path in (Path(__file__).parent / 'icons').glob('*.png')} - - self._app.root.columnconfigure(0, weight=1) - self._app.root.rowconfigure(0, weight=1) - - self._app.style.configure('Treeview', font=self._fonts['regular']) - - frame = ttk.Frame(self._app.root) - frame.columnconfigure(0, weight=1) - frame.rowconfigure(0, weight=1) - frame.grid(row=0, column=0, sticky='nswe') - - horizontal_pane = ttk.Panedwindow(frame, orient="horizontal") - horizontal_pane.grid(row=0, column=0, sticky='nswe') - - directories_frame = ttk.Frame(horizontal_pane) - directories_frame.columnconfigure(0, weight=1) - directories_frame.rowconfigure(0, weight=1) - horizontal_pane.add(directories_frame, weight=1) - - self._directories_tree = ttk.Treeview(directories_frame, - selectmode='browse', - columns=('unseen', 'total')) - self._directories_tree.column('unseen', width=32, anchor='e') - self._directories_tree.column('total', width=32, anchor='e') - self._directories_tree.bind('<<TreeviewSelect>>', - self._on_directories_tree_select) - self._directories_tree.grid(row=0, column=0, sticky='nswe') - - directories_scroll = ttk.Scrollbar( - directories_frame, orient='vertical', - command=self._directories_tree.yview) - self._directories_tree.configure(yscrollcommand=directories_scroll.set) - directories_scroll.grid(row=0, column=1, sticky='nswe') - - vertical_pane = ttk.Panedwindow(horizontal_pane, orient="vertical") - horizontal_pane.add(vertical_pane, weight=2) - - messages_frame = ttk.Frame(vertical_pane) - messages_frame.columnconfigure(0, weight=1) - messages_frame.rowconfigure(0, weight=1) - vertical_pane.add(messages_frame, weight=1) - - self._messages_tree = ttk.Treeview(messages_frame, - selectmode='browse', - columns=('sender', 'date')) - self._messages_tree.column('sender', width=32, anchor='w') - self._messages_tree.column('date', width=32, anchor='w') - self._messages_tree.bind('<<TreeviewSelect>>', - self._on_messages_tree_select) - self._messages_tree.bind('<Double-Button-1>', - self._on_messages_tree_double) - self._messages_tree.grid(row=0, column=0, sticky='nswe') - - messages_scroll = ttk.Scrollbar( - messages_frame, orient='vertical', - command=self._messages_tree.yview) - self._messages_tree.configure(yscrollcommand=messages_scroll.set) - messages_scroll.grid(row=0, column=1, sticky='nswe') - - message_frame = ttk.Frame(vertical_pane) - message_frame.columnconfigure(0, weight=1) - message_frame.rowconfigure(0, weight=1) - vertical_pane.add(message_frame, weight=1) - - self._message_text = tk.Text(message_frame, - font=self._fonts['monospace']) - self._message_text.grid(row=0, column=0, sticky='nswe') - - message_scroll_vertical = ttk.Scrollbar( - message_frame, orient='vertical', - command=self._message_text.yview) - self._message_text.configure( - yscrollcommand=message_scroll_vertical.set) - message_scroll_vertical.grid(row=0, column=1, sticky='nswe') - - def run(self, args): - self._get_directories(args) - self._app.run() - def _get_directories(self, paths): - def on_done(f): - self._app.call_main(self._on_directories, f.result()) + def on_directories_changed(current, previous): + item = directories.model.itemFromIndex(current) + directory = item.data() if item else None + messages.set_directory(directory) - f = self._app.call_worker(mblaze.get_directories, paths) - f.add_done_callback(on_done) + def on_messages_changed(current, pevious): + item = messages.model.itemFromIndex(current) + path = item.data() if item else None + message.set_path(path) - def _get_directory_unseen(self, path): - def on_done(f): - self._app.call_main(self._on_directory_unseen, path, f.result()) + def on_messages_double_clicked(index): + item = messages.model.itemFromIndex(index) + path = item.data() if item else None + if path: + print(path) - f = self._app.call_worker(mblaze.get_directory_unseen, path) - f.add_done_callback(on_done) + app = PySide6.QtWidgets.QApplication(sys.argv) - def _get_directory_total(self, path): - def on_done(f): - self._app.call_main(self._on_directory_total, path, f.result()) + executor = Executor() + icons = {i.stem: PySide6.QtGui.QIcon(str(i)) + for i in icons_path.glob('*.png')} - f = self._app.call_worker(mblaze.get_directory_total, path) - f.add_done_callback(on_done) + directories = Directories(executor, icons, sys.argv[1:]) + messages = Messages(executor, icons) + message = Message(executor) - def _get_messages(self, path): - def on_done(f): - self._app.call_main(self._on_messages, path, f.result()) + loader = PySide6.QtUiTools.QUiLoader() + window = loader.load(main_ui_path) - f = self._app.call_worker(mblaze.get_messages, path) - f.add_done_callback(on_done) + font = PySide6.QtGui.QFontDatabase.systemFont( + PySide6.QtGui.QFontDatabase.FixedFont) + window.message.setFont(font) - def _get_message(self, path): - def on_done(f): - self._app.call_main(self._on_message, path, f.result()) - - f = self._app.call_worker(mblaze.get_message, path) - f.add_done_callback(on_done) - - def _on_directories(self, directories): - self._directories = set() - directory_ids = self._directories_tree.get_children() - if directory_ids: - self._directories_tree.delete(*directory_ids) - - def add_directory(parent_id, directory): - directory_id = (str(Path(parent_id) / directory.name) if parent_id - else directory.name) - if directory.is_leaf: - self._directories.add(directory_id) - icon = self._icons['inbox-16' if directory.is_leaf + window.vsplitter.setStretchFactor(0, 1) + window.vsplitter.setStretchFactor(1, 2) + + window.directories.setModel(directories.model) + model = window.directories.selectionModel() + model.currentChanged.connect(on_directories_changed) + + header = window.directories.header() + header.setSectionResizeMode(0, PySide6.QtWidgets.QHeaderView.Stretch) + header.setSectionResizeMode(1, PySide6.QtWidgets.QHeaderView.Interactive) + header.setSectionResizeMode(2, PySide6.QtWidgets.QHeaderView.Interactive) + + window.messages.setModel(messages.model) + model = window.messages.selectionModel() + model.currentChanged.connect(on_messages_changed) + window.messages.doubleClicked.connect(on_messages_double_clicked) + + header = window.messages.header() + header.setSectionResizeMode(0, PySide6.QtWidgets.QHeaderView.Stretch) + header.setSectionResizeMode(1, PySide6.QtWidgets.QHeaderView.Interactive) + header.setSectionResizeMode(2, PySide6.QtWidgets.QHeaderView.Interactive) + + message.change.connect(window.message.setText) + + window.show() + + sys.exit(app.exec()) + + +class Directories: + + def __init__(self, executor, icons, paths): + self._executor = executor + self._icons = icons + self._model = PySide6.QtGui.QStandardItemModel() + self._model.setHorizontalHeaderLabels(['Directory', 'Unseed', 'Total']) + self._get_directories(paths) + + @property + def model(self) -> PySide6.QtCore.QAbstractItemModel: + return self._model + + def _get_directories(self, paths): + + def on_done(directories): + for directory in directories: + self._model.appendRow(create_row(directory)) + + def create_row(directory): + icon = self._icons['inbox-16' if directory.path else 'folder-16'] - self._directories_tree.insert(parent_id, 'end', - id=directory_id, - text=f' {directory.name}', - image=icon, - values=('', ''), - open=True) - if directory.is_leaf: - self._get_directory_unseen(directory_id) - self._get_directory_total(directory_id) + + items = [PySide6.QtGui.QStandardItem(icon, directory.name), + PySide6.QtGui.QStandardItem(''), + PySide6.QtGui.QStandardItem('')] + + for item in items: + item.setData(directory.path) + + if directory.path: + self._get_directory_unseen(items[1], directory.path) + self._get_directory_total(items[2], directory.path) + for child in directory.children: - add_directory(directory_id, child) - - for directory in directories: - add_directory('', directory) - - def _on_directory_unseen(self, directory, count): - values = self._directories_tree.item(directory, 'values') - values = str(count), values[1] - self._directories_tree.item(directory, values=values) - - def _on_directory_total(self, directory, count): - values = self._directories_tree.item(directory, 'values') - values = values[0], str(count) - self._directories_tree.item(directory, values=values) - - def _on_directories_tree_select(self, evt): - selection = self._directories_tree.selection() - directory = selection[0] if selection else None - directory = directory if directory in self._directories else None - if directory == self._selected_directory: - return + items[0].appendRow(create_row(child)) - self._selected_directory = directory + return items - message_ids = self._messages_tree.get_children() - if message_ids: - self._messages_tree.delete(*message_ids) + self._executor.call_worker(mblaze.get_directories, paths, + done_cb=on_done) - if not directory: - return + def _get_directory_unseen(self, item, path): - self._get_messages(directory) + def on_done(count): + item.setText(str(count)) - def _on_messages(self, directory, messages): - if directory != self._selected_directory: + self._executor.call_worker(mblaze.get_directory_unseen, path, + done_cb=on_done) + + def _get_directory_total(self, item, path): + + def on_done(count): + item.setText(str(count)) + + self._executor.call_worker(mblaze.get_directory_total, path, + done_cb=on_done) + + +class Messages: + + def __init__(self, executor: Executor, icons): + self._executor = executor + self._icons = icons + self._model = PySide6.QtGui.QStandardItemModel() + self._model.setHorizontalHeaderLabels(['Subject', 'Sender', 'Date']) + self._directory = None + + @property + def model(self) -> PySide6.QtCore.QAbstractItemModel: + return self._model + + def set_directory(self, directory: str): + if self._directory == directory: return - def add_message(parent_id, message): + self._directory = directory + self._model.setRowCount(0) + + if directory: + self._get_messages(directory) + + def _get_messages(self, directory): + + def on_done(messages): + if self._directory != directory: + return + + for message in messages: + self._model.appendRow(create_row(message)) + + def create_row(message): icon = _status_icon(message.status) - icon = self._icons[f'{icon}-16'] if icon else '' - self._messages_tree.insert(parent_id, 'end', - id=message.path, - text=f' {message.subject}', - image=icon, - values=(message.sender, message.date)) + icon = self._icons[f'{icon}-16'] if icon else None + + items = [PySide6.QtGui.QStandardItem(icon, message.subject), + PySide6.QtGui.QStandardItem(message.sender), + PySide6.QtGui.QStandardItem(message.date)] + + for item in items: + item.setData(message.path) + for child in message.children: - add_message(message.path, child) + items[0].appendRow(create_row(child)) + + return items - for message in messages: - add_message('', message) + self._executor.call_worker(mblaze.get_messages, directory, + done_cb=on_done) - def _on_messages_tree_select(self, evt): - selection = self._messages_tree.selection() - message = selection[0] if selection else None - if message == self._selected_message: + def _on_messages(self, directory, messages): + if directory != self._selected_directory: return - self._selected_message = message - self._message_text.delete('1.0', 'end') - if not message: - return +class Message(PySide6.QtCore.QObject): - self._get_message(message) + change = PySide6.QtCore.Signal(str) - def _on_messages_tree_double(self, evt): - if self._selected_message: - print(self._selected_message) + def __init__(self, executor): + super().__init__() + self._executor = executor + self._path = None - def _on_message(self, message, text): - if message != self._selected_message: + def set_path(self, path): + if self._path == path: return - self._message_text.insert('1.0', text) + self._path = path + self.change.emit('') + + if path: + self._get_message(path) + + def _get_message(self, path): + + def on_done(text): + if self._path != path: + return + + self.change.emit(text) + + self._executor.call_worker(mblaze.get_message, path, + done_cb=on_done) def _status_icon(status): diff --git a/mbgui/main.ui b/mbgui/main.ui new file mode 100644 index 0000000..a675f4c --- /dev/null +++ b/mbgui/main.ui @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>windows</class> + <widget class="QMainWindow" name="windows"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>800</width> + <height>600</height> + </rect> + </property> + <property name="windowTitle"> + <string>MainWindow</string> + </property> + <widget class="QWidget" name="centralwidget"> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QSplitter" name="vsplitter"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <widget class="QTreeView" name="directories"> + <property name="editTriggers"> + <set>QAbstractItemView::NoEditTriggers</set> + </property> + <attribute name="headerStretchLastSection"> + <bool>false</bool> + </attribute> + </widget> + <widget class="QSplitter" name="hsplitter"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <widget class="QTreeView" name="messages"> + <property name="editTriggers"> + <set>QAbstractItemView::NoEditTriggers</set> + </property> + <attribute name="headerStretchLastSection"> + <bool>false</bool> + </attribute> + </widget> + <widget class="QTextEdit" name="message"> + <property name="readOnly"> + <bool>true</bool> + </property> + </widget> + </widget> + </widget> + </item> + </layout> + </widget> + </widget> + <resources/> + <connections/> +</ui> diff --git a/mbgui/mblaze.py b/mbgui/mblaze.py index 89841b3..4dae52c 100644 --- a/mbgui/mblaze.py +++ b/mbgui/mblaze.py @@ -14,8 +14,8 @@ class Status(enum.Enum): class Directory(typing.NamedTuple): + path: typing.Optional[str] name: str - is_leaf: bool children: typing.List['Directory'] @@ -30,7 +30,7 @@ class Message(typing.NamedTuple): def get_directories(roots: typing.Iterable[str]) -> typing.List[Directory]: paths = _cmd(['mdirs', '-a', *roots]) - directories = (_get_directory(Path(path).parts) for path in paths) + directories = (_get_directory(path, Path(path).parts) for path in paths) directories = _group_directories(directories) directories = [_reduce_directory(directory) for directory in directories] return directories @@ -73,12 +73,11 @@ def _cmd(args, stdin_lines=[]): return stdout.split('\n') -def _get_directory(path_parts): +def _get_directory(path, path_parts): name, path_parts = path_parts[0], path_parts[1:] - is_leaf = not path_parts - children = [_get_directory(path_parts)] if path_parts else [] - return Directory(name=name, - is_leaf=is_leaf, + children = [_get_directory(path, path_parts)] if path_parts else [] + return Directory(path=(path if not path_parts else None), + name=name, children=children) @@ -88,16 +87,16 @@ def _group_directories(directories): name_directories.setdefault(i.name, []).append(i) for name, directories in name_directories.items(): - is_leaf = any(i.is_leaf for i in directories) + path = next((i.path for i in directories if i.path), None) children = list(_group_directories( itertools.chain.from_iterable(i.children for i in directories))) - yield Directory(name=name, - is_leaf=is_leaf, + yield Directory(path=path, + name=name, children=children) def _reduce_directory(directory): - while not directory.is_leaf and len(directory.children) == 1: + while not directory.path and len(directory.children) == 1: child = directory.children[0] directory = child._replace(name=str(Path(directory.name) / child.name)) diff --git a/mbgui/tkapp.py b/mbgui/tkapp.py deleted file mode 100644 index fe16545..0000000 --- a/mbgui/tkapp.py +++ /dev/null @@ -1,59 +0,0 @@ -from tkinter import ttk -import collections -import concurrent.futures -import threading -import tkinter as tk -import typing - - -class TkApp: - - def __init__(self): - self._root = tk.Tk() - self._style = ttk.Style(self._root) - self._executor = concurrent.futures.ThreadPoolExecutor() - self._call_main_queue = collections.deque() - self._call_main_lock = threading.Lock() - - self._root.bind('<<AppCall>>', self._on_app_call) - - @property - def root(self) -> tk.Tk: - return self._root - - @property - def style(self) -> ttk.Style: - return self._style - - def call_main(self, - fn: typing.Callable, - *args, - **kwargs): - with self._call_main_lock: - self._call_main_queue.append((fn, args, kwargs)) - self._root.event_generate('<<AppCall>>') - - def call_worker(self, - fn: typing.Callable, - *args, - **kwargs - ) -> concurrent.futures.Future: - return self._executor.submit(fn, *args, **kwargs) - - def run(self): - try: - self._root.mainloop() - - finally: - self._executor.shutdown() - - def _on_app_call(self, evt): - with self._call_main_lock: - if not self._call_main_queue: - return - - queue = self._call_main_queue - self._call_main_queue = collections.deque() - - for fn, args, kwargs in queue: - fn(*args, **kwargs) @@ -7,12 +7,13 @@ readme = (Path(__file__).parent / 'README.rst').read_text() setup( name='mbgui', - version='0.0.1', + version='0.1.0', description='Maildir GUI based on mblaze', long_description=readme, long_description_content_type='text/x-rst', url='https://github.com/bozokopic/mbgui', packages=['mbgui'], + install_requires=['PySide6'], package_data={'mbgui': ['icons/*.png']}, license='GPLv3', classifiers=[ |
