""" Markdown Editor Application using PyQt6. Key Features: - Create, edit, and preview Markdown files. - Live HTML preview of Markdown content. - Configurable settings stored in YAML. Dependencies: PyQt6, yaml, markdown2. Author: Isaak Buslovich Date: 2024-01-13 Version: 0.1 """ import pathlib import platform import sys import webbrowser import yaml from PyQt6.QtCore import QFileSystemWatcher, Qt from PyQt6.QtGui import QAction, QStandardItem, QStandardItemModel, QIcon, QPalette from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebEngineCore import QWebEngineSettings from PyQt6.QtWidgets import QApplication, QMainWindow, QTextEdit, QHBoxLayout, QWidget, QToolBar, QStatusBar, \ QMessageBox, QTreeView, QFileDialog, QAbstractItemView, QLineEdit, QVBoxLayout from markdown2 import markdown from fuzzywuzzy import process, fuzz class MarkdownEditor(QMainWindow): """ A PyQt6-based Markdown editor with file navigation, text editing, and live HTML preview. Attributes: config_path (pathlib.Path): Configuration file path. config (dict): Application configuration. fileSystemWatcher (QFileSystemWatcher): Monitors file system changes. dataDirectory (pathlib.Path): Data directory path. directoryTree (QTreeView): File navigation widget. editor (QTextEdit): Markdown text editor. preview (QWebEngineView): Live HTML preview widget. """ def __init__(self): """ Initializes the Markdown editor application with UI setup and configuration management. """ super().__init__() self.dataDirectory = None self.preview = None self.editor = None self.directoryTree = None self.config_path = pathlib.Path(__file__).parent / 'config.yaml' self.toolbar = None if not self.config_path.exists(): self.config = {'window': {'width': 800, 'height': 600}, 'dataDirectory': str(pathlib.Path(__file__).parent.resolve())} self.save_config() else: self.config = self.load_config() self.fileSystemWatcher = QFileSystemWatcher() self.initialize_data_directory() self.initialize_ui() self.update_directory_tree_view() self.fileSystemWatcher.directoryChanged.connect(self.update_directory_tree_view) self.load_initial_file() def initialize_ui(self): """ Sets up the application's user interface components. """ self.setWindowTitle("Markdown Editor") width = self.config['window']['width'] height = self.config['window']['height'] self.setGeometry(100, 100, width, height) icon_path = self.dataDirectory / 'data' / 'smile.png' self.setWindowIcon(QIcon(str(icon_path))) # Create the directory tree view self.directoryTree = QTreeView() self.directoryTree.setHeaderHidden(True) self.directoryTree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.directoryTree.doubleClicked.connect(self.on_file_double_clicked) # Create the QLineEdit widget for the search box self.searchBox = QLineEdit() self.searchBox.setPlaceholderText("Search...") # Placeholder text for the search box self.searchBox.textChanged.connect(self.on_search_text_changed) # Connect to search handler # Create a QVBoxLayout for the directory tree and search box directory_layout = QVBoxLayout() directory_layout.addWidget(self.searchBox) # Add the search box to the layout directory_layout.addWidget(self.directoryTree) # Add the directory tree below the search box # Text editor setup self.editor = QTextEdit() self.editor.textChanged.connect(self.update_preview) # Preview setup self.preview = QWebEngineView() web_engine_settings = self.preview.page().settings() web_engine_settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True) web_engine_settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True) # Main layout setup layout = QHBoxLayout() layout.addLayout(directory_layout, 0) # Add the QVBoxLayout with the search box and directory tree layout.addWidget(self.editor, 1) layout.addWidget(self.preview, 1) # Set the main layout container = QWidget() container.setLayout(layout) self.setCentralWidget(container) # Toolbar, menu bar, and status bar setup self.create_tool_bar() self.create_menu_bar() self.setStatusBar(QStatusBar(self)) def initialize_data_directory(self): """ Initializes the data directory and sets up the file system watcher. """ data_directory = self.config['dataDirectory'] self.dataDirectory = pathlib.Path(data_directory).resolve() if not self.dataDirectory.is_dir(): QMessageBox.critical(self, "Directory Error", "The application's directory is not valid.") sys.exit(1) self.fileSystemWatcher.addPath(str(self.dataDirectory)) def on_search_text_changed(self, text): """ Handles the search functionality. First, tries an exact match. If no results are found, uses fuzzy matching and gradually increases fuzziness. """ if not self.dataDirectory or not self.dataDirectory.is_dir(): return model = QStandardItemModel() self.directoryTree.setModel(model) try: entries = list(self.dataDirectory.rglob('*')) filtered_entries = [] if text: filtered_entries = [entry for entry in entries if text.lower() in entry.name.lower()] fuzziness = 100 while not filtered_entries and fuzziness > 0: matches = [(entry, fuzz.partial_ratio(text.lower(), entry.name.lower())) for entry in entries] filtered_entries = [entry for entry, score in matches if score >= fuzziness] if len(filtered_entries) >= 2: break fuzziness -= 5 for entry in filtered_entries: self.add_item_to_model(model, entry, is_dir=entry.is_dir()) except Exception as e: QMessageBox.critical(self, "Search Error", f"An error occurred during the search: {e}") def add_item_to_model(self, model, entry, is_dir=False): """ Adds an item to the directory tree model. Args: model (QStandardItemModel): The model of the directory tree. entry (Path): The file or directory path. is_dir (bool): Indicates if the entry is a directory. """ item = QStandardItem(entry.name) item.setData(str(entry)) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEnabled) if is_dir: item.setIcon(QIcon('icons/folder_icon.png')) model.appendRow(item) @staticmethod def get_config_path(): """ Get the path to the configuration file based on the operating system. Returns: Path: The path to the configuration file. """ app_name = "YourApp" home = pathlib.Path.home() if platform.system() == "Windows": config_path = pathlib.Path(home, "AppData", "Roaming", app_name, "config.yaml") elif platform.system() == "Darwin": config_path = pathlib.Path(home, "Library", "Application Support", app_name, "config.yaml") else: config_path = pathlib.Path(home, ".config", app_name, "config.yaml") config_path.parent.mkdir(parents=True, exist_ok=True) return config_path config_file = get_config_path() def closeEvent(self, event): """ Handle the close event of the application. Save the configuration before closing. Args: event (QCloseEvent): The close event. """ self.config['window']['width'] = self.size().width() self.config['window']['height'] = self.size().height() self.config['dataDirectory'] = str(self.dataDirectory) self.save_config() super().closeEvent(event) def load_config(self): """ Load the configuration from the configuration file. Returns: dict: The loaded configuration. """ with open(self.config_path) as file: return yaml.safe_load(file) def save_config(self): """ Save the configuration to the configuration file. """ with open(self.config_path, 'w') as file: yaml.dump(self.config, file) def load_initial_file(self): """ Load an initial Markdown file for editing. This method looks for a 'cheatsheet.md' file in the application's data directory and loads it into the editor if it exists. """ cheatsheet_path = self.dataDirectory / 'cheatsheet.md' if cheatsheet_path.is_file(): with open(cheatsheet_path, encoding='utf-8') as file: markdown_content = file.read() self.editor.setText(markdown_content) self.update_preview() else: QMessageBox.warning(self, "File Not Found", f"Cheatsheet file not found: {cheatsheet_path}") def on_file_double_clicked(self, index): """ Loads a Markdown file into the editor on double-click in the directory tree. Args: index (QModelIndex): Index of the clicked item in the directory tree. """ try: file_path = pathlib.Path(index.model().itemFromIndex(index).data()) if not file_path.exists(): QMessageBox.critical(self, "Error", f"The item does not exist: {file_path}") return if not file_path.is_file(): QMessageBox.critical(self, "Error", "The selected item is not a file.") return with open(file_path, encoding='utf-8') as file: markdown_content = file.read() self.editor.setText(markdown_content) self.update_preview() except Exception as e: QMessageBox.critical(self, "File Open Error", f"An error occurred: {e}") def create_menu_bar(self): """ Create the menu bar for the application. It adds various menus like File, Edit, View, Window, and Help with their respective actions. """ menu_bar = self.menuBar() file_menu = menu_bar.addMenu("&File") self.add_actions_to_menu(file_menu, [ ("&New", self.new_file), ("&Open", self.open_file), ("&Save", self.save_entry), ("Open Data Directory", self.open_data_directory), ("Change Data Directory", self.change_data_directory), ("Theme", self.change_theme), ("Settings", self.open_settings), ("E&xit", self.close) ]) edit_menu = menu_bar.addMenu("&Edit") self.add_actions_to_menu(edit_menu, [ ("Undo", self.undo_action), ("Redo", self.redo_action) ]) view_menu = menu_bar.addMenu("&View") self.add_actions_to_menu(view_menu, [ ("Toggle Split View Mode", self.toggle_split_view), ("Toggle Toolbar", self.toggle_toolbar) ]) window_menu = menu_bar.addMenu("&Window") self.add_actions_to_menu(window_menu, [ ("Close", self.close), ("Minimize", self.minimize), ("Search", self.search) ]) help_menu = menu_bar.addMenu("&Help") self.add_actions_to_menu(help_menu, [ ("&About", self.about), ("View Cheatsheet", self.view_cheatsheet), ("GitHub", self.open_git_hub) ]) def add_actions_to_menu(self, menu, actions): """ Add actions to a given menu. Args: menu (QMenu): The menu to which actions will be added. actions (list of tuples): Each tuple contains the text for the menu item and the handler function. """ for action_text, action_handler in actions: action = QAction(action_text, self) action.triggered.connect(action_handler) menu.addAction(action) def create_tool_bar(self): """ Create the main toolbar for the application. It adds actions like New, Open, and Save with icons. """ self.toolbar = QToolBar("Main Toolbar") self.addToolBar(self.toolbar) self.add_actions_to_toolbar(self.toolbar, [ ("New", "📄", self.new_file), ("Open", "📂", self.open_file), ("Save", "💾", self.save_entry) ]) def add_actions_to_toolbar(self, toolbar, actions): """ Add actions to a given toolbar. """ for name, emoji, handler in actions: action = QAction(emoji + ' ' + name, self) action.triggered.connect(handler) toolbar.addAction(action) @staticmethod def markdown_table_to_html(markdown_content): """ Convert Markdown tables in the given content to HTML table format. Args: markdown_content (str): The Markdown content containing tables. Returns: str: The HTML content with Markdown tables converted to HTML tables. """ lines = markdown_content.split('\n') html_lines = [] in_table = False for line in lines: if '|' in line: if not in_table: html_lines.append('
| {} | '.format(col.strip()) for col in columns) + '
|---|
| {} | '.format(col.strip()) for col in columns) + '