Skip to content

Instantly share code, notes, and snippets.

@dataserver
Created August 24, 2025 17:44
Show Gist options
  • Save dataserver/b0caefc4640ba88b49baec9ac5e9748e to your computer and use it in GitHub Desktop.
Save dataserver/b0caefc4640ba88b49baec9ac5e9748e to your computer and use it in GitHub Desktop.
A simple multi-tab notepad app built with PySide6.
"""
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