852 lines
33 KiB
Python
852 lines
33 KiB
Python
"""
|
|
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('<table>')
|
|
in_table = True
|
|
columns = line.split('|')[1:-1]
|
|
if '---' in columns[0]:
|
|
html_lines.append('<tr>' + ''.join('<th>{}</th>'.format(col.strip()) for col in columns) + '</tr>')
|
|
else:
|
|
html_lines.append('<tr>' + ''.join('<td>{}</td>'.format(col.strip()) for col in columns) + '</tr>')
|
|
else:
|
|
if in_table:
|
|
html_lines.append('</table>')
|
|
in_table = False
|
|
html_lines.append(line)
|
|
|
|
if in_table:
|
|
html_lines.append('</table>')
|
|
|
|
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"""
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Lora&display=swap');
|
|
/* Main body styling */
|
|
body {{
|
|
font-family: 'Lora', serif;
|
|
background-color: {background_color};
|
|
color: {foreground_color};
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
justify-content: center;
|
|
}}
|
|
/* Container for the content */
|
|
.content-container {{
|
|
max-width: 600px;
|
|
width: 95%;
|
|
margin: 20px;
|
|
}}
|
|
/* Headers styling */
|
|
h1, h2, h3, h4, h5, h6 {{
|
|
color: {accent_color};
|
|
}}
|
|
/* Style for scrollbar */
|
|
::-webkit-scrollbar {{
|
|
width: 12px;
|
|
}}
|
|
::-webkit-scrollbar-track {{
|
|
background: {background_color};
|
|
}}
|
|
::-webkit-scrollbar-thumb {{
|
|
background: #555;
|
|
border-radius: 6px;
|
|
}}
|
|
::-webkit-scrollbar-thumb:hover {{
|
|
background: {accent_color};
|
|
}}
|
|
/* Table styling */
|
|
table {{
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}}
|
|
th, td {{
|
|
border: 1px solid {foreground_color};
|
|
padding: 8px;
|
|
text-align: left;
|
|
background-color: {background_color};
|
|
color: {foreground_color};
|
|
}}
|
|
th {{
|
|
background-color: {accent_color};
|
|
color: white;
|
|
}}
|
|
tr:nth-child(even) {{
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
}}
|
|
tr:hover {{
|
|
background-color: rgba(255, 255, 255, 0.2);
|
|
}}
|
|
</style>
|
|
"""
|
|
html_with_css = css + '<div class="content-container">' + html_content + '</div>'
|
|
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.
|
|
Shows all directories containing Markdown files and Markdown files themselves.
|
|
|
|
Args:
|
|
parent_item (QStandardItem): Item to populate.
|
|
directory (pathlib.Path): Directory to scan.
|
|
depth (int): Depth level for recursive scanning.
|
|
"""
|
|
if depth > self.depth:
|
|
return
|
|
try:
|
|
directory_entries = sorted(directory.iterdir(),
|
|
key=lambda dir_entry: (dir_entry.is_file(), dir_entry.name.lower()))
|
|
for entry in directory_entries:
|
|
if entry.is_dir():
|
|
self.process_directory(entry, parent_item, depth)
|
|
elif entry.suffix == '.md':
|
|
self.add_file_item(entry, parent_item)
|
|
except PermissionError as permission_error:
|
|
logging.error(f"Permission error accessing {directory}: {permission_error}")
|
|
|
|
@staticmethod
|
|
def add_file_item(file_path, parent_item):
|
|
"""
|
|
Adds a file item to the model.
|
|
|
|
Args:
|
|
file_path (pathlib.Path): The file path to add.
|
|
parent_item (QStandardItem): The parent item in the model.
|
|
"""
|
|
file_item = QStandardItem(file_path.name)
|
|
file_item.setData(str(file_path))
|
|
parent_item.appendRow(file_item)
|
|
|
|
def process_directory(self, directory, parent_item, depth):
|
|
"""
|
|
Processes a single directory, adding it to the model if it contains Markdown files.
|
|
|
|
Args:
|
|
directory (pathlib.Path): The directory to process.
|
|
parent_item (QStandardItem): The parent item in the model.
|
|
depth (int): Current depth in the directory tree.
|
|
"""
|
|
if any(file.suffix == '.md' for file in directory.iterdir()):
|
|
dir_item = QStandardItem(directory.name)
|
|
dir_item.setData(str(directory))
|
|
dir_item.setIcon(QIcon('icons/folder_icon.png'))
|
|
parent_item.appendRow(dir_item)
|
|
self.populate_model(dir_item, directory, depth + 1)
|
|
|
|
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 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 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
|
|
|
|
|
|
class SyntaxColors:
|
|
COMMENT = "#6272a4"
|
|
CYAN = "#8be9fd"
|
|
GREEN = "#50fa7b"
|
|
ORANGE = "#ffb86c"
|
|
PINK = "#ff79c6"
|
|
PURPLE = "#9f74b3"
|
|
RED = "#ff5555"
|
|
YELLOW = "#f1fa8c"
|
|
|
|
|
|
class MarkdownHighlighter(QSyntaxHighlighter):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setupRules()
|
|
|
|
def setupRules(self):
|
|
heading_format = QTextCharFormat()
|
|
heading_format.setForeground(QColor(SyntaxColors.PURPLE))
|
|
|
|
italic_format = QTextCharFormat()
|
|
italic_format.setFontItalic(True)
|
|
italic_format.setForeground(QColor(SyntaxColors.GREEN))
|
|
|
|
bold_format = QTextCharFormat()
|
|
bold_format.setFontWeight(QFont.Weight.Bold)
|
|
bold_format.setForeground(QColor(SyntaxColors.ORANGE))
|
|
|
|
self.highlightingRules = [
|
|
(QRegularExpression("^#.*"), heading_format),
|
|
(QRegularExpression("\*{1}[^*]+\*{1}"), italic_format),
|
|
(QRegularExpression("\*{2}[^*]+\*{2}"), bold_format),
|
|
]
|
|
|
|
def highlightBlock(self, text):
|
|
for pattern, format in self.highlightingRules:
|
|
match_iterator = pattern.globalMatch(text)
|
|
while match_iterator.hasNext():
|
|
match = match_iterator.next()
|
|
self.setFormat(match.capturedStart(), match.capturedLength(), format)
|
|
self.setCurrentBlockState(0)
|
|
|
|
|
|
def main():
|
|
""" Main function to run the Markdown Editor application. """
|
|
logging.info("Launching the application...")
|
|
app = QApplication(sys.argv)
|
|
editor = MarkdownEditor()
|
|
editor.show()
|
|
sys.exit(app.exec())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|