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.

Version 1.0.0 Updated Sep 01, 2025 Size ~60 MB Portable – no installer required

Why you’ll like it

Tabbed workspace

Work on multiple notes and documents at once with a clean, tabbed interface.

Built-in templates

Start fast with templates for meetings, job postings, video scripts, and K–12 lessons.

Rich text + images

Paste or drag-and-drop images directly into your notes, then select and resize them inline.

Spellcheck (optional)

If enchant is installed, misspelled words are underlined so you can clean drafts quickly.

Line numbers when you want them

Toggle line numbers for more structured notes, scripts, or lesson plans.

Project-friendly layout

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

1
Run the app

Download the .exe and launch it. Settings live in your user profile, not the registry.

2
Create or open notes

Use tabs for different projects. Start from a template or open existing files from disk.

3
Write, drop images, and organize

Type, format, and insert images. Enable spellcheck and line numbers when you want more structure.

4
Save and print

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.

Download options

Portable EXE
Version 1.0.0 Updated 2025-09-01 Size ~60 MB

Template types included

Meeting Notes

Agenda, attendees, notes, and action items in a repeatable layout.

Job Posting Draft

Sections for summary, responsibilities, requirements, benefits, and how to apply.

Video Script Outline

Hook, intro, main points, and call-to-action sections for YouTube-style videos.

K–12 Lesson Plan

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
Want more utilities like this? Explore additional free tools on the home page.

← Back to Home

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()