""" 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('') in_table = True columns = line.split('|')[1:-1] if '---' in columns[0]: html_lines.append('' + ''.join(''.format(col.strip()) for col in columns) + '') else: html_lines.append('' + ''.join(''.format(col.strip()) for col in columns) + '') else: if in_table: html_lines.append('
{}
{}
') in_table = False html_lines.append(line) if in_table: html_lines.append('') return '\n'.join(html_lines) @staticmethod def convert_image_paths(html_content, base_directory): """ Converts relative image paths in the HTML content to absolute file URLs. Args: html_content (str): The HTML content with Markdown images. base_directory (Path): The base directory where the 'data' folder is located. Returns: str: Modified HTML content with absolute image paths. """ image_folder = base_directory / 'data' def replace_path(match): """ Replaces a matched image path with an absolute URL. Args: match (MatchObject): Regex match object containing the image path. Returns: str: The image URL with the absolute path. """ image_relative_path = match.group(1) image_absolute_path = image_folder / image_relative_path return f'src="{image_absolute_path.as_uri()}"' import re html_content = re.sub(r'src="([^"]+)"', replace_path, html_content) return html_content def update_preview(self): """ Updates the HTML preview based on the current Markdown content. """ markdown_content = self.editor.toPlainText() html_content = markdown(markdown_content) html_content = self.markdown_table_to_html(html_content) data_directory = pathlib.Path(__file__).parent.resolve() html_content = self.convert_image_paths(html_content, data_directory) html_content = html_content.replace('[x]', '✅') html_content = html_content.replace('[ ]', '❌') palette = QApplication.instance().palette() background_color = palette.color(QPalette.ColorRole.Window).name() foreground_color = palette.color(QPalette.ColorRole.WindowText).name() accent_color = "#9f74b3" css = f""" """ html_with_css = css + '
' + html_content + '
' self.preview.setHtml(html_with_css) def update_directory_tree_view(self): """ Refreshes the directory tree view with files and directories. """ if not self.dataDirectory or not self.dataDirectory.is_dir(): return model = QStandardItemModel() self.directoryTree.setModel(model) self.populate_model(model, self.dataDirectory) def populate_model(self, parent_item, directory, depth=0): """ Populates the directory tree model with directory contents. Args: parent_item (QStandardItem): Item to populate. directory (pathlib.Path): Directory to scan. depth (int): Depth level for recursive scanning. """ if depth > 5: return try: for entry in sorted(directory.iterdir(), key=lambda e: (e.is_file(), e.name.lower())): if entry.is_dir(): if any(file.suffix == '.md' for file in entry.glob('*.md')): dir_item = QStandardItem(entry.name) dir_item.setData(str(entry)) dir_item.setFlags(dir_item.flags() | Qt.ItemFlag.ItemIsEnabled) dir_item.setIcon(QIcon('icons/folder_icon.png')) parent_item.appendRow(dir_item) self.populate_model(dir_item, entry, depth + 1) elif entry.suffix == '.md': file_item = QStandardItem(entry.name) file_item.setData(str(entry)) parent_item.appendRow(file_item) except PermissionError: pass def on_directory_tree_clicked(self, index): """ Handle clicks on the directory tree. It loads the selected file into the editor if it is a Markdown file. Args: index (QModelIndex): The model index of the clicked item in the directory tree. """ file_path = pathlib.Path(index.data()) if file_path.is_file(): self.load_file(file_path) def load_file(self, file_path): """ Loads the content of a file into the text editor. Args: file_path (pathlib.Path): The path of the file to load. """ if not file_path.is_file(): QMessageBox.critical(self, "File Open Error", "The selected file does not exist.") return try: with open(file_path, encoding='utf-8') as file: markdown_content = file.read() except OSError as e: QMessageBox.critical(self, "File Open Error", f"Error opening file: {e}") return self.editor.setText(markdown_content) self.update_preview() def new_file(self): """ Clear the editor to create a new file. This action empties the text editor for writing new content. """ self.editor.clear() def open_file(self): """ Open a file dialog to select a Markdown file. This method allows the user to select a Markdown file from the file system and loads its content into the editor. """ filename, _ = QFileDialog.getOpenFileName( self, "Open Markdown File", str(self.dataDirectory), 'Markdown Files (*.md)') if not filename: return try: with open(filename, encoding='utf-8') as file: markdown_content = file.read() except OSError as e: QMessageBox.critical(self, "File Open Error", f"Error opening file: {e}") return self.editor.setText(markdown_content) self.update_preview() def save_entry(self): """ Save the current entry. This method is intended to save the content of the text editor to a file. """ pass def about(self): """ Displays an 'About' dialog with information about the Markdown Editor. """ QMessageBox.about(self, "About Markdown Editor", "Markdown Editor\nVersion 1.0") def open_data_directory(self): """ Opens the application's data directory in the system's default file explorer. """ try: directory_url = pathlib.Path(self.dataDirectory).resolve().as_uri() webbrowser.open(directory_url) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to open directory: {e}") def change_data_directory(self): """ Opens a dialog to select a new data directory for the application. """ new_data_directory = QFileDialog.getExistingDirectory(self, "Select Data Directory", str(self.dataDirectory)) if new_data_directory: new_path = pathlib.Path(new_data_directory) if new_path.is_dir(): self.dataDirectory = new_path self.update_directory_tree_view() else: QMessageBox.critical(self, "Directory Selection Error", "Invalid directory path") def change_theme(self): pass def open_settings(self): pass def undo_action(self): pass def redo_action(self): pass def toggle_split_view(self): """ Toggles the visibility of the preview pane. """ self.preview.setVisible(not self.preview.isVisible()) self.centralWidget().layout().activate() def toggle_toolbar(self): """ Toggle the visibility of the toolbar. """ if self.toolbar: self.toolbar.setVisible(not self.toolbar.isVisible()) def minimize(self): """ Minimize the main window. """ self.showMinimized() def search(self): pass def view_cheatsheet(self): """ Load and display the cheatsheet markdown file. """ cheatsheet_path = self.dataDirectory / 'cheatsheet.md' if cheatsheet_path.is_file(): self.load_file(cheatsheet_path) else: QMessageBox.warning(self, "Cheatsheet Not Found", f"Cheatsheet file not found: {cheatsheet_path}") def open_git_hub(self): pass def main(): """ Main function to run the Markdown Editor application. """ app = QApplication(sys.argv) editor = MarkdownEditor() editor.show() sys.exit(app.exec()) if __name__ == '__main__': main()