diff --git a/CHANGELOG.md b/CHANGELOG.md index 157fb68..1cee404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,26 @@ +## [v0.7.0] + +### Added + +1. Add a simple toolbar at the top. +2. Add a context menu to the chat list sidebar with options: + - Edit Chat Title + - Delete + +### Changed + +1. Move the import button to the toolbar. +2. Move helper windows to separate module. + + ## [v0.6.0] -## Added +### Added 1. Add a common engine for the app. 2. Add storage modules to keep the services layer lighter. -## Changed +### Changed 1. Use a session factory for getting db sessions. 2. Reduce the maximum width of message bubbles. diff --git a/src/memorytext/services/chat_service.py b/src/memorytext/services/chat_service.py index 31b334b..a892060 100644 --- a/src/memorytext/services/chat_service.py +++ b/src/memorytext/services/chat_service.py @@ -57,3 +57,13 @@ def delete_chat_by_title(title: str): except SQLAlchemyError: session.rollback() raise + + +def edit_chat_title(conversation_id: int, new_title: str): + with get_db_session() as session: + try: + chrepo.edit_chat_title(session, conversation_id, new_title) + session.commit() + except SQLAlchemyError: + session.rollback() + raise diff --git a/src/memorytext/storage/chats_repo.py b/src/memorytext/storage/chats_repo.py index 3e82c63..17446a0 100644 --- a/src/memorytext/storage/chats_repo.py +++ b/src/memorytext/storage/chats_repo.py @@ -1,6 +1,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from sqlalchemy import delete, select +from sqlalchemy import delete, select, update from sqlalchemy.orm import selectinload from memorytext.models.core import Conversation @@ -45,3 +45,11 @@ def delete_chat_by_title(session: Session, title: str): select(Conversation.id).where(Conversation.title == title) ) delete_chat(session, conversation_id) + + +def edit_chat_title(session: Session, conversation_id: int | None, new_title: str): + session.execute( + update(Conversation) + .where(Conversation.id == conversation_id) + .values(title=new_title) + ) diff --git a/src/memorytext/views/chat_list_view.py b/src/memorytext/views/chat_list_view.py index 38a871a..60ff80e 100644 --- a/src/memorytext/views/chat_list_view.py +++ b/src/memorytext/views/chat_list_view.py @@ -1,16 +1,77 @@ -from PySide6.QtWidgets import QListView +from PySide6.QtWidgets import ( + QListView, + QMenu, +) +from PySide6.QtCore import Qt, QPoint, Signal +from PySide6.QtGui import QAction, QIcon + from memorytext.models.chat_list import ChatList -from memorytext.services.chat_service import get_chats +from memorytext.services import chat_service +from memorytext.windows.helper_windows import ConfirmationWindow, EditFieldWindow + +DELETE_ICON = QIcon.fromTheme("edit-delete") +EDIT_TITLE_ICON = QIcon.fromTheme("document-edit") class ChatListView(QListView): + chat_deleted = Signal(int) + def __init__(self): super().__init__() - chat_list = get_chats() + chat_list = chat_service.get_chats() self.chats = ChatList(chat_list) self.setModel(self.chats) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.show_context_menu) def update_data(self): - chat_list = get_chats() + chat_list = chat_service.get_chats() self.chats.set_data(chat_list) + + def show_context_menu(self, position: QPoint): + menu = QMenu(self) + index = self.indexAt(position) + if index.isValid(): + delete_action = QAction(DELETE_ICON, "Delete Chat", self) + edit_title_action = QAction(EDIT_TITLE_ICON, "Edit Chat Title", self) + menu.addAction(edit_title_action) + menu.addAction(delete_action) + + action = menu.exec(self.mapToGlobal(position)) + + if action == delete_action: + self.delete_conversation_id = index.data(role=Qt.ItemDataRole.UserRole) + text = "Are you sure you want to delete the chat?" + self.confirm = ConfirmationWindow(text, self.delete_chat_action) + self.confirm.show() + + if action == edit_title_action: + self.edit_conversation_id = index.data(role=Qt.ItemDataRole.UserRole) + text = "Please enter a new title." + self.edit_window = EditFieldWindow( + "Edit Chat Title", "New Title", text, self.edit_chat_title_action + ) + self.edit_window.show() + + def edit_chat_title_action(self): + new_title = self.edit_window.field_box.text() + if new_title and self.edit_conversation_id: + try: + chat_service.edit_chat_title(self.edit_conversation_id, new_title) + except Exception as e: + print(e) + raise + self.edit_window.close() + self.update_data() + + def delete_chat_action(self): + if self.delete_conversation_id: + try: + chat_service.delete_chat(self.delete_conversation_id) + self.chat_deleted.emit(self.delete_conversation_id) + except Exception as e: + print(e) + raise + self.confirm.close() + self.update_data() diff --git a/src/memorytext/windows/helper_windows.py b/src/memorytext/windows/helper_windows.py new file mode 100644 index 0000000..11146e1 --- /dev/null +++ b/src/memorytext/windows/helper_windows.py @@ -0,0 +1,164 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import ( + QComboBox, + QFormLayout, + QMainWindow, + QHBoxLayout, + QVBoxLayout, + QLineEdit, + QPushButton, + QWidget, + QLabel, +) +from PySide6.QtCore import Slot +from memorytext.services import chat_service + +if TYPE_CHECKING: + from collections.abc import Callable + + +class EditFieldWindow(QMainWindow): + def __init__( + self, window_title: str, field_name: str, text: str, ok_action: Callable + ): + super().__init__() + self.setWindowTitle(window_title) + self.setFixedSize(500, 200) + + button_layout = QHBoxLayout() + form_layout = QFormLayout() + layout = QVBoxLayout() + + self.label = QLabel(text) + layout.addWidget(self.label) + + self.field_box = QLineEdit() + self.field_box.setMaximumWidth(150) + + self.ok_button = QPushButton("Ok") + button_layout.addWidget(self.ok_button) + + self.cancel_button = QPushButton("Cancel") + button_layout.addWidget(self.cancel_button) + + form_layout.addRow(f"{field_name}:", self.field_box) + + layout.addLayout(form_layout) + layout.addLayout(button_layout) + + self.cancel_button.clicked.connect(self.close) + self.ok_button.clicked.connect(ok_action) + + container = QWidget() + container.setLayout(layout) + + self.setCentralWidget(container) + + +class ErrorWindow(QMainWindow): + def __init__(self, error: str): + super().__init__() + self.setWindowTitle("Error") + self.setFixedSize(500, 200) + + self.error_text = QLabel(text=error) + self.ok_button = QPushButton("Ok") + + self.ok_button.clicked.connect(self.close) + + layout = QVBoxLayout() + layout.addWidget(self.error_text) + layout.addWidget(self.ok_button) + + container = QWidget() + container.setLayout(layout) + + self.setCentralWidget(container) + + +class SelectUserWindow(QMainWindow): + def __init__(self, service: Callable[[str], set[str]], title: str): + super().__init__() + self.service = service + self.chat_title = title + self.initUI() + + def initUI(self): + self.participants = self.service(self.chat_title) + self.setWindowTitle("Select your username") + self.setGeometry(100, 100, 300, 200) + + button_layout = QHBoxLayout() + layout = QVBoxLayout() + + self.comboBox = QComboBox() + self.comboBox.addItems(self.participants) # pyright: ignore[reportArgumentType] + layout.addWidget(self.comboBox) + + self.default_select = str(next((name for name in self.participants), None)) + self.comboBox.setCurrentText(self.default_select) + + self.label = QLabel(f"Selected username: {self.default_select}") + layout.addWidget(self.label) + + self.ok_button = QPushButton("Ok") + button_layout.addWidget(self.ok_button) + + self.cancel_button = QPushButton("Cancel") + button_layout.addWidget(self.cancel_button) + + layout.addLayout(button_layout) + + self.cancel_button.clicked.connect(self.close) + self.ok_button.clicked.connect(self.set_username) + self.comboBox.currentTextChanged.connect(self.on_selection_changed) + + container = QWidget() + container.setLayout(layout) + + self.setCentralWidget(container) + + @Slot(str) + def on_selection_changed(self, text): + self.label.setText(f"Selected username: {text}") + + @Slot() + def set_username(self): + try: + username = self.comboBox.currentText() + chat_service.set_username(self.chat_title, username) + except Exception as e: + self.error_window = ErrorWindow(error=f"Error: {e}") + self.error_window.show() + self.close() + + +class ConfirmationWindow(QMainWindow): + def __init__(self, text: str, ok_action: Callable): + super().__init__() + self.setWindowTitle("Confirm Delete") + self.setFixedSize(500, 200) + + button_layout = QHBoxLayout() + layout = QVBoxLayout() + + self.label = QLabel(text) + layout.addWidget(self.label) + + self.ok_button = QPushButton("Ok") + button_layout.addWidget(self.ok_button) + + self.cancel_button = QPushButton("Cancel") + button_layout.addWidget(self.cancel_button) + + layout.addLayout(button_layout) + + self.cancel_button.clicked.connect(self.close) + self.ok_button.clicked.connect(ok_action) + + container = QWidget() + container.setLayout(layout) + + self.setCentralWidget(container) diff --git a/src/memorytext/windows/import_window.py b/src/memorytext/windows/import_window.py index 18622d5..de46cca 100644 --- a/src/memorytext/windows/import_window.py +++ b/src/memorytext/windows/import_window.py @@ -1,25 +1,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from PySide6.QtWidgets import ( - QComboBox, QFileDialog, QFormLayout, QMainWindow, - QHBoxLayout, - QVBoxLayout, QLineEdit, QPushButton, QWidget, - QLabel, ) -from PySide6.QtCore import Slot from sqlalchemy.exc import IntegrityError + from memorytext.io.import_wa import import_whatsapp from memorytext.services import chat_service - -if TYPE_CHECKING: - from collections.abc import Callable +from memorytext.windows.helper_windows import ( + ErrorWindow, + SelectUserWindow, +) class ImportWindow(QMainWindow): @@ -87,81 +82,3 @@ def import_chat(self): self.error_window.show() self.close() - - -class SelectUserWindow(QMainWindow): - def __init__(self, service: Callable[[str], set[str]], title: str): - super().__init__() - self.service = service - self.chat_title = title - self.initUI() - - def initUI(self): - self.participants = self.service(self.chat_title) - self.setWindowTitle("Select your username") - self.setGeometry(100, 100, 300, 200) - - button_layout = QHBoxLayout() - layout = QVBoxLayout() - - self.comboBox = QComboBox() - self.comboBox.addItems(self.participants) # pyright: ignore[reportArgumentType] - layout.addWidget(self.comboBox) - - self.default_select = str(next((name for name in self.participants), None)) - self.comboBox.setCurrentText(self.default_select) - - self.label = QLabel(f"Selected username: {self.default_select}") - layout.addWidget(self.label) - - self.ok_button = QPushButton("Ok") - button_layout.addWidget(self.ok_button) - - self.cancel_button = QPushButton("Cancel") - button_layout.addWidget(self.cancel_button) - - layout.addLayout(button_layout) - - self.cancel_button.clicked.connect(self.close) - self.ok_button.clicked.connect(self.set_username) - self.comboBox.currentTextChanged.connect(self.on_selection_changed) - - container = QWidget() - container.setLayout(layout) - - self.setCentralWidget(container) - - @Slot(str) - def on_selection_changed(self, text): - self.label.setText(f"Selected username: {text}") - - @Slot() - def set_username(self): - try: - username = self.comboBox.currentText() - chat_service.set_username(self.chat_title, username) - except Exception as e: - self.error_window = ErrorWindow(error=f"Error: {e}") - self.error_window.show() - self.close() - - -class ErrorWindow(QMainWindow): - def __init__(self, error: str): - super().__init__() - self.setWindowTitle("Error") - self.setFixedSize(500, 200) - - self.error_text = QLabel(text=error) - self.ok_button = QPushButton("Ok") - - self.ok_button.clicked.connect(self.close) - - layout = QVBoxLayout() - layout.addWidget(self.error_text) - layout.addWidget(self.ok_button) - - container = QWidget() - container.setLayout(layout) - - self.setCentralWidget(container) diff --git a/src/memorytext/windows/main_window.py b/src/memorytext/windows/main_window.py index e71a49f..a140372 100644 --- a/src/memorytext/windows/main_window.py +++ b/src/memorytext/windows/main_window.py @@ -2,16 +2,18 @@ QMainWindow, QHBoxLayout, QVBoxLayout, - # QLayout, + QToolBar, QWidget, - QPushButton, ) -from PySide6.QtCore import QModelIndex +from PySide6.QtCore import Qt, QModelIndex +from PySide6.QtGui import QAction, QIcon from memorytext.views.chat_view import ChatView from memorytext.views.chat_list_view import ChatListView from memorytext.windows.import_window import ImportWindow from memorytext.services import message_service +IMPORT_ICON = QIcon.fromTheme("list-add") + class MainWindow(QMainWindow): def __init__(self): @@ -23,12 +25,19 @@ def __init__(self): central_widget.setLayout(main_layout) self.setCentralWidget(central_widget) + # Toolbar + toolbar = QToolBar("Main Toolbar") + self.addToolBar(toolbar) + self.import_action = QAction(IMPORT_ICON, "Import Chat") + toolbar.addAction(self.import_action) + toolbar.setMovable(False) + toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + # Sidebar - self.import_button = QPushButton("Import chat") self.sidebar = ChatListView() self.sidebar.setFixedWidth(250) + self.sidebar.chat_deleted.connect(self.handle_chat_deletion) sidebar_layout = QVBoxLayout() - sidebar_layout.addWidget(self.import_button) sidebar_layout.addWidget(self.sidebar) # Messages @@ -39,7 +48,7 @@ def __init__(self): # Import Window self.import_window = None - self.import_button.clicked.connect(self.import_new_chat) + self.import_action.triggered.connect(self.import_new_chat) def load_messages(self, index: QModelIndex): convo_id = self.sidebar.model().get_id(index) # pyright: ignore[reportAttributeAccessIssue] @@ -49,3 +58,8 @@ def import_new_chat(self): if self.import_window is None: self.import_window = ImportWindow(parent=self) self.import_window.show() + + def handle_chat_deletion(self, deleted_conversation_id: int): + self.chat_area.set_messages( + deleted_conversation_id, message_service.get_messages + )