""" 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 logging import pathlib import platform import sys import threading import webbrowser import yaml from PyQt6.QtCore import QFileSystemWatcher, Qt, QTimer, QSize, QRegularExpression from PyQt6.QtGui import QAction, QStandardItem, QStandardItemModel, QIcon, QPalette, QSyntaxHighlighter, \ QTextCharFormat, QColor, QFont, QPainter, QPixmap, QFontDatabase from PyQt6.QtWebEngineCore import QWebEngineSettings from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import QApplication, QMainWindow, QTextEdit, QWidget, QToolBar, QStatusBar, \ QMessageBox, QTreeView, QFileDialog, QAbstractItemView, QLineEdit, QVBoxLayout, QStyle, QTabWidget, QLabel, \ QSplitter from fuzzywuzzy import fuzz from markdown2 import markdown logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') MATERIAL_ICONS_PATH = "data/material-icons.ttf" MATERIAL_ICONS = { "New": "\ue862", "Open": "\ue2c7", "Save": "\ue161", "Undo": "\ue166", "Redo": "\ue15a", "Settings": "\ue8b8", "Exit": "\ue879", "Search": "\ue8b6", "Help": "\ue8fd", "About": "\ue88e", "Folder": "\ue2c7", "File": "\ue24d", "Close": "\ue5cd", "View": "\ue8f4", "GitHub": "\ue86f", "Minimize": "\ue921", } ICON_SIZE = 42 ICON_COLOR = "#ccbbca" DEFAULT_WINDOW_SIZE = (900, 700) def load_material_icons(): font_id = QFontDatabase.addApplicationFont(MATERIAL_ICONS_PATH) if font_id == -1: raise RuntimeError("Failed to load Material Icons font.") return QFontDatabase.applicationFontFamilies(font_id)[0] 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): super().__init__() self.config_path = pathlib.Path(__file__).parent / 'config.yaml' self.config = self.load_config() self.setup_ui() self.initialize_editor_preview() QTimer.singleShot(0, self.post_init) def initialize_editor_preview(self): """ Initialize the editor and preview widgets """ self.editor = QTextEdit() self.editor.textChanged.connect(self.update_word_count) self.editor.textChanged.connect(self.update_preview) self.highlighter = MarkdownHighlighter(self.editor.document()) 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) def post_init(self): """Initializes the Markdown editor application with UI setup and configuration management.""" try: logging.info("Initializing the Markdown Editor...") 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() self.searchTimer = QTimer() self.searchTimer.setSingleShot(True) self.searchTimer.timeout.connect(self.perform_search) except Exception as e: logging.error(f"Error during post initialization: {e}", exc_info=True) QMessageBox.critical(self, "Initialization Error", f"An error occurred during initialization: {e}") sys.exit(1) def setup_ui(self): """ Sets up the minimal UI components necessary for initial display. """ self.setWindowTitle("Markdown Editor") width = self.config.get('window', {}).get('width', 800) height = self.config.get('window', {}).get('height', 600) self.setGeometry(100, 100, width, height) central_widget = QWidget() layout = QVBoxLayout(central_widget) layout.setContentsMargins(5, 5, 5, 5) placeholder_label = QLabel("Loading...", alignment=Qt.AlignmentFlag.AlignCenter) layout.addWidget(placeholder_label) central_widget.setLayout(layout) self.setCentralWidget(central_widget) self.setStatusBar(QStatusBar(self)) def initialize_ui(self): """ Sets up the application's user interface components, now with a ribbon interface. """ logging.info("Setting up the user interface with a ribbon...") 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))) central_widget = QWidget() layout = QVBoxLayout(central_widget) layout.setContentsMargins(5, 5, 5, 5) layout.addWidget(self.create_ribbon_interface()) self.directoryTree = QTreeView() self.directoryTree.setHeaderHidden(True) self.directoryTree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.directoryTree.doubleClicked.connect(self.on_file_double_clicked) self.searchBox = QLineEdit() self.searchBox.setPlaceholderText("Search...") self.searchBox.textChanged.connect(self.on_search_text_changed) directory_layout = QVBoxLayout() directory_layout.addWidget(self.searchBox) directory_layout.addWidget(self.directoryTree) directory_widget = QWidget() directory_widget.setLayout(directory_layout) splitter_horizontal = QSplitter(Qt.Orientation.Horizontal) splitter_vertical = QSplitter(Qt.Orientation.Vertical) splitter_horizontal.addWidget(directory_widget) splitter_horizontal.addWidget(self.editor) splitter_vertical.addWidget(splitter_horizontal) splitter_vertical.addWidget(self.preview) layout.addWidget(splitter_vertical) central_widget.setLayout(layout) self.setCentralWidget(central_widget) self.setStatusBar(QStatusBar(self)) def get_material_icon(self, unicode, size=ICON_SIZE, color=ICON_COLOR): """Create a QIcon object from a Material Icon. Args: unicode (str): The unicode character for the icon. size (int, optional): Pixel size of the icon. Defaults to ICON_SIZE. color (str, optional): Color of the icon. Defaults to ICON_COLOR. Returns: QIcon: The generated icon object. """ font_family = load_material_icons() font = QFont(font_family) font.setPixelSize(int(size)) pixmap = QPixmap(size, size) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setFont(font) painter.setPen(QColor(color)) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, unicode) painter.end() return QIcon(pixmap) def initialize_data_directory(self): """ Initializes the data directory and sets up the file system watcher. """ logging.info("Initializing data directory.") try: data_directory = self.config.get('dataDirectory', str(pathlib.Path.home())) self.dataDirectory = pathlib.Path(data_directory).resolve() if not self.dataDirectory.is_dir(): raise FileNotFoundError(f"Data directory not found: {self.dataDirectory}") self.fileSystemWatcher.addPath(str(self.dataDirectory)) logging.info(f"Data directory set to: {self.dataDirectory}") except Exception as e: logging.error(f"Failed to initialize data directory: {e}") QMessageBox.critical(self, "Directory Error", str(e)) sys.exit(1) def create_ribbon_interface(self): """ Creates the ribbon interface with tabs for File, Edit, View, Window, and Help. """ ribbon = QTabWidget() ribbon.setTabPosition(QTabWidget.TabPosition.North) ribbon.addTab(self.create_file_tab(), "File") ribbon.addTab(self.create_edit_tab(), "Edit") ribbon.addTab(self.create_view_tab(), "View") ribbon.addTab(self.create_window_tab(), "Window") ribbon.addTab(self.create_help_tab(), "Help") ribbon.setMaximumHeight(ribbon.sizeHint().height()) return ribbon def create_file_tab(self): """ Creates the File tab for the ribbon. """ toolbar = QToolBar() toolbar.setIconSize(QSize(16, 16)) toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) toolbar.addAction(self.create_action("New", MATERIAL_ICONS["New"], self.new_file)) toolbar.addAction(self.create_action("Open", MATERIAL_ICONS["Open"], self.open_file)) toolbar.addAction(self.create_action("Save", MATERIAL_ICONS["Save"], self.save_entry)) toolbar.addAction(self.create_action("Open Data Directory", MATERIAL_ICONS["Folder"], self.open_data_directory)) toolbar.addAction( self.create_action("Change Data Directory", MATERIAL_ICONS["Folder"], self.change_data_directory)) toolbar.addAction(self.create_action("Settings", MATERIAL_ICONS["Settings"], self.open_settings)) toolbar.addAction(self.create_action("Exit", MATERIAL_ICONS["Exit"], self.close)) return toolbar def create_edit_tab(self): """ Creates the Edit tab for the ribbon. """ toolbar = QToolBar() toolbar.setIconSize(QSize(16, 16)) toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) toolbar.addAction(self.create_action("Undo", MATERIAL_ICONS["Undo"], self.undo_action)) toolbar.addAction(self.create_action("Redo", MATERIAL_ICONS["Redo"], self.redo_action)) return toolbar def create_view_tab(self): """ Creates the View tab for the ribbon. """ toolbar = QToolBar() toolbar.setIconSize(QSize(16, 16)) toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) toolbar.addAction(self.create_action("Toggle Split View", MATERIAL_ICONS["View"], self.toggle_split_view)) return toolbar def create_window_tab(self): """ Creates the Window tab for the ribbon. """ toolbar = QToolBar() toolbar.setIconSize(QSize(16, 16)) toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) toolbar.addAction(self.create_action("Close", MATERIAL_ICONS["Close"], self.close)) toolbar.addAction(self.create_action("Minimize", MATERIAL_ICONS["Minimize"], self.minimize)) toolbar.addAction(self.create_action("Search", MATERIAL_ICONS["Search"], self.search)) return toolbar def create_help_tab(self): """ Creates the Help tab for the ribbon. """ toolbar = QToolBar() toolbar.setIconSize(QSize(16, 16)) toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) toolbar.addAction(self.create_action("About", MATERIAL_ICONS["About"], self.about)) toolbar.addAction(self.create_action("View Cheatsheet", MATERIAL_ICONS["Help"], self.view_cheatsheet)) toolbar.addAction( self.create_action("GitHub", MATERIAL_ICONS["GitHub"], self.open_git_hub)) return toolbar def create_action(self, text, icon_code, callback): """ Creates an action for the toolbar in the ribbon interface. """ action = QAction(self.get_material_icon(icon_code), text, self) action.triggered.connect(callback) return action def on_search_text_changed(self, text): self.searchText = text if not text: self.update_directory_tree_view() else: self.searchTimer.start(60) def update_search_results(self, text): model = QStandardItemModel() self.populate_filtered_model(model, self.dataDirectory, text) self.directoryTree.setModel(model) def perform_search(self): threading.Thread(target=self.update_search_results, args=(self.searchText,)).start() def populate_filtered_model(self, parent_item, directory, text, fuzziness=100, depth=0): """ Recursively populates the directory tree model with filtered contents based on the search text. Args: parent_item (QStandardItem): The parent item in the tree view model. directory (pathlib.Path): The directory to scan. text (str): The search text. fuzziness (int): The fuzziness level for matching file names. depth (int): Current depth of recursion. """ if depth > self.depth: return False dir_added = False for entry in sorted(directory.iterdir(), key=lambda e: (e.is_file(), e.name.lower())): if entry.is_dir(): dir_item = QStandardItem(entry.name) if self.populate_filtered_model(dir_item, entry, text, fuzziness, depth + 1): parent_item.appendRow(dir_item) dir_added = True elif entry.suffix == '.md' and self.is_match(entry.name, text, fuzziness): file_item = QStandardItem(entry.name) file_item.setData(str(entry)) parent_item.appendRow(file_item) dir_added = True return dir_added def update_word_count(self): text = self.editor.toPlainText() word_count = len(text.split()) self.statusBar().showMessage(f"Word Count: {word_count}") @staticmethod def is_match(filename, text, fuzziness): if not text: return False if fuzziness == 100: return text.lower() in filename.lower() return fuzz.partial_ratio(text.lower(), filename.lower()) >= fuzziness @staticmethod def add_item_to_model(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 default values if keys are missing. """ default_config = { 'window': {'width': 800, 'height': 600}, 'dataDirectory': str(pathlib.Path(__file__).parent.resolve()), 'depth': 5 } try: with open(self.config_path) as file: config = yaml.safe_load(file) or {} except FileNotFoundError: config = {} for key, value in default_config.items(): config.setdefault(key, value) self.depth = config['depth'] return config 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 add_actions_to_menu(self, menu, actions): for action_text, action_func in actions: if isinstance(action_func, list): submenu = menu.addMenu(action_text) for sub_action_text, sub_action_func in action_func: sub_action = QAction(sub_action_text, self) sub_action.triggered.connect(sub_action_func) submenu.addAction(sub_action) else: action = QAction(action_text, self) action.triggered.connect(action_func) menu.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) + '