File Editor
A tabbed rich-text editor for real-world work: meetings, jobs, video scripts, K-12 lessons, and everything in between. Drop in images, keep structured outlines, and stay organized in one portable app.
Why you’ll like it
Work on multiple notes and documents at once with a clean, tabbed interface.
Start fast with templates for meetings, job postings, video scripts, and K–12 lessons.
Paste or drag-and-drop images directly into your notes, then select and resize them inline.
If enchant is installed, misspelled words are underlined so you can clean drafts quickly.
Toggle line numbers for more structured notes, scripts, or lesson plans.
Optional file tree dock, saved window settings, and a layout that feels like a tiny writing IDE.
Preview of templates
# Meeting Notes
Date: __________
Attendees: _______________________
# Job Posting Draft
Job Title: ______________________
# Video Script Outline
Title: ______________________
# Lesson Plan (K–12)
Subject: __________ Grade: ______
How it works
Download the .exe and launch it. Settings live in your user profile, not the registry.
Use tabs for different projects. Start from a template or open existing files from disk.
Type, format, and insert images. Enable spellcheck and line numbers when you want more structure.
Save documents like any normal editor and print using the built-in print support when needed.
FAQ
Does it require admin rights?
No. It’s designed to run from your user space. Only saving into protected folders would need elevation.
Does it work offline?
Yes. Everything is local. There are no network calls or online dependencies.
Where are settings stored?
Settings are stored in your user profile via Qt’s standard settings system, so you can carry the EXE separately.
What about spellcheck?
If Python enchant is available inside the build, misspelled words get a red underline.
Without it, the editor still works normally—just without spellcheck.
Template types included
Agenda, attendees, notes, and action items in a repeatable layout.
Sections for summary, responsibilities, requirements, benefits, and how to apply.
Hook, intro, main points, and call-to-action sections for YouTube-style videos.
Objectives, materials, warm-up, instruction, practice, assessment, and differentiation spots.
System requirements
- Windows 10 or later
- ~60 MB free space
- No separate Python install needed in the EXE build
Source Code
import sys, os, uuid, json, re, time, hashlib
from pathlib import Path
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QTextEdit, QAction, QFileDialog,
QTabWidget, QWidget, QVBoxLayout, QToolBar, QMessageBox,
QLabel, QLineEdit, QSpinBox, QDockWidget, QTreeView,
QPlainTextEdit, QComboBox, QFileSystemModel
)
from PyQt5.QtGui import (
QKeySequence, QTextCursor, QTextCharFormat, QTextImageFormat,
QTextListFormat, QFont, QPainter, QPen, QColor, QImage,
QGuiApplication, QTextDocument, QPalette, QTextFormat, QIcon
)
from PyQt5.QtCore import (
Qt, QUrl, QSettings, QPoint, QSize, QRectF,
pyqtSignal, QStandardPaths, QTimer, QProcess
)
from PyQt5.QtPrintSupport import QPrinter
try:
import enchant
except ImportError:
enchant = None
def resource_path(relative_path: str) -> str:
base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base_path, relative_path)
TEMPLATE_MEETING = """# Meeting Notes
Date: __________
Time: __________
Attendees: _______________________
## Agenda
- Item 1
- Item 2
- Item 3
## Notes
-
## Action Items
- [ ] Owner - Task
- [ ] Owner - Task
"""
TEMPLATE_JOB = """# Job Posting Draft
Job Title: ______________________
Company: ______________________
Location: ______________________
Employment Type: Full-time
## Summary
Short 2–3 sentence overview of the role.
## Responsibilities
-
## Requirements
-
## Benefits
-
## How to Apply
Include link or email address here.
"""
TEMPLATE_VIDEO = """# Video Script Outline
Title: ______________________
## Hook (0–15 sec)
-
## Intro (15–60 sec)
- Who you are
- What the video is about
- Why they should watch
## Main Points
1.
2.
3.
## Call to Action
- Subscribe / like / comment / visit website
"""
TEMPLATE_LESSON = """# Lesson Plan (K–12)
Subject: _____________ Grade: ______
Lesson Title: ______________________
Estimated Time: ______ minutes
## Standards
-
## Learning Objectives
-
## Materials
-
## Warm-up (5–10 min)
-
## Direct Instruction
-
## Guided Practice
-
## Independent Practice
-
## Assessment
-
## Differentiation / Accommodations
-
"""
class LineNumberArea(QWidget):
def __init__(self, editor):
super().__init__(editor)
self.editor = editor
def sizeHint(self):
return QSize(self.editor.line_number_area_width(), 0)
def paintEvent(self, event):
self.editor.line_number_area_paint_event(event)
class RichTextEditor(QTextEdit):
imageSelected = pyqtSignal(bool, int, int)
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptRichText(True)
self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(' '))
self.selected_image_cursor = None
self.setMouseTracking(True)
self._show_line_numbers = False
self.lineNumberArea = LineNumberArea(self)
self._spellcheck_enabled = False
self._spell_dict = None
self._spell_selections = []
self._line_highlight_selection = None
self.document().blockCountChanged.connect(self.update_line_number_area_width)
self.cursorPositionChanged.connect(self.highlight_current_line)
self.verticalScrollBar().valueChanged.connect(self.lineNumberArea.update)
self.textChanged.connect(self._on_text_changed)
self.update_line_number_area_width()
self.highlight_current_line()
self.lineNumberArea.hide()
def set_spellcheck_enabled(self, enabled: bool, dictionary=None):
self._spellcheck_enabled = bool(enabled) and dictionary is not None
self._spell_dict = dictionary if self._spellcheck_enabled else None
self.recheck_spelling()
def _on_text_changed(self):
self.lineNumberArea.update()
if self._spellcheck_enabled:
self.recheck_spelling()
def recheck_spelling(self):
self._spell_selections = []
if not self._spellcheck_enabled or self._spell_dict is None:
self.update_extra_selections()
return
pattern = re.compile(r"\b[\w']+\b")
block = self.document().firstBlock()
while block.isValid():
text = block.text()
for match in pattern.finditer(text):
word = match.group()
if not self._spell_dict.check(word):
sel = QTextEdit.ExtraSelection()
fmt = QTextCharFormat()
fmt.setUnderlineColor(QColor("red"))
fmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
sel.format = fmt
cursor = QTextCursor(block)
start = block.position() + match.start()
end = block.position() + match.end()
cursor.setPosition(start)
cursor.setPosition(end, QTextCursor.KeepAnchor)
sel.cursor = cursor
self._spell_selections.append(sel)
block = block.next()
self.update_extra_selections()
def update_extra_selections(self):
selections = []
if self._line_highlight_selection is not None:
selections.append(self._line_highlight_selection)
selections.extend(self._spell_selections)
self.setExtraSelections(selections)
def insertFromMimeData(self, source):
if source.hasImage():
image = source.imageData()
if isinstance(image, QImage):
self.insert_image(image)
if source.hasText():
super().insertFromMimeData(source)
return
super().insertFromMimeData(source)
def insert_image(self, image: QImage):
if image.isNull():
return
name = f"image_{uuid.uuid4().hex}"
self.document().addResource(
QTextDocument.ImageResource, QUrl(name), image
)
cursor = self.textCursor()
cursor.beginEditBlock()
if cursor.positionInBlock() != 0:
cursor.insertBlock()
fmt = QTextImageFormat()
fmt.setName(name)
max_width = min(600, self.viewport().width() - 40)
width = min(max_width, image.width()) if image.width() > 0 else max_width
if width <= 0:
width = image.width() or 200
height = width * image.height() / image.width() if image.width() else image.height()
fmt.setWidth(width)
fmt.setHeight(height)
cursor.insertImage(fmt)
cursor.insertBlock()
cursor.endEditBlock()
self.setTextCursor(cursor)
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
cursor = self.cursorForPosition(event.pos())
fmt = cursor.charFormat()
if fmt.isImageFormat():
self.selected_image_cursor = cursor
img_fmt = fmt.toImageFormat()
width = int(img_fmt.width())
height = int(img_fmt.height())
if width <= 0 or height <= 0:
img = self._image_for_format(img_fmt)
if img is not None:
width, height = img.width(), img.height()
self.imageSelected.emit(True, width, height)
self.viewport().update()
else:
if self.selected_image_cursor is not None:
self.selected_image_cursor = None
self.imageSelected.emit(False, 0, 0)
self.viewport().update()
super().mousePressEvent(event)
def _image_for_format(self, img_fmt: QTextImageFormat):
name = img_fmt.name()
if not name:
return None
res = self.document().resource(
QTextDocument.ImageResource, QUrl(name)
)
if isinstance(res, QImage):
return res
return None
def _image_rect_for_cursor(self, cursor):
if not cursor or not cursor.charFormat().isImageFormat():
return None
img_fmt = cursor.charFormat().toImageFormat()
width = img_fmt.width()
height = img_fmt.height()
if width <= 0 or height <= 0:
img = self._image_for_format(img_fmt)
if img is None:
return None
width, height = img.width(), img.height()
caret_rect = self.cursorRect(cursor)
x = caret_rect.left() - width
y = caret_rect.bottom() - height
return QRectF(x, y, width, height)
def paintEvent(self, event):
super().paintEvent(event)
if self.selected_image_cursor is not None:
img_rect = self._image_rect_for_cursor(self.selected_image_cursor)
if img_rect is not None:
painter = QPainter(self.viewport())
pen = QPen(Qt.DashLine)
pen.setWidth(2)
pen.setColor(QColor(0, 120, 215))
painter.setPen(pen)
margin = 3
outline = img_rect.adjusted(-margin, -margin, margin, margin)
painter.drawRect(outline)
def resize_selected_image(self, new_width: int):
if self.selected_image_cursor is None:
return
cursor = self.selected_image_cursor
fmt = cursor.charFormat()
if not fmt.isImageFormat():
return
img_fmt = fmt.toImageFormat()
img = self._image_for_format(img_fmt)
if img is None or img.width() == 0:
return
width = max(16, min(new_width, 2000))
height = width * img.height() / img.width()
img_fmt.setWidth(width)
img_fmt.setHeight(height)
cursor.beginEditBlock()
cursor.select(QTextCursor.CharacterUnderCursor)
cursor.setCharFormat(img_fmt)
cursor.clearSelection()
cursor.endEditBlock()
self.viewport().update()
self.imageSelected.emit(True, int(width), int(height))
def has_images(self) -> bool:
return "
int:
if not self._show_line_numbers:
return 0
digits = len(str(max(1, self.document().blockCount())))
space = 3 + self.fontMetrics().horizontalAdvance('9' * max(2, digits))
return space + 6
def update_line_number_area_width(self, _=0):
if not self._show_line_numbers:
self.setViewportMargins(0, 0, 0, 0)
return
width = self.line_number_area_width()
self.setViewportMargins(width, 0, 0, 0)
cr = self.contentsRect()
self.lineNumberArea.setGeometry(cr.left(), cr.top(), width, cr.height())
def resizeEvent(self, event):
super().resizeEvent(event)
self.update_line_number_area_width()
def line_number_area_paint_event(self, event):
if not self._show_line_numbers:
return
painter = QPainter(self.lineNumberArea)
painter.fillRect(event.rect(), self.palette().window().color().darker(110))
layout = self.document().documentLayout()
vscroll = self.verticalScrollBar().value()
block = self.document().firstBlock()
while block.isValid():
rect = layout.blockBoundingRect(block)
bottom = rect.bottom() - vscroll
if bottom >= event.rect().top():
break
block = block.next()
while block.isValid():
rect = layout.blockBoundingRect(block)
top = rect.top() - vscroll
if top > event.rect().bottom():
break
if block.isVisible():
number = str(block.blockNumber() + 1)
painter.setPen(self.palette().text().color())
painter.drawText(
0, int(top),
self.lineNumberArea.width() - 4, int(rect.height()),
Qt.AlignRight | Qt.AlignVCenter,
number,
)
block = block.next()
def set_line_numbers_visible(self, visible: bool):
self._show_line_numbers = bool(visible)
self.lineNumberArea.setVisible(self._show_line_numbers)
self.update_line_number_area_width()
self.lineNumberArea.update()
self.viewport().update()
def highlight_current_line(self):
if self.isReadOnly():
return
selection = QTextEdit.ExtraSelection()
line_color = self.palette().alternateBase().color()
selection.format.setBackground(line_color)
selection.format.setProperty(QTextFormat.FullWidthSelection, True)
selection.cursor = self.textCursor()
selection.cursor.clearSelection()
self._line_highlight_selection = selection
self.update_extra_selections()
class EditorTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.editor = RichTextEditor(self)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.editor)
self.file_path = None
self.is_modified = False
self.editor.textChanged.connect(self.on_modified)
def on_modified(self):
self.is_modified = True
mw = self.window()
if isinstance(mw, QMainWindow) and hasattr(mw, "update_tab_titles"):
mw.update_tab_titles()
def load_from_file(self, path: str):
ext = os.path.splitext(path)[1].lower()
try:
with open(path, "r", encoding="utf-8") as f:
data = f.read()
except UnicodeDecodeError:
with open(path, "r", encoding="latin1") as f:
data = f.read()
if ext in (".html", ".htm"):
self.editor.setHtml(data)
else:
self.editor.setPlainText(data)
self.file_path = path
self.is_modified = False
def save_to_file(self, path: str):
ext = os.path.splitext(path)[1].lower()
if ext in (".pdf", ".png"):
ext = ".html"
html = self.editor.toHtml()
if ext in (".html", ".htm"):
content = html
else:
content = self.editor.toPlainText()
with open(path, "w", encoding="utf-8") as f:
f.write(content)
self.file_path = path
self.is_modified = False
def has_images(self) -> bool:
return self.editor.has_images()
def title(self) -> str:
return os.path.basename(self.file_path) if self.file_path else "Untitled"
def get_editor(self) -> RichTextEditor:
return self.editor
class TerminalWidget(QWidget):
def __init__(self, parent=None, start_dir=None):
super().__init__(parent)
self.current_dir = Path(start_dir or Path.home())
self.output = QPlainTextEdit(self)
self.output.setReadOnly(True)
self.input = QLineEdit(self)
self.input.setPlaceholderText("Enter command (local only). Use 'cd ' to change directory.")
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.output)
layout.addWidget(self.input)
self.input.returnPressed.connect(self.run_command)
def append_text(self, text: str):
if text:
self.output.appendPlainText(text.rstrip("\n"))
def run_command(self):
cmd = self.input.text().strip()
if not cmd:
return
self.append_text(f"> {cmd}")
self.input.clear()
if cmd.startswith("cd "):
path_str = cmd[3:].strip().strip('"').strip("'")
new_path = Path(path_str)
if not new_path.is_absolute():
new_path = (self.current_dir / new_path).resolve()
if new_path.is_dir():
self.current_dir = new_path
self.append_text(f"(cwd: {self.current_dir})")
else:
self.append_text(f"Directory not found: {path_str}")
return
proc = QProcess(self)
proc.setWorkingDirectory(str(self.current_dir))
def handle_stdout():
data = proc.readAllStandardOutput().data().decode(errors="ignore")
if data:
self.append_text(data)
def handle_stderr():
data = proc.readAllStandardError().data().decode(errors="ignore")
if data:
self.append_text(data)
def finished(exit_code, _status):
self.append_text(f"[process exited {exit_code}]")
proc.deleteLater()
proc.readyReadStandardOutput.connect(handle_stdout)
proc.readyReadStandardError.connect(handle_stderr)
proc.finished.connect(finished)
if os.name == "nt":
proc.start("cmd", ["/C", cmd])
else:
proc.start("/bin/bash", ["-lc", cmd])
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.settings = QSettings("FileEditor", "FileEditor")
icon_path = resource_path("icon.ico")
if os.path.exists(icon_path):
icon = QIcon(icon_path)
self.setWindowIcon(icon)
app = QApplication.instance()
if app is not None:
app.setWindowIcon(icon)
self.dark_mode = self.settings.value("dark_mode", False, type=bool)
self.show_line_numbers = self.settings.value("show_line_numbers", False, type=bool)
self.word_wrap_enabled = self.settings.value("word_wrap", True, type=bool)
self.spellcheck_enabled = self.settings.value("spellcheck_enabled", False, type=bool)
if enchant:
try:
self.spell_dict = enchant.Dict("en_US")
except Exception:
self.spell_dict = None
else:
self.spell_dict = None
base = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)
if not base:
base = os.path.join(os.path.expanduser("~"), ".file_editor")
self.app_data_dir = Path(base)
self.app_data_dir.mkdir(parents=True, exist_ok=True)
self.autosave_dir = self.app_data_dir / "autosave"
self.autosave_dir.mkdir(exist_ok=True)
self.versions_dir = self.app_data_dir / "versions"
self.versions_dir.mkdir(exist_ok=True)
self.setWindowTitle("File Editor")
self.resize(1100, 750)
self.tab_widget = QTabWidget(self)
self.tab_widget.setTabsClosable(True)
self.tab_widget.tabCloseRequested.connect(self.close_tab)
self.tab_widget.currentChanged.connect(self.on_current_tab_changed)
self.setCentralWidget(self.tab_widget)
self.file_tree_dock = None
self.file_model = None
self.notes_root = None
self.terminal_dock = None
self.terminal_widget = None
self.image_width_spin = None
self.fontSizeCombo = None
self.styleCombo = None
self.findToolbar = None
self.findEdit = None
self.replaceEdit = None
self.create_actions()
self.create_menus_toolbars()
self.setup_file_tree()
self.setup_terminal()
self.toggleThemeAct.setChecked(self.dark_mode)
self.toggleLineNumbersAct.setChecked(self.show_line_numbers)
self.toggleWordWrapAct.setChecked(self.word_wrap_enabled)
self.toggleSpellcheckAct.setChecked(self.spellcheck_enabled)
self.toggleFileTreeAct.setChecked(True)
self.toggleTerminalAct.setChecked(False)
self.apply_theme()
restored = self.restore_session_state()
if not restored:
self.new_tab()
self.apply_line_numbers_visibility()
self.apply_word_wrap()
self.apply_spellcheck_to_all()
self.statusBar().showMessage("Ready")
self.start_autosave_timer()
def create_actions(self):
self.newAct = QAction("&New Tab", self, shortcut=QKeySequence.New,
triggered=self.new_tab)
self.openAct = QAction("&Open...", self, shortcut=QKeySequence.Open,
triggered=self.open_file)
self.saveAct = QAction("&Save", self, shortcut=QKeySequence.Save,
triggered=self.save_file)
self.saveAsAct = QAction("Save &As...", self, shortcut=QKeySequence.SaveAs,
triggered=self.save_file_as)
self.viewHistoryAct = QAction("View &History...", self,
triggered=self.view_history_for_current)
self.exportPdfAct = QAction("Export as &PDF...", self,
triggered=self.export_as_pdf)
self.exportPngAct = QAction("Export as &PNG...", self,
triggered=self.export_as_png)
self.exitAct = QAction("E&xit", self, shortcut="Ctrl+Shift+Q",
triggered=self.close)
self.undoAct = QAction("&Undo", self, shortcut=QKeySequence.Undo,
triggered=lambda: self.call_on_editor("undo"))
self.redoAct = QAction("&Redo", self, shortcut=QKeySequence.Redo,
triggered=lambda: self.call_on_editor("redo"))
self.cutAct = QAction("Cu&t", self, shortcut=QKeySequence.Cut,
triggered=lambda: self.call_on_editor("cut"))
self.copyAct = QAction("&Copy", self, shortcut=QKeySequence.Copy,
triggered=lambda: self.call_on_editor("copy"))
self.pasteAct = QAction("&Paste", self, shortcut=QKeySequence.Paste,
triggered=lambda: self.call_on_editor("paste"))
self.boldAct = QAction("&Bold", self, shortcut="Ctrl+B",
checkable=True, triggered=self.toggle_bold)
self.italicAct = QAction("&Italic", self, shortcut="Ctrl+I",
checkable=True, triggered=self.toggle_italic)
self.underlineAct = QAction("&Underline", self, shortcut="Ctrl+U",
checkable=True, triggered=self.toggle_underline)
self.bulletAct = QAction("Bulleted &List", self,
triggered=self.toggle_bullet_list)
self.highlightAct = QAction("&Highlight", self,
checkable=True, triggered=self.toggle_highlight)
self.clearFormatAct = QAction("&Clear Formatting", self,
triggered=self.clear_formatting)
self.insertImageClipboardAct = QAction(
"Image from &Clipboard", self,
shortcut="Ctrl+Shift+V",
triggered=self.insert_image_from_clipboard
)
self.insertImageFileAct = QAction(
"Image from &File...", self,
triggered=self.insert_image_from_file
)
self.findAct = QAction("&Find / Replace", self,
shortcut=QKeySequence.Find,
triggered=self.show_find_toolbar)
self.toggleThemeAct = QAction("Dark Mode", self,
checkable=True,
shortcut="Ctrl+T",
triggered=self.toggle_dark_mode)
self.toggleLineNumbersAct = QAction("Show Line Numbers", self,
checkable=True,
shortcut="Ctrl+L",
triggered=self.toggle_line_numbers)
self.toggleWordWrapAct = QAction("Word Wrap", self,
checkable=True,
triggered=self.toggle_word_wrap)
self.toggleSpellcheckAct = QAction("Spellcheck", self,
checkable=True,
triggered=self.toggle_spellcheck)
self.setNotesRootAct = QAction("Set Notes Root...", self,
triggered=self.choose_notes_root)
self.toggleFileTreeAct = QAction("File Tree", self,
checkable=True,
shortcut="Ctrl+Q",
triggered=self.toggle_file_tree)
self.toggleTerminalAct = QAction("Terminal", self,
checkable=True,
shortcut="Ctrl+M",
triggered=self.toggle_terminal)
self.newMeetingTemplateAct = QAction("Meeting Notes", self,
triggered=lambda: self.new_from_template("meeting"))
self.newJobTemplateAct = QAction("Job Posting Draft", self,
triggered=lambda: self.new_from_template("job"))
self.newVideoTemplateAct = QAction("Video Script Outline", self,
triggered=lambda: self.new_from_template("video"))
self.newLessonTemplateAct = QAction("Lesson Plan (K-12)", self,
triggered=lambda: self.new_from_template("lesson"))
def create_menus_toolbars(self):
menubar = self.menuBar()
fileMenu = menubar.addMenu("&File")
fileMenu.addAction(self.newAct)
templateMenu = fileMenu.addMenu("New from &Template")
templateMenu.addAction(self.newMeetingTemplateAct)
templateMenu.addAction(self.newJobTemplateAct)
templateMenu.addAction(self.newVideoTemplateAct)
templateMenu.addAction(self.newLessonTemplateAct)
fileMenu.addAction(self.openAct)
fileMenu.addSeparator()
fileMenu.addAction(self.saveAct)
fileMenu.addAction(self.saveAsAct)
fileMenu.addSeparator()
fileMenu.addAction(self.viewHistoryAct)
fileMenu.addSeparator()
fileMenu.addAction(self.exportPdfAct)
fileMenu.addAction(self.exportPngAct)
fileMenu.addSeparator()
fileMenu.addAction(self.exitAct)
editMenu = menubar.addMenu("&Edit")
editMenu.addAction(self.undoAct)
editMenu.addAction(self.redoAct)
editMenu.addSeparator()
editMenu.addAction(self.cutAct)
editMenu.addAction(self.copyAct)
editMenu.addAction(self.pasteAct)
editMenu.addSeparator()
editMenu.addAction(self.findAct)
formatMenu = menubar.addMenu("F&ormat")
formatMenu.addAction(self.boldAct)
formatMenu.addAction(self.italicAct)
formatMenu.addAction(self.underlineAct)
formatMenu.addAction(self.highlightAct)
formatMenu.addAction(self.clearFormatAct)
formatMenu.addSeparator()
formatMenu.addAction(self.bulletAct)
insertMenu = menubar.addMenu("&Insert")
insertMenu.addAction(self.insertImageClipboardAct)
insertMenu.addAction(self.insertImageFileAct)
viewMenu = menubar.addMenu("&View")
viewMenu.addAction(self.toggleFileTreeAct)
viewMenu.addAction(self.toggleTerminalAct)
settingsMenu = menubar.addMenu("&Settings")
settingsMenu.addAction(self.toggleThemeAct)
settingsMenu.addAction(self.toggleLineNumbersAct)
settingsMenu.addAction(self.toggleWordWrapAct)
settingsMenu.addAction(self.toggleSpellcheckAct)
settingsMenu.addSeparator()
settingsMenu.addAction(self.setNotesRootAct)
toolbar = QToolBar("Main Toolbar", self)
toolbar.setMovable(False)
self.addToolBar(toolbar)
toolbar.addAction(self.newAct)
toolbar.addAction(self.openAct)
toolbar.addAction(self.saveAct)
toolbar.addSeparator()
toolbar.addAction(self.boldAct)
toolbar.addAction(self.italicAct)
toolbar.addAction(self.underlineAct)
toolbar.addAction(self.highlightAct)
toolbar.addAction(self.bulletAct)
toolbar.addSeparator()
self.fontSizeCombo = QComboBox(self)
for size in [10, 11, 12, 14, 16, 18, 20]:
self.fontSizeCombo.addItem(str(size))
self.fontSizeCombo.setCurrentText("12")
self.fontSizeCombo.currentTextChanged.connect(self.on_font_size_changed)
toolbar.addWidget(QLabel("Size:", self))
toolbar.addWidget(self.fontSizeCombo)
self.styleCombo = QComboBox(self)
self.styleCombo.addItems(["Normal", "Heading 1", "Heading 2", "Quote", "Code"])
self.styleCombo.currentTextChanged.connect(self.on_style_changed)
toolbar.addWidget(QLabel("Style:", self))
toolbar.addWidget(self.styleCombo)
toolbar.addSeparator()
toolbar.addAction(self.insertImageClipboardAct)
toolbar.addAction(self.insertImageFileAct)
toolbar.addSeparator()
label = QLabel("Img W:", self)
toolbar.addWidget(label)
self.image_width_spin = QSpinBox(self)
self.image_width_spin.setRange(16, 2000)
self.image_width_spin.setEnabled(False)
self.image_width_spin.valueChanged.connect(self.on_image_width_changed)
toolbar.addWidget(self.image_width_spin)
self.findToolbar = QToolBar("Find / Replace", self)
self.findToolbar.setMovable(False)
self.addToolBar(Qt.BottomToolBarArea, self.findToolbar)
self.findEdit = QLineEdit(self)
self.replaceEdit = QLineEdit(self)
self.findEdit.setPlaceholderText("Find...")
self.replaceEdit.setPlaceholderText("Replace with...")
self.findToolbar.addWidget(QLabel("Find:", self))
self.findToolbar.addWidget(self.findEdit)
self.findToolbar.addSeparator()
self.findToolbar.addWidget(QLabel("Replace:", self))
self.findToolbar.addWidget(self.replaceEdit)
findNextAct = QAction("Next", self, triggered=self.find_next)
replaceAct = QAction("Replace", self, triggered=self.replace_once)
replaceAllAct = QAction("Replace All", self, triggered=self.replace_all)
closeFindAct = QAction("Close", self, triggered=self.hide_find_toolbar)
self.findToolbar.addAction(findNextAct)
self.findToolbar.addAction(replaceAct)
self.findToolbar.addAction(replaceAllAct)
self.findToolbar.addAction(closeFindAct)
self.findEdit.returnPressed.connect(self.find_next)
self.replaceEdit.returnPressed.connect(self.replace_once)
self.findToolbar.hide()
def setup_file_tree(self):
root_str = self.settings.value("notes_root", "", type=str)
if root_str:
root_path = Path(root_str)
else:
root_path = Path.home()
if not root_path.exists():
root_path = Path.home()
self.notes_root = root_path
self.file_model = QFileSystemModel(self)
self.file_model.setRootPath(str(self.notes_root))
self.file_model.setNameFilters(["*.txt", "*.html", "*.htm", "*.md"])
self.file_model.setNameFilterDisables(False)
tree = QTreeView(self)
tree.setModel(self.file_model)
tree.setRootIndex(self.file_model.index(str(self.notes_root)))
tree.setHeaderHidden(True)
tree.doubleClicked.connect(self.on_file_tree_double_clicked)
self.file_tree_dock = QDockWidget("Notes", self)
self.file_tree_dock.setWidget(tree)
self.file_tree_dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
self.addDockWidget(Qt.LeftDockWidgetArea, self.file_tree_dock)
self.file_tree_dock.visibilityChanged.connect(self.on_file_tree_visibility_changed)
def setup_terminal(self):
self.terminal_widget = TerminalWidget(self, start_dir=self.notes_root or Path.home())
self.terminal_dock = QDockWidget("Terminal", self)
self.terminal_dock.setWidget(self.terminal_widget)
self.terminal_dock.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea)
self.addDockWidget(Qt.BottomDockWidgetArea, self.terminal_dock)
self.terminal_dock.hide()
self.terminal_dock.visibilityChanged.connect(self.on_terminal_visibility_changed)
def new_tab(self, path=None):
tab = EditorTab(self)
idx = self.tab_widget.addTab(tab, "Untitled")
self.tab_widget.setCurrentIndex(idx)
editor = tab.get_editor()
editor.cursorPositionChanged.connect(self.update_status_bar)
editor.textChanged.connect(self.update_status_bar)
editor.imageSelected.connect(self.on_image_selected)
editor.set_line_numbers_visible(self.show_line_numbers)
self.apply_word_wrap_to_editor(editor)
editor.set_spellcheck_enabled(self.spellcheck_enabled, self.spell_dict)
if path:
tab.load_from_file(path)
self.update_tab_titles()
self.update_status_bar()
def new_from_template(self, kind: str):
self.new_tab()
tab = self.current_tab()
if not tab:
return
editor = tab.get_editor()
if kind == "meeting":
editor.setPlainText(TEMPLATE_MEETING)
elif kind == "job":
editor.setPlainText(TEMPLATE_JOB)
elif kind == "video":
editor.setPlainText(TEMPLATE_VIDEO)
elif kind == "lesson":
editor.setPlainText(TEMPLATE_LESSON)
tab.is_modified = True
self.update_tab_titles()
def current_tab(self):
widget = self.tab_widget.currentWidget()
if isinstance(widget, EditorTab):
return widget
return None
def current_editor(self):
tab = self.current_tab()
return tab.get_editor() if tab else None
def call_on_editor(self, method_name: str):
editor = self.current_editor()
if editor is None:
return
func = getattr(editor, method_name, None)
if func:
func()
def update_tab_titles(self):
for i in range(self.tab_widget.count()):
widget = self.tab_widget.widget(i)
if isinstance(widget, EditorTab):
title = widget.title()
if widget.is_modified:
title = "*" + title
self.tab_widget.setTabText(i, title)
current = self.current_tab()
if current:
self.setWindowTitle(f"{current.title()} - File Editor")
else:
self.setWindowTitle("File Editor")
def open_file(self):
paths, _ = QFileDialog.getOpenFileNames(
self,
"Open Notes",
"",
"Notes (*.txt *.html *.htm *.md);;All Files (*)",
)
for path in paths:
if path:
self.new_tab(path)
def save_file(self):
tab = self.current_tab()
if tab is None:
return
if tab.file_path is None:
self.save_file_as()
return
path = tab.file_path
ext = os.path.splitext(path)[1].lower()
if ext == ".txt" and tab.has_images():
resp = QMessageBox.question(
self,
"Images and .txt",
"This note contains images. Saving as .txt will drop them "
"and any formatting.\n\n"
"Would you like to instead export to PDF (recommended)?",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
)
if resp == QMessageBox.Cancel:
return
if resp == QMessageBox.Yes:
self.export_as_pdf()
return
tab.save_to_file(path)
self.snapshot_version(tab)
self.update_tab_titles()
self.save_session_state()
def save_file_as(self):
tab = self.current_tab()
if tab is None:
return
initial = tab.file_path or "note.txt"
path, _ = QFileDialog.getSaveFileName(
self,
"Save Note",
initial,
"Text files (*.txt);;HTML files (*.html *.htm);;PDF files (*.pdf);;PNG images (*.png);;All Files (*)",
)
if not path:
return
ext = os.path.splitext(path)[1].lower()
if ext == ".pdf":
self.export_as_pdf(path)
return
if ext == ".png":
self.export_as_png(path)
return
if ext == ".txt" and tab.has_images():
resp = QMessageBox.question(
self,
"Images and .txt",
"This note contains images. Saving as .txt will drop them "
"and any formatting.\n\n"
"Do you want to change the extension to .pdf instead?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if resp == QMessageBox.Yes:
base, _ = os.path.splitext(path)
path = base + ".pdf"
self.export_as_pdf(path)
return
tab.save_to_file(path)
self.snapshot_version(tab)
self.update_tab_titles()
self.save_session_state()
def maybe_save_tab(self, tab: EditorTab) -> bool:
if not tab.is_modified:
return True
resp = QMessageBox.question(
self,
"Unsaved changes",
f"Save changes to '{tab.title()}'?",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
QMessageBox.Yes,
)
if resp == QMessageBox.Cancel:
return False
if resp == QMessageBox.Yes:
idx = self.tab_widget.indexOf(tab)
current = self.tab_widget.currentIndex()
self.tab_widget.setCurrentIndex(idx)
self.save_file()
self.tab_widget.setCurrentIndex(current)
if tab.is_modified:
return False
return True
def close_tab(self, index):
widget = self.tab_widget.widget(index)
if isinstance(widget, EditorTab):
if not self.maybe_save_tab(widget):
return
self.tab_widget.removeTab(index)
if self.tab_widget.count() == 0:
self.new_tab()
self.save_session_state()
def closeEvent(self, event):
for i in range(self.tab_widget.count()):
widget = self.tab_widget.widget(i)
if isinstance(widget, EditorTab):
if not self.maybe_save_tab(widget):
event.ignore()
return
self.autosave_all()
event.accept()
def toggle_bold(self):
editor = self.current_editor()
if editor is None:
return
fmt = QTextCharFormat()
fmt.setFontWeight(QFont.Normal if editor.fontWeight() == QFont.Bold else QFont.Bold)
editor.mergeCurrentCharFormat(fmt)
def toggle_italic(self):
editor = self.current_editor()
if editor is None:
return
fmt = QTextCharFormat()
fmt.setFontItalic(not editor.fontItalic())
editor.mergeCurrentCharFormat(fmt)
def toggle_underline(self):
editor = self.current_editor()
if editor is None:
return
fmt = QTextCharFormat()
fmt.setFontUnderline(not editor.fontUnderline())
editor.mergeCurrentCharFormat(fmt)
def toggle_bullet_list(self):
editor = self.current_editor()
if editor is None:
return
cursor = editor.textCursor()
cursor.beginEditBlock()
current_list = cursor.currentList()
if current_list is not None:
current_list.remove(cursor.block())
else:
list_format = QTextListFormat()
list_format.setStyle(QTextListFormat.ListDisc)
cursor.createList(list_format)
cursor.endEditBlock()
def toggle_highlight(self):
editor = self.current_editor()
if editor is None:
return
cursor = editor.textCursor()
highlight_color = QColor(255, 255, 150)
fmt = QTextCharFormat()
if self.highlightAct.isChecked():
fmt.setBackground(highlight_color)
else:
fmt.clearBackground()
if cursor.hasSelection():
cursor.mergeCharFormat(fmt)
else:
editor.mergeCurrentCharFormat(fmt)
def clear_formatting(self):
editor = self.current_editor()
if editor is None:
return
cursor = editor.textCursor()
fmt = QTextCharFormat()
if cursor.hasSelection():
cursor.mergeCharFormat(fmt)
else:
editor.setCurrentCharFormat(fmt)
def on_font_size_changed(self, text: str):
editor = self.current_editor()
if editor is None:
return
try:
size = int(text)
except ValueError:
return
fmt = QTextCharFormat()
fmt.setFontPointSize(size)
editor.mergeCurrentCharFormat(fmt)
def on_style_changed(self, name: str):
editor = self.current_editor()
if editor is None:
return
cursor = editor.textCursor()
cursor.beginEditBlock()
block_fmt = cursor.blockFormat()
char_fmt = QTextCharFormat()
block_fmt.setLeftMargin(0)
if name == "Normal":
char_fmt.setFontPointSize(12)
char_fmt.setFontWeight(QFont.Normal)
char_fmt.setFontItalic(False)
char_fmt.clearBackground()
elif name == "Heading 1":
char_fmt.setFontPointSize(18)
char_fmt.setFontWeight(QFont.Bold)
elif name == "Heading 2":
char_fmt.setFontPointSize(16)
char_fmt.setFontWeight(QFont.DemiBold)
elif name == "Quote":
block_fmt.setLeftMargin(20)
char_fmt.setFontItalic(True)
elif name == "Code":
f = QFont("Consolas")
f.setPointSize(11)
char_fmt.setFont(f)
block_fmt.setLeftMargin(10)
char_fmt.setBackground(QColor(245, 245, 245))
cursor.setBlockFormat(block_fmt)
if cursor.hasSelection():
cursor.mergeCharFormat(char_fmt)
else:
editor.mergeCurrentCharFormat(char_fmt)
cursor.endEditBlock()
def insert_image_from_clipboard(self):
editor = self.current_editor()
if editor is None:
return
cb = QGuiApplication.clipboard()
image = cb.image()
if image.isNull():
QMessageBox.information(self, "Clipboard", "Clipboard does not contain an image.")
return
editor.insert_image(image)
def insert_image_from_file(self):
editor = self.current_editor()
if editor is None:
return
path, _ = QFileDialog.getOpenFileName(
self,
"Insert Image",
"",
"Images (*.png *.jpg *.jpeg *.bmp *.gif);;All Files (*)",
)
if not path:
return
image = QImage(path)
if image.isNull():
QMessageBox.warning(self, "Image", "Could not load the selected image.")
return
editor.insert_image(image)
def on_image_selected(self, selected: bool, width: int, height: int):
if self.image_width_spin is None:
return
self.image_width_spin.blockSignals(True)
if selected:
self.image_width_spin.setEnabled(True)
self.image_width_spin.setValue(max(16, min(width, 2000)))
else:
self.image_width_spin.setEnabled(False)
self.image_width_spin.blockSignals(False)
def on_image_width_changed(self, value: int):
editor = self.current_editor()
if editor is None:
return
editor.resize_selected_image(value)
def apply_theme(self):
app = QApplication.instance()
if app is None:
return
if self.dark_mode:
app.setStyle("Fusion")
palette = QPalette()
palette.setColor(QPalette.Window, QColor(37, 37, 38))
palette.setColor(QPalette.WindowText, QColor(220, 220, 220))
palette.setColor(QPalette.Base, QColor(30, 30, 30))
palette.setColor(QPalette.AlternateBase, QColor(45, 45, 48))
palette.setColor(QPalette.ToolTipBase, QColor(37, 37, 38))
palette.setColor(QPalette.ToolTipText, QColor(220, 220, 220))
palette.setColor(QPalette.Text, QColor(220, 220, 220))
palette.setColor(QPalette.Button, QColor(45, 45, 48))
palette.setColor(QPalette.ButtonText, QColor(220, 220, 220))
palette.setColor(QPalette.BrightText, Qt.red)
palette.setColor(QPalette.Highlight, QColor(0, 120, 215))
palette.setColor(QPalette.HighlightedText, Qt.white)
app.setPalette(palette)
else:
app.setPalette(app.style().standardPalette())
def toggle_dark_mode(self):
self.dark_mode = self.toggleThemeAct.isChecked()
self.settings.setValue("dark_mode", self.dark_mode)
self.apply_theme()
def apply_line_numbers_visibility(self):
for i in range(self.tab_widget.count()):
widget = self.tab_widget.widget(i)
if isinstance(widget, EditorTab):
widget.get_editor().set_line_numbers_visible(self.show_line_numbers)
def toggle_line_numbers(self):
self.show_line_numbers = self.toggleLineNumbersAct.isChecked()
self.settings.setValue("show_line_numbers", self.show_line_numbers)
self.apply_line_numbers_visibility()
def apply_word_wrap_to_editor(self, editor: QTextEdit):
if self.word_wrap_enabled:
editor.setLineWrapMode(QTextEdit.WidgetWidth)
else:
editor.setLineWrapMode(QTextEdit.NoWrap)
def apply_word_wrap(self):
for i in range(self.tab_widget.count()):
widget = self.tab_widget.widget(i)
if isinstance(widget, EditorTab):
self.apply_word_wrap_to_editor(widget.get_editor())
def toggle_word_wrap(self):
self.word_wrap_enabled = self.toggleWordWrapAct.isChecked()
self.settings.setValue("word_wrap", self.word_wrap_enabled)
self.apply_word_wrap()
def apply_spellcheck_to_all(self):
for i in range(self.tab_widget.count()):
widget = self.tab_widget.widget(i)
if isinstance(widget, EditorTab):
widget.get_editor().set_spellcheck_enabled(
self.spellcheck_enabled, self.spell_dict
)
def toggle_spellcheck(self):
self.spellcheck_enabled = self.toggleSpellcheckAct.isChecked()
self.settings.setValue("spellcheck_enabled", self.spellcheck_enabled)
self.apply_spellcheck_to_all()
def show_find_toolbar(self):
editor = self.current_editor()
if editor is not None:
selected = editor.textCursor().selectedText()
if selected:
self.findEdit.setText(selected)
self.findToolbar.show()
self.findEdit.setFocus()
self.findEdit.selectAll()
def hide_find_toolbar(self):
if self.findToolbar:
self.findToolbar.hide()
def find_next(self):
editor = self.current_editor()
if editor is None:
return
text = self.findEdit.text()
if not text:
return
if not editor.find(text):
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
editor.find(text)
def replace_once(self):
editor = self.current_editor()
if editor is None:
return
search = self.findEdit.text()
repl = self.replaceEdit.text()
if not search:
return
cursor = editor.textCursor()
if cursor.hasSelection() and cursor.selectedText() == search:
cursor.insertText(repl)
editor.setTextCursor(cursor)
self.find_next()
def replace_all(self):
editor = self.current_editor()
if editor is None:
return
search = self.findEdit.text()
repl = self.replaceEdit.text()
if not search:
return
cursor = editor.textCursor()
cursor.beginEditBlock()
cursor.movePosition(QTextCursor.Start)
editor.setTextCursor(cursor)
count = 0
while editor.find(search):
c = editor.textCursor()
if c.hasSelection():
c.insertText(repl)
editor.setTextCursor(c)
count += 1
cursor.endEditBlock()
QMessageBox.information(self, "Replace All", f"Replaced {count} occurrence(s).")
def export_as_pdf(self, path=None):
editor = self.current_editor()
if editor is None:
return
if not path:
tab = self.current_tab()
initial = (os.path.splitext(tab.file_path)[0] + ".pdf") if (tab and tab.file_path) else "note.pdf"
path, _ = QFileDialog.getSaveFileName(
self,
"Export as PDF",
initial,
"PDF files (*.pdf);;All Files (*)",
)
if not path:
return
printer = QPrinter(QPrinter.HighResolution)
printer.setOutputFormat(QPrinter.PdfFormat)
printer.setOutputFileName(path)
editor.document().print_(printer)
self.statusBar().showMessage(f"Exported to {path}", 5000)
def export_as_png(self, path=None, scale=2.0):
editor = self.current_editor()
if editor is None:
return
doc = editor.document()
doc.adjustSize()
size = doc.size()
if size.isEmpty():
QMessageBox.information(self, "Export as PNG", "Document is empty.")
return
width = int(size.width() * scale)
height = int(size.height() * scale)
max_dimension = 8000
width = min(width, max_dimension)
height = min(height, max_dimension)
if not path:
tab = self.current_tab()
initial = (os.path.splitext(tab.file_path)[0] + ".png") if (tab and tab.file_path) else "note.png"
path, _ = QFileDialog.getSaveFileName(
self,
"Export as PNG",
initial,
"PNG images (*.png);;All Files (*)",
)
if not path:
return
image = QImage(width, height, QImage.Format_ARGB32)
image.fill(Qt.white if not self.dark_mode else QColor(37, 37, 38))
painter = QPainter(image)
painter.scale(scale, scale)
doc.drawContents(painter)
painter.end()
image.save(path, "PNG")
self.statusBar().showMessage(f"Exported to {path}", 5000)
def start_autosave_timer(self):
self.autosave_timer = QTimer(self)
self.autosave_timer.setInterval(60000) # 60s
self.autosave_timer.timeout.connect(self.autosave_all)
self.autosave_timer.start()
def autosave_all(self):
for i in range(self.tab_widget.count()):
tab = self.tab_widget.widget(i)
if not isinstance(tab, EditorTab):
continue
editor = tab.get_editor()
if not editor.toPlainText().strip() and not tab.has_images():
continue
if tab.file_path:
if tab.is_modified:
try:
tab.save_to_file(tab.file_path)
self.snapshot_version(tab)
except Exception:
pass
else:
autosave_path = self.autosave_dir / f"tab_{i}.autosave.html"
try:
with open(autosave_path, "w", encoding="utf-8") as f:
f.write(editor.toHtml())
except Exception:
pass
self.save_session_state()
def save_session_state(self):
tabs_state = []
for i in range(self.tab_widget.count()):
tab = self.tab_widget.widget(i)
if not isinstance(tab, EditorTab):
continue
state = {"file_path": tab.file_path, "unsaved_autosave": None}
if tab.file_path is None:
autosave_path = self.autosave_dir / f"tab_{i}.autosave.html"
state["unsaved_autosave"] = str(autosave_path) if autosave_path.exists() else None
tabs_state.append(state)
state = {
"tabs": tabs_state,
"current_index": self.tab_widget.currentIndex(),
}
try:
self.settings.setValue("session_state", json.dumps(state))
except Exception:
pass
def restore_session_state(self):
raw = self.settings.value("session_state", "", type=str)
if not raw:
return False
try:
state = json.loads(raw)
except Exception:
return False
tabs = state.get("tabs") or []
if not tabs:
return False
for tab_state in tabs:
file_path = tab_state.get("file_path")
autosave_path = tab_state.get("unsaved_autosave")
if file_path and os.path.exists(file_path):
self.new_tab(file_path)
elif autosave_path and os.path.exists(autosave_path):
tab = EditorTab(self)
idx = self.tab_widget.addTab(tab, "Recovered")
self.tab_widget.setCurrentIndex(idx)
editor = tab.get_editor()
editor.cursorPositionChanged.connect(self.update_status_bar)
editor.textChanged.connect(self.update_status_bar)
editor.imageSelected.connect(self.on_image_selected)
editor.set_line_numbers_visible(self.show_line_numbers)
self.apply_word_wrap_to_editor(editor)
editor.set_spellcheck_enabled(self.spellcheck_enabled, self.spell_dict)
try:
with open(autosave_path, "r", encoding="utf-8") as f:
html = f.read()
editor.setHtml(html)
except Exception:
pass
tab.file_path = None
tab.is_modified = True
if self.tab_widget.count() == 0:
return False
cur = state.get("current_index", 0)
if 0 <= cur < self.tab_widget.count():
self.tab_widget.setCurrentIndex(cur)
self.update_tab_titles()
self.update_status_bar()
return True
def snapshot_version(self, tab: EditorTab):
if not tab.file_path:
return
try:
key = hashlib.sha1(os.path.abspath(tab.file_path).encode("utf-8")).hexdigest()
ver_dir = self.versions_dir / key
ver_dir.mkdir(parents=True, exist_ok=True)
editor = tab.get_editor()
html = editor.toHtml()
existing = sorted(ver_dir.glob("*.html"))
if existing:
last = existing[-1]
try:
with open(last, "r", encoding="utf-8") as f:
prev = f.read()
if prev == html:
return
except Exception:
pass
ts = int(time.time())
snap_path = ver_dir / f"{ts}.html"
with open(snap_path, "w", encoding="utf-8") as f:
f.write(html)
except Exception:
pass
def view_history_for_current(self):
tab = self.current_tab()
if tab is None or not tab.file_path:
QMessageBox.information(self, "History", "History is only available for saved notes.")
return
key = hashlib.sha1(os.path.abspath(tab.file_path).encode("utf-8")).hexdigest()
ver_dir = self.versions_dir / key
if not ver_dir.exists():
QMessageBox.information(self, "History", "No history snapshots found for this note.")
return
path, _ = QFileDialog.getOpenFileName(
self,
"Select Snapshot",
str(ver_dir),
"HTML files (*.html);;All Files (*)",
)
if not path:
return
snapshot_tab = EditorTab(self)
idx = self.tab_widget.addTab(snapshot_tab, "History")
self.tab_widget.setCurrentIndex(idx)
editor = snapshot_tab.get_editor()
editor.cursorPositionChanged.connect(self.update_status_bar)
editor.textChanged.connect(self.update_status_bar)
editor.imageSelected.connect(self.on_image_selected)
editor.set_line_numbers_visible(self.show_line_numbers)
self.apply_word_wrap_to_editor(editor)
editor.set_spellcheck_enabled(self.spellcheck_enabled, self.spell_dict)
try:
with open(path, "r", encoding="utf-8") as f:
html = f.read()
editor.setHtml(html)
except Exception:
pass
editor.setReadOnly(True)
snapshot_tab.file_path = None
snapshot_tab.is_modified = False
self.update_tab_titles()
def on_file_tree_double_clicked(self, index):
if not self.file_model or self.file_model.isDir(index):
return
path = self.file_model.filePath(index)
self.new_tab(path)
def toggle_file_tree(self):
if self.file_tree_dock:
visible = self.toggleFileTreeAct.isChecked()
self.file_tree_dock.setVisible(visible)
def on_file_tree_visibility_changed(self, visible: bool):
self.toggleFileTreeAct.setChecked(visible)
def toggle_terminal(self):
if self.terminal_dock:
visible = self.toggleTerminalAct.isChecked()
self.terminal_dock.setVisible(visible)
def on_terminal_visibility_changed(self, visible: bool):
self.toggleTerminalAct.setChecked(visible)
def choose_notes_root(self):
path = QFileDialog.getExistingDirectory(
self,
"Select Notes Root",
str(self.notes_root or Path.home()),
)
if not path:
return
self.notes_root = Path(path)
self.settings.setValue("notes_root", str(self.notes_root))
if self.file_model and self.file_tree_dock:
self.file_model.setRootPath(str(self.notes_root))
tree = self.file_tree_dock.widget()
tree.setRootIndex(self.file_model.index(str(self.notes_root)))
if self.terminal_widget:
self.terminal_widget.current_dir = self.notes_root
def on_current_tab_changed(self, index: int):
if self.image_width_spin is not None:
self.image_width_spin.blockSignals(True)
self.image_width_spin.setEnabled(False)
self.image_width_spin.blockSignals(False)
self.update_status_bar()
def update_status_bar(self):
editor = self.current_editor()
if editor is None:
self.statusBar().clearMessage()
return
cursor = editor.textCursor()
line = cursor.blockNumber() + 1
col = cursor.positionInBlock() + 1
self.statusBar().showMessage(f"Line {line}, Column {col}")
def main():
app = QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()