Created
August 24, 2025 17:44
-
-
Save dataserver/b0caefc4640ba88b49baec9ac5e9748e to your computer and use it in GitHub Desktop.
A simple multi-tab notepad app built with PySide6.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| A simple multi-tab notepad app built with PySide6. | |
| Features: | |
| - Tabbed Interface: Keep multiple text files open in different tabs. | |
| - File Operations: Easily create new files, open existing ones, and save your work. | |
| - Session Recovery: Automatically saves your tabs and restores them when you reopen the app. | |
| - Markdown Preview: Switch between plain text and a live HTML preview for Markdown files. | |
| - Basic Text Editing: Includes undo, redo, cut, copy, paste, and select all. | |
| - Word Wrap: Turn on word wrap for easier reading of long lines. | |
| - Status Bar: Shows the current line, column number, and status messages | |
| Required Library: | |
| pip install PySide6 | |
| pip install Markdown | |
| """ | |
| import json | |
| import sys | |
| from pathlib import Path | |
| from typing import cast | |
| import markdown | |
| from PySide6.QtGui import QAction, QFont | |
| from PySide6.QtWidgets import ( | |
| QApplication, | |
| QFileDialog, | |
| QHBoxLayout, | |
| QLabel, | |
| QMainWindow, | |
| QMessageBox, | |
| QPushButton, | |
| QStatusBar, | |
| QTabWidget, | |
| QTextEdit, | |
| QVBoxLayout, | |
| QWidget, | |
| ) | |
| APP_DATA_DIR_NAME = "notepad_data" | |
| APP_ASK_TO_SAVE_ON_CLOSE = False | |
| def get_app_data_dir() -> Path: | |
| """Gets the 'notepad_data' folder next to the script or executable.""" | |
| if getattr(sys, "frozen", False): | |
| base_path = Path(sys.executable).parent | |
| else: | |
| base_path = Path(__file__).parent | |
| data_dir = base_path / APP_DATA_DIR_NAME | |
| data_dir.mkdir(exist_ok=True) | |
| return data_dir | |
| class TextEdit(QTextEdit): | |
| def __init__(self, parent: QWidget | None = None) -> None: | |
| super().__init__(parent) | |
| self.is_modified = False | |
| self.file_path = "" | |
| self.plain_text_content = "" | |
| self.markdown_preview_active = False | |
| self.setAcceptRichText(False) | |
| self.setLineWrapMode(QTextEdit.WidgetWidth) | |
| sans_serif_font = QFont() | |
| # Set the font family hint for cross-platform compatibility | |
| sans_serif_font.setStyleHint(QFont.StyleHint.SansSerif) | |
| # Set the font size to 12 points | |
| sans_serif_font.setPointSize(12) | |
| self.setFont(sans_serif_font) | |
| def setPlainText(self, text: str) -> None: | |
| """Sets text and resets modification status.""" | |
| super().setPlainText(text) | |
| self.plain_text_content = text | |
| self.is_modified = False | |
| class Notepad(QMainWindow): | |
| """Main application window for the multi-tab notepad.""" | |
| # 1. Initialization and Setup Methods | |
| # ------------------------------------------------------------------------- | |
| def __init__(self) -> None: | |
| super().__init__() | |
| self.app_data_dir = get_app_data_dir() | |
| self.metadata_path = self.app_data_dir / "notepad_tabs_meta.json" | |
| self._setup_ui() | |
| self._handle_session_recovery() | |
| def _setup_ui(self) -> None: | |
| """Initializes the main UI components.""" | |
| self.setWindowTitle("Notepad") | |
| self.setGeometry(100, 100, 800, 600) | |
| central_widget = QWidget() | |
| self.setCentralWidget(central_widget) | |
| main_layout = QVBoxLayout(central_widget) | |
| self.tab_widget = QTabWidget() | |
| self.tab_widget.setTabsClosable(True) | |
| self.tab_widget.tabCloseRequested.connect(self._close_tab) # type: ignore | |
| self.tab_widget.currentChanged.connect(self._on_tab_changed) # type: ignore | |
| main_layout.addWidget(self.tab_widget) | |
| self._create_menu_bar() | |
| self._create_status_bar() | |
| def _create_menu_bar(self) -> None: | |
| """Creates the application's menu bar and actions.""" | |
| menu_bar = self.menuBar() | |
| file_menu = menu_bar.addMenu("&File") | |
| file_menu.addAction("&New", self._add_new_tab, "Ctrl+N") | |
| file_menu.addAction("&Open...", self._open_file, "Ctrl+O") | |
| file_menu.addAction("&Save", self._save_file, "Ctrl+S") | |
| file_menu.addAction("Save &As...", self._save_file_as, "F12") | |
| file_menu.addSeparator() | |
| file_menu.addAction("E&xit", self.close, "Alt+F4") | |
| edit_menu = menu_bar.addMenu("&Edit") | |
| edit_menu.addAction("&Undo", lambda: self._current_editor().undo(), "Ctrl+Z") | |
| edit_menu.addAction("&Redo", lambda: self._current_editor().redo(), "Ctrl+Y") | |
| edit_menu.addSeparator() | |
| edit_menu.addAction("Cu&t", lambda: self._current_editor().cut(), "Ctrl+X") | |
| edit_menu.addAction("&Copy", lambda: self._current_editor().copy(), "Ctrl+C") | |
| edit_menu.addAction("&Paste", lambda: self._current_editor().paste(), "Ctrl+V") | |
| edit_menu.addSeparator() | |
| edit_menu.addAction( | |
| "Select &All", lambda: self._current_editor().selectAll(), "Ctrl+A" | |
| ) | |
| format_menu = menu_bar.addMenu("&Format") | |
| word_wrap_action = QAction("Word Wrap", self, checkable=True, checked=True) # type: ignore | |
| word_wrap_action.triggered.connect(self._toggle_word_wrap) # type: ignore | |
| format_menu.addAction(word_wrap_action) | |
| help_menu = menu_bar.addMenu("&Help") | |
| about_action = QAction("&About Notepad", self) | |
| about_action.triggered.connect(self._show_about_dialog) # type: ignore | |
| help_menu.addAction(about_action) | |
| def _create_status_bar(self) -> None: | |
| """Creates and populates the status bar.""" | |
| self.status_bar = QStatusBar() | |
| self.setStatusBar(self.status_bar) | |
| status_container = QWidget() | |
| status_layout = QHBoxLayout(status_container) | |
| status_layout.setContentsMargins(0, 0, 0, 0) | |
| self.status_label = QLabel("Ready") | |
| status_layout.addWidget(self.status_label) | |
| status_layout.addStretch() | |
| self.markdown_button = QPushButton("Markdown Preview") | |
| self.markdown_button.setCheckable(True) | |
| self.markdown_button.toggled.connect(self._toggle_markdown_preview) # type: ignore | |
| self.markdown_button.setFixedWidth(140) | |
| self.markdown_button.setEnabled(False) | |
| status_layout.addWidget(self.markdown_button) | |
| self.status_bar.addWidget(status_container, 1) | |
| self.status_bar.setSizeGripEnabled(True) | |
| # 2. File and Session Management Methods | |
| # ------------------------------------------------------------------------- | |
| def _handle_session_recovery(self) -> None: | |
| """Restores tabs from the previous session if the metadata file exists.""" | |
| if self.metadata_path.exists(): | |
| try: | |
| with open(self.metadata_path, "r", encoding="utf-8") as f: | |
| tabs_data = json.load(f) | |
| if tabs_data: | |
| for tab_data in tabs_data: | |
| # Defensive check to handle potential malformed data from older versions | |
| if isinstance(tab_data, dict): | |
| self._add_new_tab( | |
| content=tab_data.get("content", ""), | |
| file_path=tab_data.get("file_path", ""), | |
| ) | |
| else: | |
| # Handle old format (list of paths) | |
| with open(tab_data, "r", encoding="utf-8") as temp_f: | |
| content = temp_f.read() | |
| self._add_new_tab(content=content) | |
| QMessageBox.information( | |
| self, | |
| "Session Restored", | |
| "Unsaved tabs have been recovered.", | |
| ) | |
| else: | |
| self._add_new_tab() | |
| except (IOError, json.JSONDecodeError) as e: | |
| QMessageBox.warning( | |
| self, "Recovery Failed", f"Could not restore session:\n{e}" | |
| ) | |
| self._add_new_tab() | |
| else: | |
| self._add_new_tab() | |
| def _save_session(self) -> None: | |
| """Saves the current state of all tabs for recovery on next start.""" | |
| tabs_data = [] | |
| for i in range(self.tab_widget.count()): | |
| editor = self.tab_widget.widget(i) | |
| if isinstance(editor, TextEdit): | |
| tab_data = { | |
| "content": editor.plain_text_content, | |
| "file_path": editor.file_path, | |
| } | |
| tabs_data.append(tab_data) | |
| try: | |
| with open(self.metadata_path, "w", encoding="utf-8") as f: | |
| json.dump(tabs_data, f, indent=2) | |
| print("Session saved successfully.") | |
| except Exception as e: | |
| print(f"Error saving session: {e}") | |
| def _add_new_tab(self, content: str = "", file_path: str = "") -> None: | |
| """Adds a new tab with optional content and file path.""" | |
| editor = TextEdit() | |
| editor.setPlainText(content) | |
| editor.file_path = file_path | |
| editor.cursorPositionChanged.connect(self._update_status_bar) # type: ignore | |
| editor.textChanged.connect(self._on_text_changed) # type: ignore | |
| tab_title = Path(file_path).name if file_path else "Untitled" | |
| index = self.tab_widget.addTab(editor, tab_title) | |
| self.tab_widget.setCurrentIndex(index) | |
| self._on_tab_changed(index) | |
| def _open_file(self) -> None: | |
| """Opens a file dialog and loads the selected file into a new tab.""" | |
| file_name, _ = QFileDialog.getOpenFileName( | |
| self, | |
| "Open File", | |
| "", | |
| "Text Files (*.txt *.md *.markdown);;All Files (*)", | |
| ) | |
| if not file_name: | |
| return | |
| try: | |
| with open(file_name, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| self._add_new_tab(content, file_path=file_name) | |
| except Exception as e: | |
| QMessageBox.critical(self, "Error", f"Could not open file:\n{e}") | |
| def _save_file(self) -> bool: | |
| """Saves the current tab's content.""" | |
| editor = self._current_editor() | |
| if not editor: | |
| return False | |
| if not editor.file_path: | |
| return self._save_file_as() | |
| return self._save_file_to_disk(editor.file_path) | |
| def _save_file_as(self) -> bool: | |
| """Prompts for a file path and saves the current tab.""" | |
| file_name, _ = QFileDialog.getSaveFileName( | |
| self, | |
| "Save File As", | |
| "", | |
| "Text Files (*.txt *.md *.markdown);;All Files (*)", | |
| ) | |
| return self._save_file_to_disk(file_name) if file_name else False | |
| def _save_file_to_disk(self, file_path: str) -> bool: | |
| """Saves content to the specified file path.""" | |
| editor = self._current_editor() | |
| if not editor: | |
| return False | |
| try: | |
| content_to_save = editor.plain_text_content | |
| with open(file_path, "w", encoding="utf-8") as f: | |
| f.write(content_to_save) | |
| index = self.tab_widget.currentIndex() | |
| editor.file_path = file_path | |
| editor.is_modified = False | |
| self.tab_widget.setTabText(index, Path(file_path).name) | |
| self.tab_widget.setTabToolTip(index, file_path) | |
| self.status_bar.showMessage(f"Saved: {file_path}", 3000) | |
| return True | |
| except Exception as e: | |
| QMessageBox.critical(self, "Save Error", str(e)) | |
| return False | |
| # 3. UI and State Management Methods | |
| # ------------------------------------------------------------------------- | |
| def _current_editor(self) -> TextEdit: | |
| """Returns the currently active TextEdit widget.""" | |
| return cast(TextEdit, self.tab_widget.currentWidget()) | |
| def _on_tab_changed(self, index: int) -> None: | |
| """Updates UI elements when the current tab changes.""" | |
| editor = self.tab_widget.widget(index) | |
| self.markdown_button.setEnabled(isinstance(editor, TextEdit)) | |
| if isinstance(editor, TextEdit): | |
| self.markdown_button.setChecked(editor.markdown_preview_active) | |
| self._update_status_bar() | |
| def _on_text_changed(self) -> None: | |
| """Handles text changes and updates the tab title with an asterisk if modified.""" | |
| editor = self._current_editor() | |
| if not editor or editor.markdown_preview_active: | |
| return | |
| # Determine modification status by comparing with the saved content. | |
| is_modified = editor.toPlainText() != editor.plain_text_content | |
| editor.is_modified = is_modified | |
| index = self.tab_widget.currentIndex() | |
| tab_title = self.tab_widget.tabText(index).strip(" *") | |
| if is_modified: | |
| if not tab_title.endswith(" *"): | |
| self.tab_widget.setTabText(index, tab_title + " *") | |
| else: | |
| self.tab_widget.setTabText(index, tab_title) | |
| def _toggle_markdown_preview(self, checked: bool) -> None: | |
| editor = self._current_editor() | |
| if not editor: | |
| return | |
| if checked: | |
| editor.plain_text_content = editor.toPlainText() # Correctly saves state | |
| html = markdown.markdown( | |
| editor.plain_text_content, extensions=["extra", "codehilite", "nl2br"] | |
| ) | |
| editor.setHtml( | |
| f"<div style='font-family: sans-serif; font-size:16px'>{html}</div>" | |
| ) | |
| editor.setReadOnly(True) | |
| self.markdown_button.setText("Plain Text") | |
| self.status_label.setText("Markdown Preview Mode") | |
| editor.markdown_preview_active = True | |
| else: | |
| editor.setPlainText(editor.plain_text_content) # Restores from saved state | |
| editor.setReadOnly(False) | |
| self.markdown_button.setText("Markdown Preview") | |
| self.status_label.setText("Editing Mode") | |
| editor.markdown_preview_active = False | |
| def _update_status_bar(self) -> None: | |
| """Updates line and column status.""" | |
| editor = self._current_editor() | |
| if editor and not editor.markdown_preview_active: | |
| cursor = editor.textCursor() | |
| line = cursor.blockNumber() + 1 | |
| col = cursor.columnNumber() + 1 | |
| self.status_label.setText(f"Ln {line}, Col {col}") | |
| else: | |
| self.status_label.setText("Ready") | |
| def _toggle_word_wrap(self) -> None: | |
| """Toggles word wrap for the current editor.""" | |
| editor = self._current_editor() | |
| if editor: | |
| mode = ( | |
| QTextEdit.NoWrap | |
| if editor.lineWrapMode() != QTextEdit.NoWrap | |
| else QTextEdit.WidgetWidth | |
| ) | |
| editor.setLineWrapMode(mode) | |
| # 4. Event Handlers | |
| # ------------------------------------------------------------------------- | |
| def _close_tab(self, index: int) -> None: | |
| """Closes a tab, prompting to save if it has unsaved changes.""" | |
| editor = self.tab_widget.widget(index) | |
| if isinstance(editor, TextEdit) and editor.is_modified: | |
| tab_name = self.tab_widget.tabText(index) | |
| result = QMessageBox.question( | |
| self, | |
| "Unsaved Changes", | |
| f"The tab '{tab_name}' has unsaved changes. Save them?", | |
| QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, | |
| ) | |
| if result == QMessageBox.Cancel: | |
| return | |
| if result == QMessageBox.Save and not self._save_file(): | |
| return | |
| self.tab_widget.removeTab(index) | |
| if self.tab_widget.count() == 0: | |
| self._add_new_tab() | |
| def closeEvent(self, event) -> None: | |
| """Handles the application close event, prompting to save any unsaved tabs.""" | |
| if APP_ASK_TO_SAVE_ON_CLOSE: | |
| if self._has_unsaved_tabs(): | |
| result = QMessageBox.question( | |
| self, | |
| "Unsaved Changes", | |
| "You have unsaved changes in one or more tabs. Save them before closing?", | |
| QMessageBox.SaveAll | QMessageBox.Close | QMessageBox.Cancel, | |
| ) | |
| if result == QMessageBox.Cancel: | |
| event.ignore() | |
| return | |
| if result == QMessageBox.SaveAll: | |
| for i in range(self.tab_widget.count()): | |
| editor = self.tab_widget.widget(i) | |
| if isinstance(editor, TextEdit) and editor.is_modified: | |
| self.tab_widget.setCurrentIndex(i) | |
| if not self._save_file(): | |
| event.ignore() | |
| return | |
| self._save_session() | |
| event.accept() | |
| def _has_unsaved_tabs(self) -> bool: | |
| """Checks if any tab has unsaved changes.""" | |
| for i in range(self.tab_widget.count()): | |
| editor = self.tab_widget.widget(i) | |
| if isinstance(editor, TextEdit) and editor.is_modified: | |
| return True | |
| return False | |
| def _show_about_dialog(self) -> None: | |
| """Shows the 'About' dialog.""" | |
| QMessageBox.about( | |
| self, | |
| "About Notepad", | |
| "A multi-tab Notepad with markdown preview.\n" | |
| "Built with PySide6.\n\nBy John Doe", | |
| ) | |
| if __name__ == "__main__": | |
| app = QApplication(sys.argv) | |
| notepad = Notepad() | |
| notepad.show() | |
| sys.exit(app.exec()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment