diff --git a/README.md b/README.md index 3688005..a5ac10b 100644 --- a/README.md +++ b/README.md @@ -12,34 +12,71 @@ Owner-led governance; contributions welcome (see CONTRIBUTING). ### πŸ“ Live Preview -* Debounced, side-by-side Markdown preview while you type. -* Dark-mode friendly CSS. -* Optional Qt WebEngine rendering (automatically disabled in tests/headless runs). +- Debounced, side-by-side Markdown preview while you type. +- Dark-mode friendly styling. +- Optional Qt WebEngine rendering (automatically disabled in tests/headless runs). ### 🧠 Markdown Rendering Powered by: -* `python-markdown` -* Extensions: +- `python-markdown` +- Extensions: + - `extra` + - `fenced_code` + - `codehilite` + - `toc` + - `sane_lists` + - `smarty` +- Optional: + - `pymdown-extensions` (e.g. math/LaTeX via `arithmatex`) - * `extra` - * `fenced_code` - * `codehilite` - * `toc` - * `sane_lists` - * `smarty` -* Optional: +### πŸ“ Robust File Handling - * `pymdown-extensions` (e.g. math/LaTeX via `arithmatex`) +- Open/Save `.md` files +- Atomic writes via `QSaveFile` +- UTF-8 encoding +- Drag & drop support +- Recent files persisted via `QSettings` -### πŸ“ Robust File Handling +--- + +## 🧠 UX Improvements (Smart Markdown Editing) + +PyMarkdownEditor includes selection-aware behaviour for common Markdown actions. + +### βœ… Smart Bold / Italic Toggle (Selection Highlighting) + +When text is selected, **Bold** and *Italic* behave as toggles: + +- If the selection is **not** already wrapped, it is wrapped with the correct Markdown. +- If the selection **is already wrapped**, the wrapping is removed. + +Examples: + +- Selecting `hello` then pressing **Ctrl+B** β†’ `**hello**` +- Selecting `**hello**` then pressing **Ctrl+B** β†’ `hello` +- Selecting `hello` then pressing **Ctrl+I** β†’ `_hello_` +- Selecting `_hello_` then pressing **Ctrl+I** β†’ `hello` -* Open/Save `.md` files -* Atomic writes via `QSaveFile` -* UTF-8 encoding -* Drag & drop support -* Recent files persisted via `QSettings` +### πŸ”— Smart Paste: URLs become Markdown links + +When you paste a URL (**Ctrl+V**) the editor detects whether you have selected text: + +- **No selection + URL in clipboard** + - Inserts: `[](https://example.com)` + - Cursor is placed inside the `[]` so you can type the label immediately. + +- **Selection + URL in clipboard** + - Converts to: `[selected text](https://example.com)` + +This makes link creation fast without opening a dialog. + +### ⌨ New Shortcuts + +- **Ctrl+B** β†’ toggle bold on selected text (`**...**`) +- **Ctrl+I** β†’ toggle italic on selected text (`_..._`) +- **Ctrl+E** β†’ insert a new fenced code block on a new line --- @@ -61,9 +98,9 @@ Uses `QWebEngineView` for closer WYSIWYG output. Automatically disabled in: -* pytest -* headless environments -* when `PYMD_DISABLE_WEBENGINE=1` +- pytest +- headless environments +- when `PYMD_DISABLE_WEBENGINE=1` --- @@ -88,7 +125,7 @@ Recommended host wiring: plugin_manager.set_api(app_api) plugin_manager.reload() plugin_manager.on_app_ready() -``` +```` Hooks (optional): @@ -126,7 +163,7 @@ SOLID-leaning, layered design: * Strategy-based exporters * Explicit plugin lifecycle * Deterministic startup -* Test-safe QtWebEngine behavior +* Test-safe QtWebEngine behaviour --- @@ -196,22 +233,23 @@ Optional (WebEngine PDF export): ### Formatting -* **B** β†’ Bold -* *i* β†’ Italic -* `code` +* **Ctrl+B** β†’ Bold toggle (selection-aware) +* **Ctrl+I** β†’ Italic toggle (selection-aware) +* `code` (inline) * `# H1` * `## H2` * `- list` ### Insert -* Insert link +* **Ctrl+E** β†’ Insert fenced code block on a new line +* Insert link (dialog) * Insert image * Insert table * Find / Replace * About dialog -All actions exposed via toolbar + menus. +All actions are exposed via toolbar + menus. --- @@ -229,6 +267,7 @@ All actions exposed via toolbar + menus. β”‚ β”‚ β”œβ”€β”€ state.py β”‚ β”‚ └── builtin/ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”œβ”€β”€ config/ β”‚ β”‚ β”œβ”€β”€ exporters/ β”‚ β”‚ β”œβ”€β”€ file_service.py β”‚ β”‚ β”œβ”€β”€ markdown_renderer.py @@ -343,7 +382,7 @@ Triggered by version tags. ### Missing pymdownx -``` +```bash pip install pymdown-extensions ``` diff --git a/pymd/di/container.py b/pymd/di/container.py index 34ac8a9..8bcc050 100644 --- a/pymd/di/container.py +++ b/pymd/di/container.py @@ -1,8 +1,11 @@ from __future__ import annotations +from pathlib import Path + from PyQt6.QtCore import QSettings from pymd.domain.interfaces import ( + IAppConfig, IExporterRegistry, IFileService, IMarkdownRenderer, @@ -11,6 +14,7 @@ from pymd.plugins.manager import PluginManager from pymd.plugins.pip_installer import QtPipInstaller from pymd.plugins.state import SettingsPluginStateStore +from pymd.services.config.app_config import build_app_config from pymd.services.exporters import WebEnginePdfExporter from pymd.services.exporters.base import ExporterRegistryInst from pymd.services.exporters.html_exporter import HtmlExporter @@ -50,6 +54,10 @@ def __init__( qsettings: QSettings | None = None, dialogs: object | None = None, messages: object | None = None, + *, + app_config: IAppConfig | None = None, + explicit_ini: Path | None = None, + project_root: Path | None = None, ) -> None: # Core services (defaults if not supplied) self.renderer: IMarkdownRenderer = renderer or MarkdownRenderer() @@ -58,6 +66,12 @@ def __init__( qsettings or QSettings() ) + # NEW: App config (version + ini-backed config surface) + self.app_config: IAppConfig = app_config or build_app_config( + explicit_ini=explicit_ini, + project_root=project_root, + ) + # Exporter registry instance (per-instance; test-friendly) self.exporter_registry: IExporterRegistry = ExporterRegistryInst() self._ensure_builtin_exporters(self.exporter_registry) @@ -140,9 +154,6 @@ def _attach_plugins_to_window(self, window: MainWindow) -> None: # ---------- UI factories ---------- def build_main_presenter(self, view) -> object: - """ - Create a MainPresenter bound to a view (if presenter layer is available). - """ if MainPresenter is None: # pragma: no cover raise RuntimeError("Presenter layer is not available in this build.") @@ -173,12 +184,12 @@ def build_main_window( renderer=self.renderer, file_service=self.file_service, settings=self.settings_service, + config=self.app_config, # βœ… NEW exporter_registry=self.exporter_registry, start_path=start_path, app_title=app_title, ) - # --- Plugins wiring (consistent + deterministic) --- self._attach_plugins_to_window(window) # Attach presenter if available @@ -188,15 +199,11 @@ def build_main_window( if hasattr(window, "attach_presenter"): window.attach_presenter(presenter) # type: ignore[attr-defined] except Exception: - # Presenter layer optional; ignore failures here. pass return window -# --- Convenience function (parity with prior API) ---------------------------- - - def build_main_window( qsettings: QSettings | None = None, *, diff --git a/pymd/domain/interfaces.py b/pymd/domain/interfaces.py index 7a9eb52..96fa38b 100644 --- a/pymd/domain/interfaces.py +++ b/pymd/domain/interfaces.py @@ -112,3 +112,12 @@ def as_dict(self) -> Mapping[str, Mapping[str, str]]: # Convenience for very common keys def app_version(self) -> str: ... + + +class IAppConfig(IConfigService, Protocol): + """ + Full config contract for the application. + Must include all public IniConfigService methods + app version. + """ + + def get_version(self) -> str: ... diff --git a/pymd/services/config/app_config.py b/pymd/services/config/app_config.py new file mode 100644 index 0000000..accf024 --- /dev/null +++ b/pymd/services/config/app_config.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import re +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from pymd.domain.interfaces import IAppConfig +from pymd.services.config.ini_config_service import IniConfigService + +_VERSION_RE = re.compile(r"^v?(\d+\.\d+\.\d+)(?:[-+].*)?$", re.IGNORECASE) + + +def _project_root_fallback() -> Path: + """ + Best-effort project root resolution that also works in PyInstaller: + - PyInstaller onefile/onedir uses sys._MEIPASS as bundle root + - dev mode uses this file location to walk upward + """ + meipass = getattr(sys, "_MEIPASS", None) # type: ignore[attr-defined] + if meipass: + return Path(meipass) + + # app_config.py -> pymd/services/config/app_config.py + # parents[3] = repository root (same trick you used in about.py) + return Path(__file__).resolve().parents[3] + + +def _read_version_file(version_path: Path) -> str | None: + try: + raw = version_path.read_text(encoding="utf-8").strip() + except Exception: + return None + + m = _VERSION_RE.match(raw) + if not m: + return None + + # return normalized X.Y.Z (no leading v) + return m.group(1) + + +@dataclass(frozen=True) +class AppConfig(IAppConfig): + """ + Adapter that wraps IniConfigService and adds get_version() from /version file. + + Precedence for version: + 1) /version file (semantic e.g. v1.0.5) + 2) ini_config_service.app_version() (fallback) + 3) "0.0.0" + """ + + ini: IniConfigService + project_root: Path + + def get_version(self) -> str: + v = _read_version_file(self.project_root / "version") + if v: + return v + + # fallback to ini setting (kept for compatibility / override scenarios) + v2 = self.ini.app_version() + v2 = (v2 or "").strip() + if v2: + # normalize possible "v1.0.5" + m = _VERSION_RE.match(v2) + return m.group(1) if m else v2 + + return "0.0.0" + + # ---- delegate IniConfigService methods (full surface) ---- + + def get(self, section: str, key: str, default: str | None = None) -> str | None: + return self.ini.get(section, key, default) + + def get_int(self, section: str, key: str, default: int | None = None) -> int | None: + return self.ini.get_int(section, key, default) + + def get_bool(self, section: str, key: str, default: bool | None = None) -> bool | None: + return self.ini.get_bool(section, key, default) + + def as_dict(self) -> Mapping[str, Mapping[str, str]]: + return self.ini.as_dict() + + def app_version(self) -> str: + return self.ini.app_version() + + @property + def loaded_from(self) -> Path | None: + return self.ini.loaded_from + + +def build_app_config( + *, explicit_ini: Path | None = None, project_root: Path | None = None +) -> AppConfig: + root = project_root or _project_root_fallback() + ini = IniConfigService(explicit_path=explicit_ini, project_root=root) + return AppConfig(ini=ini, project_root=root) diff --git a/pymd/services/ui/about.py b/pymd/services/ui/about.py index 7e7610e..2da288b 100644 --- a/pymd/services/ui/about.py +++ b/pymd/services/ui/about.py @@ -1,6 +1,10 @@ -# pymd/services/ui/about.py from __future__ import annotations +import sys +from pathlib import Path + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPixmap from PyQt6.QtWidgets import ( QDialog, QGridLayout, @@ -10,20 +14,82 @@ QVBoxLayout, ) +# Keep AboutDialog UI-only; configuration/version is injected (DIP). +try: + # Your new β€œapp config” protocol that includes IniConfigService methods + get_version() + from pymd.domain.interfaces_config import IAppConfig # type: ignore +except Exception: # pragma: no cover + IAppConfig = object # type: ignore[misc] + + +def _asset_path(*parts: str) -> str: + """ + Resolve an asset path that works both: + - in source checkout (relative ./assets) + - in PyInstaller builds (sys._MEIPASS) + + Repo layout: + pymd/services/ui/about.py -> parents[3] == project root + so /assets/... is correct. + """ + base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[3])) # type: ignore[attr-defined] + return str(base.joinpath("assets", *parts)) + -# Keep this tiny and self-contained. No link-creation APIs here. class AboutDialog(QDialog): - def __init__(self, _editor=None, parent=None): + """ + About dialog. + + Responsibilities: + - display app name + - display semantic version (injected via config) + - display splash image (assets/splash.png) if present + """ + + def __init__(self, _editor=None, parent=None, *, config: IAppConfig | None = None) -> None: super().__init__(parent) self.setWindowTitle("About") self.setModal(False) + self._config = config + # Widgets self.close_btn = QPushButton("OK") - # Static text; if you have a config/version provider, format it in MainWindow before showing - name_label = QLabel("PyMarkdown Editor") - version_label = QLabel("Version {version} (build {commit}, {build_date})") + # Splash image + splash_label = QLabel(self) + splash_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + splash_path = _asset_path("splash.png") + pix = QPixmap(splash_path) + if not pix.isNull(): + splash_label.setPixmap( + pix.scaled( + 420, + 420, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + else: + splash_label.setText("(splash.png missing)") + + # Text + name_label = QLabel("PyMarkdown Editor") + + version = "0.0.0" + try: + # Contract: AppConfig.get_version() reads /version (e.g. v1.0.5) and normalizes. + if hasattr(self._config, "get_version"): + version = str(self._config.get_version()) # type: ignore[attr-defined] + else: + # Backward-compatible fallback if only IniConfigService-like API is provided. + version = str(getattr(self._config, "app_version", lambda: "0.0.0")()) + except Exception: + version = "0.0.0" + + version_label = QLabel(f"Version {version}") + version_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) # Layouts form = QGridLayout() @@ -35,6 +101,8 @@ def __init__(self, _editor=None, parent=None): buttons.addWidget(self.close_btn) root = QVBoxLayout(self) + root.addWidget(splash_label) + root.addSpacing(8) root.addLayout(form) root.addLayout(buttons) diff --git a/pymd/services/ui/main_window.py b/pymd/services/ui/main_window.py index d0c38c3..79cfdc4 100644 --- a/pymd/services/ui/main_window.py +++ b/pymd/services/ui/main_window.py @@ -5,8 +5,8 @@ from pathlib import Path from typing import Any -from PyQt6.QtCore import QByteArray, Qt -from PyQt6.QtGui import QAction, QKeySequence, QTextCursor +from PyQt6.QtCore import QByteArray, QEvent, Qt +from PyQt6.QtGui import QAction, QKeyEvent, QKeySequence, QTextCursor from PyQt6.QtWidgets import ( QApplication, QFileDialog, @@ -21,9 +21,10 @@ QToolBar, ) -from pymd.domain.interfaces import IFileService, IMarkdownRenderer, ISettingsService +from pymd.domain.interfaces import IAppConfig, IFileService, IMarkdownRenderer, ISettingsService from pymd.domain.models import Document from pymd.services.exporters.base import ExporterRegistryInst, IExporterRegistry +from pymd.services.ui.about import AboutDialog from pymd.services.ui.create_link import CreateLinkDialog from pymd.services.ui.find_replace import FindReplaceDialog from pymd.services.ui.plugins_dialog import InstalledPluginRow, PluginsDialog @@ -81,6 +82,7 @@ def get_plugin_setting( def set_plugin_setting(self, plugin_id: str, key: str, value: str) -> None: self._w.settings.set_raw(f"plugins/{plugin_id}/{key}", value) + # ---- theme (example capability for builtin theme plugin) ---- def get_theme(self) -> str: return getattr(self._w, "_theme_id", "default") @@ -96,13 +98,14 @@ class MainWindow(QMainWindow): def __init__( self, - renderer: IMarkdownRenderer, - file_service: IFileService, - settings: ISettingsService, *, + app_title: str = "PyMarkdownEditor", + config: IAppConfig, exporter_registry: IExporterRegistry | None = None, + file_service: IFileService, + renderer: IMarkdownRenderer, + settings: ISettingsService, start_path: Path | None = None, - app_title: str = "PyMarkdownEditor", ) -> None: super().__init__() self.setWindowTitle(app_title) @@ -112,6 +115,9 @@ def __init__( self.file_service = file_service self.settings = settings + # βœ… IMPORTANT: store config before any use + self.config: IAppConfig = config + # Exporter registry instance (per-instance; test-friendly) self._exporters: IExporterRegistry = exporter_registry or ExporterRegistryInst() @@ -132,6 +138,9 @@ def __init__( self.editor.setAcceptRichText(False) self.editor.setTabStopDistance(4 * self.editor.fontMetrics().horizontalAdvance(" ")) + # Selection-aware UX shortcuts + self.editor.installEventFilter(self) + # Preview: prefer QWebEngineView, fallback to QTextBrowser self.preview = self._create_preview_widget() @@ -143,7 +152,8 @@ def __init__( self.splitter.setStretchFactor(1, 1) self.setCentralWidget(self.splitter) - # Non-modal Find/Replace dialog + # Non-modal dialogs + self.about_dialog = AboutDialog(config=self.config, parent=self) self.find_dialog = FindReplaceDialog(self.editor, self) self.link_dialog = CreateLinkDialog(self.editor, self) self.table_dialog = TableDialog(self.editor, self) @@ -186,24 +196,16 @@ def attach_plugins( Ownership rule: - Bootstrapper owns plugin reload() for deterministic boot. - MainWindow.attach_plugins() must NOT call reload(). - - This method only: - - stores references - - sets API on the manager (best-effort, safe) - - rebuilds UI actions from whatever is currently enabled/active """ self.plugin_manager = plugin_manager self.plugin_installer = plugin_installer - # Late-bind API if supported (does NOT activate/reload) if self.plugin_manager is not None and hasattr(self.plugin_manager, "set_api"): try: self.plugin_manager.set_api(self._app_api) # type: ignore[attr-defined] except Exception: pass - # IMPORTANT: no plugin_manager.reload() here (bootstrapper owns it) - self._rebuild_plugin_actions() # ----------------------------- UI creation ----------------------------- @@ -212,12 +214,10 @@ def _build_actions(self) -> None: self.exit_action = QAction("&Exit", self) self.exit_action.setShortcut("Ctrl+Q") self.exit_action.setStatusTip("Exit application") - self.exit_action.triggered.connect(QApplication.instance().quit) + self.exit_action.triggered.connect(QApplication.instance().quit) # type: ignore[union-attr] - # Plugins manager action self.act_plugins = QAction("&Plugins…", self, triggered=self._show_plugins_manager) - # File actions self.act_new = QAction( "New", self, shortcut=QKeySequence.StandardKey.New, triggered=self._new_file ) @@ -238,25 +238,35 @@ def _build_actions(self) -> None: "Toggle Preview", self, checkable=True, checked=True, triggered=self._toggle_preview ) - # Export actions from registry + self.act_about = QAction("About…", self, triggered=self._show_about) + self.export_actions: list[QAction] = [] for exporter in self._exporters.all(): act = QAction( - exporter.label, - self, - triggered=lambda chk=False, e=exporter: self._export_with(e), + exporter.label, self, triggered=lambda chk=False, e=exporter: self._export_with(e) ) self.export_actions.append(act) self.recent_menu = QMenu("Open Recent", self) - # Formatting actions - self.act_bold = QAction("B", self, triggered=lambda: self._surround("**", "**")) - self.act_italic = QAction("i", self, triggered=lambda: self._surround("*", "*")) + self.act_bold = QAction("B", self, triggered=lambda: self._surround_selection("**", "**")) + self.act_bold.setShortcut("Ctrl+B") + + self.act_italic = QAction("i", self, triggered=lambda: self._surround_selection("_", "_")) + self.act_italic.setShortcut("Ctrl+I") + self.act_code = QAction("`code`", self, triggered=self._insert_inline_code) self.act_code_block = QAction("codeblock", self, triggered=self._insert_code_block) - self.act_h1 = QAction("H1", self, triggered=lambda: self._prefix_line("# ")) - self.act_h2 = QAction("H2", self, triggered=lambda: self._prefix_line("## ")) + + self.act_code_block_simple = QAction( + "Insert Code Block", + self, + shortcut="Ctrl+E", + triggered=self._insert_fenced_code_block_simple, + ) + + self.act_h1 = QAction("H1", self, triggered=lambda: self._toggle_header_prefix("# ")) + self.act_h2 = QAction("H2", self, triggered=lambda: self._toggle_header_prefix("## ")) self.act_list = QAction("List", self, triggered=lambda: self._prefix_line("- ")) self.act_img = QAction("Image", self, triggered=self._select_image) self.act_link = QAction("Link", self, triggered=self._create_link) @@ -264,7 +274,6 @@ def _build_actions(self) -> None: "Table", self, shortcut="Ctrl+Shift+T", triggered=self._insert_table ) - # Find/Replace actions with standard shortcuts self.act_find = QAction("Find", self) self.act_find.setShortcut(QKeySequence.StandardKey.Find) self.act_find.triggered.connect(self._show_find) @@ -339,6 +348,7 @@ def _build_menu(self) -> None: self.act_italic, self.act_code, self.act_code_block, + self.act_code_block_simple, self.act_h1, self.act_h2, self.act_list, @@ -358,6 +368,9 @@ def _build_menu(self) -> None: toolsm.addSeparator() self._plugins_menu = toolsm + helpm = m.addMenu("&Help") + helpm.addAction(self.act_about) + def _refresh_recent_menu(self) -> None: self.recent_menu.clear() if not self.recents: @@ -370,15 +383,225 @@ def _refresh_recent_menu(self) -> None: QAction(p, self, triggered=lambda chk=False, x=p: self._open_path(Path(x))) ) + # ---------------------- UX: selection-aware shortcuts ---------------------- + + def eventFilter(self, obj: object, event: object) -> bool: + """ + Intercept editor key combos for selection-aware Markdown helpers. + + - Ctrl+B: toggle **selection** + - Ctrl+I: toggle _selection_ + - Ctrl+V: + * selection + URL in clipboard -> [selection](url) + * no selection + URL in clipboard -> [](url) and place cursor inside [] + - Ctrl+E: insert fenced code block on new line + """ + if obj is self.editor and isinstance(event, QKeyEvent): + if event.type() == QEvent.Type.KeyPress: + key = event.key() + mods = event.modifiers() + + ctrl_down = bool(mods & Qt.KeyboardModifier.ControlModifier) or bool( + mods & Qt.KeyboardModifier.MetaModifier + ) + + if ctrl_down and key == Qt.Key.Key_B: + self._surround_selection("**", "**") + return True + + if ctrl_down and key == Qt.Key.Key_I: + self._surround_selection("_", "_") + return True + + if ctrl_down and key == Qt.Key.Key_E: + self._insert_fenced_code_block_simple() + return True + + if ctrl_down and key == Qt.Key.Key_V: + if self._paste_as_markdown_link_if_applicable(): + return True + # else let default paste happen + + return super().eventFilter(obj, event) # type: ignore[misc] + + # ---------------------- UX: bold/italic toggles ---------------------- + + def _surround_selection(self, left: str, right: str) -> None: + c = self.editor.textCursor() + if not c.hasSelection(): + return + + raw_sel = c.selectedText() + sel = raw_sel.replace("\u2029", "\n") + + if left == "**" and right == "**": + new_text = self._toggle_wrapped_text(sel, left="**", right="**") + elif left == "_" and right == "_": + new_text = self._toggle_italic_underscore(sel) + else: + new_text = self._toggle_wrapped_text(sel, left=left, right=right) + + c.beginEditBlock() + try: + c.insertText(new_text) + finally: + c.endEditBlock() + + self.editor.setTextCursor(c) + + def _toggle_wrapped_text(self, text: str, *, left: str, right: str) -> str: + if text.startswith(left) and text.endswith(right) and len(text) >= len(left) + len(right): + return text[len(left) : -len(right)] + return f"{left}{text}{right}" + + def _toggle_italic_underscore(self, text: str) -> str: + if not text: + return "_" + + if text.startswith("_") and text.endswith("_") and len(text) >= 2: + return text[1:-1] + + stripped = text.strip() + if stripped.startswith("_") and stripped.endswith("_") and len(stripped) >= 2: + return stripped[1:-1] + + return f"_{text}_" + + # ---------------------- UX: header toggle ---------------------- + + def _toggle_header_prefix(self, prefix: str) -> None: + c = self.editor.textCursor() + doc = self.editor.document() + block = doc.findBlock(c.position()) + if not block.isValid(): + return + + line_text = block.text() + + existing = "" + i = 0 + while i < len(line_text) and line_text[i] == "#" and i < 6: + i += 1 + if i > 0 and i < len(line_text) and line_text[i] == " ": + existing = "#" * i + " " + + replacement_prefix = "" if existing == prefix else prefix + + c.beginEditBlock() + try: + line_start = block.position() + cur = QTextCursor(doc) + cur.setPosition(line_start) + + if existing: + cur.movePosition( + QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, len(existing) + ) + cur.removeSelectedText() + cur.clearSelection() + + if replacement_prefix: + cur.insertText(replacement_prefix) + finally: + c.endEditBlock() + + self.editor.setTextCursor(c) + + # ---------------------- UX: paste URL as markdown link ---------------------- + + def _clipboard_text(self) -> str: + cb = QApplication.clipboard() + return (cb.text() or "").strip() + + def _looks_like_url(self, text: str) -> bool: + t = text.strip() + if not t: + return False + lower = t.lower() + return ( + lower.startswith("http://") or lower.startswith("https://") or lower.startswith("www.") + ) + + def _normalize_url(self, text: str) -> str: + t = text.strip() + if t.lower().startswith("www."): + return "https://" + t + return t + + def _paste_as_markdown_link_if_applicable(self) -> bool: + """ + Ctrl+V smart paste: + - selection + URL -> [selection](url) + - no selection + URL -> [](url) with cursor placed inside [] + Returns True if handled, False if caller should fall back to default paste. + """ + clip = self._clipboard_text() + if not self._looks_like_url(clip): + return False + + url = self._normalize_url(clip) + + c = self.editor.textCursor() + + # Case 1: selection exists -> [sel](url) + if c.hasSelection(): + sel = c.selectedText().replace("\u2029", "\n").strip() + if not sel: + return False + c.beginEditBlock() + try: + c.insertText(f"[{sel}]({url})") + finally: + c.endEditBlock() + self.editor.setTextCursor(c) + return True + + # Case 2: no selection -> [](url) and move cursor inside [] + c.beginEditBlock() + try: + insert_pos = c.position() + md = f"[]({url})" + c.insertText(md) + + # Move cursor to between [ and ] + # inserted text length is len(md); we want position: insert_pos + 1 + c.setPosition(insert_pos + 1) + finally: + c.endEditBlock() + + self.editor.setTextCursor(c) + return True + + # ---------------------- UX: code block insert ---------------------- + + def _insert_fenced_code_block_simple(self) -> None: + c = self.editor.textCursor() + c.beginEditBlock() + try: + if c.position() > 0: + original_pos = c.position() + c.movePosition(c.MoveOperation.Left, c.MoveMode.KeepAnchor, 1) + prev = c.selectedText() + c.clearSelection() + c.setPosition(original_pos) + if prev not in ("\u2029", "\n"): + c.insertText("\n") + + c.insertText("```\n\n```\n") + c.movePosition(c.MoveOperation.PreviousBlock) + c.movePosition(c.MoveOperation.PreviousBlock) + c.movePosition(c.MoveOperation.EndOfBlock) + finally: + c.endEditBlock() + + self.editor.setTextCursor(c) + # ----------------------------- Plugins UI ----------------------------- def _show_plugins_manager(self) -> None: - # Plugins UI is optional; show a clear message if missing wiring. if self.plugin_manager is None or self.plugin_installer is None: QMessageBox.information( - self, - "Plugins", - "Plugin management is not available in this build.", + self, "Plugins", "Plugin management is not available in this build." ) return @@ -393,22 +616,20 @@ def _show_plugins_manager(self) -> None: return if self._plugins_dialog is None: - # mypy: ensure get_installed matches PluginsDialog's signature. + def _get_installed() -> Iterable[InstalledPluginRow]: - # PluginManager returns PluginInfo objects (same attribute names). rows = self.plugin_manager.get_installed_rows() # type: ignore[attr-defined] return rows # type: ignore[return-value] self._plugins_dialog = PluginsDialog( parent=self, state=self.plugin_manager.state_store, # type: ignore[attr-defined] - pip=self.plugin_installer, # QtPipInstaller emits output(str), finished(object) + pip=self.plugin_installer, get_installed=_get_installed, # type: ignore[arg-type] reload_plugins=self.plugin_manager.reload, # type: ignore[attr-defined] catalog=getattr(self.plugin_manager, "catalog", None), ) - # Ensure we rebuild actions after dialog closes (covers enable/disable + reload workflows). if not self._plugins_dialog_hooked: self._plugins_dialog.finished.connect(lambda _=0: self._rebuild_plugin_actions()) # type: ignore[arg-type] self._plugins_dialog_hooked = True @@ -418,15 +639,6 @@ def _get_installed() -> Iterable[InstalledPluginRow]: self._plugins_dialog.activateWindow() def _rebuild_plugin_actions(self) -> None: - """ - Populate Tools menu with actions from enabled plugins. - - Supported manager shapes (any one): - - iter_enabled_actions(app_api) -> iterable[(spec, handler)] - - iter_actions(app_api) -> iterable[(spec, handler)] - - iter_enabled_actions() -> iterable[(spec, handler)] - - iter_actions() -> iterable[(spec, handler)] - """ if self._plugins_menu is None: return @@ -488,6 +700,11 @@ def _run() -> None: # ----------------------------- Actions ----------------------------- + def _show_about(self) -> None: + self.about_dialog.show() + self.about_dialog.raise_() + self.about_dialog.activateWindow() + def _show_find(self) -> None: self.find_dialog.show_find() @@ -513,18 +730,10 @@ def _create_link(self) -> None: def _insert_table(self) -> None: self.table_dialog.show_table_dialog() - def _surround(self, left: str, right: str) -> None: - c = self.editor.textCursor() - if not c.hasSelection(): - return - sel = c.selectedText() - c.insertText(f"{left}{sel}{right}") - self.editor.setTextCursor(c) - def _insert_inline_code(self) -> None: c = self.editor.textCursor() if c.hasSelection(): - sel = c.selectedText() + sel = c.selectedText().replace("\u2029", "\n") c.insertText(f"`{sel}`") else: c.insertText("`") @@ -567,9 +776,8 @@ def _insert_code_block(self) -> None: c.insertText("\n") c.insertText(first_line + "\n\n```\n") - - c.movePosition(c.MoveOperation.PreviousBlock) # closing fence - c.movePosition(c.MoveOperation.PreviousBlock) # blank line + c.movePosition(c.MoveOperation.PreviousBlock) + c.movePosition(c.MoveOperation.PreviousBlock) c.movePosition(c.MoveOperation.EndOfBlock) finally: c.endEditBlock() @@ -610,6 +818,8 @@ def _prefix_line(self, prefix: str) -> None: self.editor.setTextCursor(c) + # ----------------------------- File ops ----------------------------- + def _new_file(self) -> None: if not self._confirm_discard(): return @@ -666,7 +876,7 @@ def _write_to(self, path: Path) -> bool: self.file_service.write_text_atomic(path, self.editor.toPlainText()) self.doc.modified = False self._update_title() - self.statusBar().showMessage(f"Saved: {path}", 3000) + self.statusBar().showMessage(f"Saved: {path}", 3000) # type: ignore[union-attr] return True except Exception as e: QMessageBox.critical(self, "Save Error", f"Failed to save file:\n{e}") @@ -685,7 +895,7 @@ def _export_with(self, exporter: Any) -> None: html = self.renderer.to_html(self.editor.toPlainText()) try: exporter.export(html, Path(out_str)) - self.statusBar().showMessage(f"Exported {exporter.name.upper()}: {out_str}", 3000) + self.statusBar().showMessage(f"Exported {exporter.name.upper()}: {out_str}", 3000) # type: ignore[union-attr] except Exception as e: QMessageBox.critical( self, "Export Error", f"Failed to export {exporter.name.upper()}:\n{e}" @@ -702,7 +912,7 @@ def _toggle_preview(self, on: bool) -> None: def _render_preview(self) -> None: html = self.renderer.to_html(self.editor.toPlainText()) - self.preview.setHtml(html) + self.preview.setHtml(html) # type: ignore[attr-defined] def _on_text_changed(self) -> None: self.doc.modified = True @@ -758,10 +968,16 @@ def closeEvent(self, event: Any) -> None: # ---------------------- Internal: preview creation ---------------------- def _create_preview_widget(self) -> Any: - """ - Prefer QWebEngineView (JS-capable: MathJax/KaTeX, better CSS), - fall back to QTextBrowser. Guard imports so the app runs without WebEngine. - """ + disable_webengine = ( + os.environ.get("PYMD_DISABLE_WEBENGINE", "").strip() == "1" + or "PYTEST_CURRENT_TEST" in os.environ + ) + + if disable_webengine: + w = QTextBrowser(self) + w.setOpenExternalLinks(True) + return w + try: from PyQt6.QtWebEngineWidgets import QWebEngineView # type: ignore @@ -771,6 +987,8 @@ def _create_preview_widget(self) -> Any: w.setOpenExternalLinks(True) return w + # ----------------------------- Themes ----------------------------- + def apply_theme(self, theme_id: str) -> None: self._theme_id = theme_id self.settings.set_raw("ui/theme", theme_id) @@ -802,33 +1020,3 @@ def apply_theme(self, theme_id: str) -> None: """ ) return - - def _create_preview_widget(self) -> Any: - """ - Prefer QWebEngineView (JS-capable: MathJax/KaTeX, better CSS), - fall back to QTextBrowser. - - IMPORTANT: - - QtWebEngine can hard-abort the process in headless / pytest runs. - - For deterministic test stability, we disable WebEngine when: - * PYTEST_CURRENT_TEST is set (pytest runtime), OR - * PYMD_DISABLE_WEBENGINE=1 is set - """ - disable_webengine = ( - os.environ.get("PYMD_DISABLE_WEBENGINE", "").strip() == "1" - or "PYTEST_CURRENT_TEST" in os.environ - ) - - if disable_webengine: - w = QTextBrowser(self) - w.setOpenExternalLinks(True) - return w - - try: - from PyQt6.QtWebEngineWidgets import QWebEngineView # type: ignore - - return QWebEngineView(self) - except Exception: - w = QTextBrowser(self) - w.setOpenExternalLinks(True) - return w diff --git a/tests/test_about_dialog.py b/tests/test_about_dialog.py index a6445bd..7b0e04a 100644 --- a/tests/test_about_dialog.py +++ b/tests/test_about_dialog.py @@ -1,67 +1,152 @@ +# tests/test_about_dialog.py from __future__ import annotations -import pytest +from dataclasses import dataclass + from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPixmap from PyQt6.QtWidgets import QLabel, QPushButton from pymd.services.ui.about import AboutDialog -class FakeCursor: - def insertText(self, *_a, **_k): - pass +@dataclass(frozen=True) +class GoodConfig: + def get_version(self) -> str: + return "1.0.5" + +class BadConfig: + def get_version(self) -> str: + raise RuntimeError("boom") -class FakeEditor: - def textCursor(self): - return FakeCursor() - def setTextCursor(self, _c): - pass +class LegacyConfig: + """Fallback path: no get_version(), only app_version().""" - def document(self): - return object() + def app_version(self) -> str: + return "2.3.4" -@pytest.fixture -def editor(): - return FakeEditor() +# ------------------------------ +# Construction & basic UI +# ------------------------------ +def test_constructs_and_is_non_modal(qtbot): + d = AboutDialog(parent=None, config=GoodConfig()) + qtbot.addWidget(d) + assert d.windowTitle() == "About" + assert d.isModal() is False -@pytest.fixture -def dlg(qtbot, editor): - d = AboutDialog(editor, None) +def test_has_ok_button_and_closes_on_click(qtbot): + d = AboutDialog(parent=None, config=GoodConfig()) qtbot.addWidget(d) - return d + ok: QPushButton | None = d.close_btn + assert isinstance(ok, QPushButton) + assert ok.text().lower() in ("ok", "close") -def test_constructs_and_is_non_modal(dlg: AboutDialog): - assert dlg.windowTitle() == "About" - assert dlg.isModal() is False + d.show() + assert d.isVisible() is True + qtbot.mouseClick(ok, Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: not d.isVisible(), timeout=1000) + + +def test_no_link_creation_controls_present(qtbot): + d = AboutDialog(parent=None, config=GoodConfig()) + qtbot.addWidget(d) + + for attr in ("create_link_btn", "url_edit", "link_title", "show_create_link", "create_link"): + assert not hasattr(d, attr), f"AboutDialog unexpectedly has '{attr}'" + + +# ------------------------------ +# Version resolution: success + fail paths +# ------------------------------ +def test_version_label_uses_get_version_success(qtbot): + d = AboutDialog(parent=None, config=GoodConfig()) + qtbot.addWidget(d) + + labels = d.findChildren(QLabel) + texts = [lbl.text() for lbl in labels] -def test_has_expected_labels(dlg: AboutDialog): - labels = dlg.findChildren(QLabel) - texts = [label.text() for label in labels] assert any("PyMarkdown Editor" in t for t in texts) - assert any("Version" in t for t in texts) + assert any("Version 1.0.5" in t for t in texts) -def test_has_ok_button_and_closes_on_click(qtbot, dlg: AboutDialog): - ok: QPushButton | None = dlg.close_btn - assert isinstance(ok, QPushButton) - assert ok.text().lower() in ("ok", "close") +def test_version_label_falls_back_to_default_on_get_version_error(qtbot): + d = AboutDialog(parent=None, config=BadConfig()) + qtbot.addWidget(d) - dlg.show() - assert dlg.isVisible() is True + labels = d.findChildren(QLabel) + texts = [lbl.text() for lbl in labels] - # Use a proper Qt mouse button enum - qtbot.mouseClick(ok, Qt.MouseButton.LeftButton) - # Wait until the dialog is no longer visible to avoid race conditions - qtbot.waitUntil(lambda: not dlg.isVisible(), timeout=1000) + # Fail path: any exception -> "0.0.0" + assert any("Version 0.0.0" in t for t in texts) -def test_no_link_creation_controls_present(dlg: AboutDialog): - # Guard against accidental copy/paste of link dialog API - for attr in ("create_link_btn", "url_edit", "link_title", "show_create_link", "create_link"): - assert not hasattr(dlg, attr), f"AboutDialog unexpectedly has '{attr}'" +def test_version_label_legacy_fallback_app_version(qtbot): + d = AboutDialog(parent=None, config=LegacyConfig()) + qtbot.addWidget(d) + + labels = d.findChildren(QLabel) + texts = [lbl.text() for lbl in labels] + + assert any("Version 2.3.4" in t for t in texts) + + +def test_version_label_no_config_defaults_to_0_0_0(qtbot): + d = AboutDialog(parent=None, config=None) + qtbot.addWidget(d) + + labels = d.findChildren(QLabel) + texts = [lbl.text() for lbl in labels] + + assert any("Version 0.0.0" in t for t in texts) + + +# ------------------------------ +# Splash image: success + fail paths (via QPixmap.isNull patch) +# ------------------------------ +def _find_splash_label(dlg: AboutDialog) -> QLabel: + """ + The splash QLabel is the only QLabel that either: + - has a pixmap set, OR + - contains the '(splash.png missing)' text. + """ + labels = dlg.findChildren(QLabel) + for lbl in labels: + if lbl.pixmap() is not None: + return lbl + if "(splash.png missing)" in (lbl.text() or ""): + return lbl + raise AssertionError("Could not locate splash label") + + +def test_splash_success_sets_pixmap(monkeypatch, qtbot): + # Force the success path regardless of filesystem + monkeypatch.setattr(QPixmap, "isNull", lambda self: False, raising=True) + + d = AboutDialog(parent=None, config=GoodConfig()) + qtbot.addWidget(d) + + splash = _find_splash_label(d) + assert splash.pixmap() is not None + assert "(splash.png missing)" not in (splash.text() or "") + + +def test_splash_missing_sets_placeholder_text(monkeypatch, qtbot): + # Force the fail path regardless of filesystem + monkeypatch.setattr(QPixmap, "isNull", lambda self: True, raising=True) + + d = AboutDialog(parent=None, config=GoodConfig()) + qtbot.addWidget(d) + + splash = _find_splash_label(d) + + # QLabel.pixmap() may return an "empty" QPixmap object rather than None. + pm = splash.pixmap() + assert pm is None or pm.isNull() + + assert splash.text() == "(splash.png missing)" diff --git a/tests/test_app_config.py b/tests/test_app_config.py new file mode 100644 index 0000000..d417d43 --- /dev/null +++ b/tests/test_app_config.py @@ -0,0 +1,154 @@ +# tests/test_app_config.py +from __future__ import annotations + +from pathlib import Path + +import pytest + +from pymd.services.config.app_config import AppConfig, build_app_config + + +# ------------------------------ +# Helpers +# ------------------------------ +def _write(p: Path, text: str) -> None: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + + +class FakeIni: + """ + Minimal IniConfigService-like fake with controllable behaviour. + We only implement what AppConfig calls. + """ + + def __init__(self, *, version: str = "0.0.0", loaded_from: Path | None = None) -> None: + self._version = version + self._loaded_from = loaded_from + + def app_version(self) -> str: + return self._version + + def get(self, section: str, key: str, default: str | None = None) -> str | None: + return default + + def get_int(self, section: str, key: str, default: int | None = None) -> int | None: + return default + + def get_bool(self, section: str, key: str, default: bool | None = None) -> bool | None: + return default + + def as_dict(self) -> dict[str, dict[str, str]]: + return {"app": {"version": self._version}} + + @property + def loaded_from(self) -> Path | None: + return self._loaded_from + + +# ------------------------------ +# get_version(): success paths +# ------------------------------ +def test_get_version_prefers_version_file_and_strips_v(tmp_path: Path): + root = tmp_path / "proj" + _write(root / "version", "v1.0.5\n") + cfg = AppConfig(ini=FakeIni(version="9.9.9"), project_root=root) + + assert cfg.get_version() == "1.0.5" + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("1.2.3", "1.2.3"), + ("v1.2.3", "1.2.3"), + ("V1.2.3", "1.2.3"), + ("v1.2.3+build.7", "1.2.3"), + ("1.2.3-alpha.1", "1.2.3"), + ], +) +def test_get_version_parses_semver_with_suffixes(tmp_path: Path, raw: str, expected: str): + root = tmp_path / "proj" + _write(root / "version", raw) + cfg = AppConfig(ini=FakeIni(version="0.0.0"), project_root=root) + + assert cfg.get_version() == expected + + +def test_get_version_falls_back_to_ini_when_version_file_missing(tmp_path: Path): + root = tmp_path / "proj" + cfg = AppConfig(ini=FakeIni(version="v2.3.4"), project_root=root) + + assert cfg.get_version() == "2.3.4" + + +def test_get_version_falls_back_to_ini_when_version_file_invalid(tmp_path: Path): + root = tmp_path / "proj" + _write(root / "version", "not-a-version") + cfg = AppConfig(ini=FakeIni(version="2.0.1"), project_root=root) + + assert cfg.get_version() == "2.0.1" + + +# ------------------------------ +# get_version(): fail paths +# ------------------------------ +def test_get_version_returns_0_0_0_when_version_file_unreadable_and_ini_empty(tmp_path: Path): + root = tmp_path / "proj" + # no version file present, ini returns empty/whitespace + cfg = AppConfig(ini=FakeIni(version=" "), project_root=root) + + assert cfg.get_version() == "0.0.0" + + +def test_get_version_returns_ini_raw_if_ini_version_is_non_semver(tmp_path: Path): + """ + If ini app_version is non-empty but doesn't match semver, AppConfig returns it as-is. + """ + root = tmp_path / "proj" + cfg = AppConfig(ini=FakeIni(version="dev"), project_root=root) + + assert cfg.get_version() == "dev" + + +# ------------------------------ +# Delegation / passthrough +# ------------------------------ +def test_loaded_from_delegates_to_ini(tmp_path: Path): + ini_path = tmp_path / "settings.ini" + cfg = AppConfig(ini=FakeIni(version="1.0.0", loaded_from=ini_path), project_root=tmp_path) + assert cfg.loaded_from == ini_path + + +def test_as_dict_delegates_to_ini(tmp_path: Path): + cfg = AppConfig(ini=FakeIni(version="3.3.3"), project_root=tmp_path) + d = cfg.as_dict() + assert d["app"]["version"] == "3.3.3" + + +# ------------------------------ +# build_app_config(): integration-ish checks +# ------------------------------ +def test_build_app_config_uses_supplied_project_root_and_reads_version_file(tmp_path: Path): + root = tmp_path / "repo" + _write(root / "version", "v4.5.6") + + cfg = build_app_config(project_root=root) + assert cfg.project_root == root + assert cfg.get_version() == "4.5.6" + + +def test_build_app_config_passes_explicit_ini_path(tmp_path: Path): + root = tmp_path / "repo" + _write(root / "version", "v1.0.0") + + explicit_ini = tmp_path / "explicit.ini" + _write(explicit_ini, "[app]\nversion = 9.9.9\n") + + cfg = build_app_config(explicit_ini=explicit_ini, project_root=root) + + # We don't reach into IniConfigService internals other than loaded_from, + # which is explicitly exposed for diagnostics. + assert cfg.loaded_from == explicit_ini + # version file still wins + assert cfg.get_version() == "1.0.0" diff --git a/tests/test_container.py b/tests/test_container.py index 7837d06..abfea08 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -1,6 +1,8 @@ # tests/test_container.py from __future__ import annotations +from typing import Any + from pymd.di.container import Container @@ -11,6 +13,7 @@ def test_container_wires_services_exporters_and_plugins(qapp, qsettings): assert c.renderer is not None assert c.file_service is not None assert c.settings_service is not None + assert c.app_config is not None # βœ… Container uses app_config, not config # Exporters (built-ins registered cleanly) exporter_registry = c.exporter_registry @@ -18,35 +21,46 @@ def test_container_wires_services_exporters_and_plugins(qapp, qsettings): assert "html" in names assert "pdf" in names - # Plugins: feature should be enabled / wired + # Plugins are expected to be available/wired in normal builds assert c.plugin_state is not None assert c.plugin_installer is not None assert c.plugin_manager is not None - # PluginManager should expose state store - assert c.plugin_manager.state_store is c.plugin_state + # PluginManager should expose the state store used by the UI + assert getattr(c.plugin_manager, "state_store", None) is c.plugin_state -def test_container_build_main_window_attaches_plugin_manager_and_installer_but_does_not_reload( +def test_container_build_main_window_attaches_plugins_and_does_not_reload( qapp, qsettings, monkeypatch ): """ Ownership rule: bootstrapper owns plugin_manager.reload(). - Container/build_main_window must: - - always attach plugin_manager + plugin_installer to the window - - ensure AppAPI is set (via MainWindow.attach_plugins) + Container.build_main_window must: + - attach plugin_manager + plugin_installer to the window - NOT call plugin_manager.reload() + - ensure plugin manager is bound to the window AppAPI """ c = Container(qsettings=qsettings) # Fail fast if Container tries to reload during build_main_window() - def boom_reload() -> None: - raise AssertionError( - "Container must not call plugin_manager.reload(); bootstrapper owns reload." - ) + if c.plugin_manager is not None and hasattr(c.plugin_manager, "reload"): + + def boom_reload(*_a: Any, **_k: Any) -> None: + raise AssertionError( + "Container must not call plugin_manager.reload(); bootstrapper owns reload." + ) + + monkeypatch.setattr(c.plugin_manager, "reload", boom_reload, raising=True) + + # Spy that API was set (Container may call set_api directly, and/or via window.attach_plugins) + api_seen: dict[str, Any] = {"api": None} + if c.plugin_manager is not None and hasattr(c.plugin_manager, "set_api"): - monkeypatch.setattr(c.plugin_manager, "reload", boom_reload, raising=True) + def spy_set_api(api: Any) -> None: + api_seen["api"] = api + + monkeypatch.setattr(c.plugin_manager, "set_api", spy_set_api, raising=True) win = c.build_main_window(app_title="Test") @@ -54,12 +68,16 @@ def boom_reload() -> None: assert getattr(win, "plugin_manager", None) is c.plugin_manager assert getattr(win, "plugin_installer", None) is c.plugin_installer - # AppAPI adapter should be created by MainWindow and bound to PluginManager via attach_plugins() + # AppAPI adapter should be created by MainWindow app_api = getattr(win, "_app_api", None) assert app_api is not None - # PluginManager should now have an API set (private but stable for this test) - assert getattr(c.plugin_manager, "_api", None) is app_api + # PluginManager should have received the API (if it supports set_api) + if c.plugin_manager is not None and hasattr(c.plugin_manager, "set_api"): + assert api_seen["api"] is app_api + + # Smoke-check: plugin menu rebuild hook exists + assert hasattr(win, "_rebuild_plugin_actions") def test_container_default_uses_provided_qsettings(qapp, qsettings): @@ -68,6 +86,7 @@ def test_container_default_uses_provided_qsettings(qapp, qsettings): so plugin enablement and other persisted state are deterministic in tests. """ c = Container.default(qsettings=qsettings) + + # Sanity check container built correctly assert c.settings_service is not None - # SettingsService is an abstraction; we just assert the container was created successfully. - # (Avoid reaching into SettingsService internals.) + assert c.app_config is not None # βœ… app_config is the correct attribute diff --git a/tests/test_ini_config_service.py b/tests/test_ini_config_service.py index 0d04e64..5e5e0e3 100644 --- a/tests/test_ini_config_service.py +++ b/tests/test_ini_config_service.py @@ -8,208 +8,216 @@ from pymd.services.config.ini_config_service import IniConfigService -def write_ini(p: Path, text: str) -> None: - p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(text, encoding="utf-8") +def _write_ini(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") -def test_defaults_when_no_config_files(monkeypatch, tmp_path): - # Ensure no platformdirs and no home fallback file - monkeypatch.setattr( - "pymd.services.config.ini_config_service.user_config_dir", None, raising=False +# ------------------------------ +# Load order + loaded_from +# ------------------------------ +def test_loads_from_explicit_path_success(tmp_path: Path): + ini = tmp_path / "explicit.ini" + _write_ini( + ini, + """ +[app] +version = 1.2.3 + +[ui] +wrap = true +font_size = 14 +""".strip(), ) - monkeypatch.setenv("HOME", str(tmp_path)) # make sure fallback resolves to empty home - cfg = IniConfigService() - assert cfg.app_version() == "0.0.0" - assert cfg.loaded_from is None + svc = IniConfigService(explicit_path=ini, project_root=None) - # getters with defaults - assert cfg.get("missing", "key", "x") == "x" - assert cfg.get_int("app", "nonint", 42) == 42 - assert cfg.get_bool("app", "nope", False) is False - assert isinstance(cfg.as_dict(), dict) + assert svc.loaded_from == ini + assert svc.app_version() == "1.2.3" + assert svc.get_bool("ui", "wrap") is True + assert svc.get_int("ui", "font_size") == 14 -def test_project_root_config_is_used_when_present(monkeypatch, tmp_path): - monkeypatch.setattr( - "pymd.services.config.ini_config_service.user_config_dir", None, raising=False - ) - monkeypatch.setenv("HOME", str(tmp_path)) +def test_explicit_missing_falls_back_to_project_root_success(tmp_path: Path): + # Explicit path does not exist + missing = tmp_path / "missing.ini" - proj_root = tmp_path / "repo" - ini = proj_root / "config" / "config.ini" - write_ini( - ini, - "[app]\nversion = 1.2.3\n[ui]\nwrap = true\n", + project_root = tmp_path / "repo" + project_ini = project_root / "config" / "config.ini" + _write_ini( + project_ini, + """ +[app] +version = 9.9.9 +""".strip(), ) - cfg = IniConfigService(project_root=proj_root) - assert cfg.app_version() == "1.2.3" - assert cfg.get_bool("ui", "wrap", None) is True - assert cfg.loaded_from == ini + svc = IniConfigService(explicit_path=missing, project_root=project_root) + assert svc.loaded_from == project_ini + assert svc.app_version() == "9.9.9" -def test_platformdirs_preferred_over_project_root(monkeypatch, tmp_path): - # Simulate platformdirs is available and returns a user config directory - def fake_user_config_dir(appname: str) -> str: - # ~/.config/ equivalent under tmp - return str(tmp_path / "usercfg") - monkeypatch.setattr( - "pymd.services.config.ini_config_service.user_config_dir", - fake_user_config_dir, - raising=False, - ) +def test_malformed_explicit_is_ignored_then_uses_project_root(tmp_path: Path, monkeypatch): + """ + Fail path: read_file raises -> should not crash; it should continue and load next candidate. + """ + bad = tmp_path / "bad.ini" + _write_ini(bad, "[app]\nversion = 1.0.0\n") - # Create both files; platformdirs one should win - plat_path = ( - Path(fake_user_config_dir(IniConfigService.DEFAULT_APP_DIR)) / IniConfigService.DEFAULT_FILE - ) - proj_root = tmp_path / "repo" - proj_path = proj_root / "config" / "config.ini" + project_root = tmp_path / "repo" + good = project_root / "config" / "config.ini" + _write_ini(good, "[app]\nversion = 2.0.0\n") - write_ini(plat_path, "[app]\nversion = 2.0.0\n") - write_ini(proj_path, "[app]\nversion = 1.0.0\n") + # Force any attempt to open the explicit file to raise. + real_open = Path.open - cfg = IniConfigService(project_root=proj_root) - assert cfg.app_version() == "2.0.0" - assert cfg.loaded_from == plat_path + def open_boom(self: Path, *a, **k): + if self == bad: + raise OSError("boom") + return real_open(self, *a, **k) + monkeypatch.setattr(Path, "open", open_boom, raising=True) -def test_explicit_path_overrides_everything(monkeypatch, tmp_path): - # Arrange a platformdirs file and a project file AND an explicit file - def fake_user_config_dir(appname: str) -> str: - return str(tmp_path / "usercfg") + svc = IniConfigService(explicit_path=bad, project_root=project_root) - monkeypatch.setattr( - "pymd.services.config.ini_config_service.user_config_dir", - fake_user_config_dir, - raising=False, - ) + assert svc.loaded_from == good + assert svc.app_version() == "2.0.0" - plat_path = ( - Path(fake_user_config_dir(IniConfigService.DEFAULT_APP_DIR)) / IniConfigService.DEFAULT_FILE - ) - proj_root = tmp_path / "repo" - proj_path = proj_root / "config" / "config.ini" - explicit_path = tmp_path / "explicit.ini" - write_ini(plat_path, "[app]\nversion = 2.0.0\n") - write_ini(proj_path, "[app]\nversion = 1.0.0\n") - write_ini(explicit_path, "[app]\nversion = 9.9.9\n") +def test_no_files_found_sets_safe_defaults(tmp_path: Path, monkeypatch): + """ + Fail path: nothing exists -> safe defaults must be present. + We also set HOME to isolate fallback candidate. + """ + monkeypatch.setenv("HOME", str(tmp_path / "home")) + svc = IniConfigService(explicit_path=None, project_root=None) - cfg = IniConfigService(explicit_path=explicit_path, project_root=proj_root) - assert cfg.app_version() == "9.9.9" - assert cfg.loaded_from == explicit_path + assert svc.loaded_from is None + assert svc.app_version() == "0.0.0" + assert svc.get("app", "version") == "0.0.0" -@pytest.mark.parametrize( - "raw, expected", - [ - ("42", 42), - (" 7 ", 7), - ("notanint", None), - ("", None), - ], -) -def test_get_int_parsing(monkeypatch, tmp_path, raw, expected): - monkeypatch.setattr( - "pymd.services.config.ini_config_service.user_config_dir", None, raising=False - ) - monkeypatch.setenv("HOME", str(tmp_path)) +# ------------------------------ +# get / get_int / get_bool success & fail paths +# ------------------------------ +def test_get_returns_default_for_missing_section_and_key(tmp_path: Path): + ini = tmp_path / "x.ini" + _write_ini(ini, "[app]\nversion = 1.0.0\n") + svc = IniConfigService(explicit_path=ini) + + assert svc.get("missing", "k", "d") == "d" + assert svc.get("app", "missing", "d2") == "d2" + - ini = tmp_path / ".config" / IniConfigService.DEFAULT_APP_DIR / IniConfigService.DEFAULT_FILE - write_ini(ini, f"[limits]\nmax = {raw}\n") +def test_get_int_success_and_fail_paths(tmp_path: Path): + ini = tmp_path / "x.ini" + _write_ini( + ini, + """ +[ui] +font_size = 16 +bad_int = sixteen +""".strip(), + ) + svc = IniConfigService(explicit_path=ini) - cfg = IniConfigService() - assert cfg.get_int("limits", "max", None) == expected + assert svc.get_int("ui", "font_size") == 16 # success + assert svc.get_int("ui", "bad_int", 12) == 12 # fail -> default + assert svc.get_int("ui", "missing", 11) == 11 # missing -> default + assert svc.get_int("missing", "x", 10) == 10 # missing section -> default @pytest.mark.parametrize( - "raw, expected", + "raw,expected", [ + ("1", True), ("true", True), - (" True ", True), - ("yes", True), + ("YES", True), + ("y", True), ("on", True), + ("0", False), ("false", False), - ("no", False), + ("No", False), + ("n", False), ("off", False), - ("0", False), - ("maybe", None), - ("", None), ], ) -def test_get_bool_parsing(monkeypatch, tmp_path, raw, expected): - monkeypatch.setattr( - "pymd.services.config.ini_config_service.user_config_dir", None, raising=False - ) - monkeypatch.setenv("HOME", str(tmp_path)) +def test_get_bool_truthy_falsy_success(tmp_path: Path, raw: str, expected: bool): + ini = tmp_path / "x.ini" + _write_ini(ini, f"[ui]\nflag = {raw}\n") + svc = IniConfigService(explicit_path=ini) - ini = tmp_path / ".config" / IniConfigService.DEFAULT_APP_DIR / IniConfigService.DEFAULT_FILE - write_ini(ini, f"[feature]\nenabled = {raw}\n") + assert svc.get_bool("ui", "flag") is expected - cfg = IniConfigService() - assert cfg.get_bool("feature", "enabled", None) is expected +def test_get_bool_fail_path_unknown_value_returns_default(tmp_path: Path): + ini = tmp_path / "x.ini" + _write_ini(ini, "[ui]\nflag = maybe\n") + svc = IniConfigService(explicit_path=ini) + + assert svc.get_bool("ui", "flag", default=True) is True + assert svc.get_bool("ui", "flag", default=False) is False -def test_as_dict_snapshot(monkeypatch, tmp_path): - monkeypatch.setattr( - "pymd.services.config.ini_config_service.user_config_dir", None, raising=False - ) - monkeypatch.setenv("HOME", str(tmp_path)) - ini = tmp_path / ".config" / IniConfigService.DEFAULT_APP_DIR / IniConfigService.DEFAULT_FILE - write_ini( +# ------------------------------ +# as_dict + app_version behaviour +# ------------------------------ +def test_as_dict_returns_snapshot_copy(tmp_path: Path): + ini = tmp_path / "x.ini" + _write_ini( ini, - "[app]\nversion = 3.1.4\n\n[ui]\nwrap = true\nzoom = 110\n", + """ +[app] +version = 3.3.3 + +[ui] +wrap = true +""".strip(), ) + svc = IniConfigService(explicit_path=ini) - cfg = IniConfigService() - snap = cfg.as_dict() - # Sections defined are present with their items; version should be as configured - assert snap.get("app", {}).get("version") == "3.1.4" - assert snap.get("ui", {}).get("wrap") == "true" - assert snap.get("ui", {}).get("zoom") == "110" + d = svc.as_dict() + assert d["app"]["version"] == "3.3.3" + assert d["ui"]["wrap"] == "true" + # ensure snapshot is a copy, not live view + d["ui"]["wrap"] = "false" + assert svc.get("ui", "wrap") == "true" -def test_malformed_config_is_ignored_and_defaults_used(monkeypatch, tmp_path): - # Force platformdirs path and write garbage there - def fake_user_config_dir(appname: str) -> str: - return str(tmp_path / "usercfg") - monkeypatch.setattr( - "pymd.services.config.ini_config_service.user_config_dir", - fake_user_config_dir, - raising=False, - ) - bad_path = ( - Path(fake_user_config_dir(IniConfigService.DEFAULT_APP_DIR)) / IniConfigService.DEFAULT_FILE - ) - bad_path.parent.mkdir(parents=True, exist_ok=True) - bad_path.write_text("this is not INI at all", encoding="utf-8") +def test_app_version_falls_back_to_default_when_empty_or_missing(tmp_path: Path): + # version key missing -> default injected + ini1 = tmp_path / "missing_version.ini" + _write_ini(ini1, "[app]\nname = x\n") + svc1 = IniConfigService(explicit_path=ini1) + assert svc1.app_version() == "0.0.0" - cfg = IniConfigService() - # It should not crash, loaded_from stays None, and defaults applied - assert cfg.loaded_from is None - assert cfg.app_version() == "0.0.0" + # version empty -> returns default '0.0.0' due to 'or "0.0.0"' + ini2 = tmp_path / "empty_version.ini" + _write_ini(ini2, "[app]\nversion =\n") + svc2 = IniConfigService(explicit_path=ini2) + assert svc2.app_version() == "0.0.0" -def test_home_fallback_used_when_platformdirs_missing(monkeypatch, tmp_path): - # No platformdirs - monkeypatch.setattr( - "pymd.services.config.ini_config_service.user_config_dir", None, raising=False - ) - # Point HOME to tmp so fallback path is predictable - monkeypatch.setenv("HOME", str(tmp_path)) +# ------------------------------ +# Optional: platformdirs fallback path (HOME/.config/...) +# ------------------------------ +def test_fallback_home_config_path_used_when_no_platformdirs(tmp_path: Path, monkeypatch): + """ + Forces the 'no platformdirs' branch by patching module-level user_config_dir to None, + then verifies HOME/.config/PyMarkdownEditor/config.ini is discovered. + """ + # Import module to patch its symbol + import pymd.services.config.ini_config_service as mod - # Create ~/.config/PyMarkdownEditor/config.ini - fb_path = ( - tmp_path / ".config" / IniConfigService.DEFAULT_APP_DIR / IniConfigService.DEFAULT_FILE - ) - write_ini(fb_path, "[app]\nversion = 4.5.6\n") + monkeypatch.setattr(mod, "user_config_dir", None, raising=True) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + + expected = tmp_path / "home" / ".config" / "PyMarkdownEditor" / "config.ini" + _write_ini(expected, "[app]\nversion = 7.7.7\n") + + svc = IniConfigService(explicit_path=None, project_root=None) - cfg = IniConfigService() - assert cfg.app_version() == "4.5.6" - assert cfg.loaded_from == fb_path + assert svc.loaded_from == expected + assert svc.app_version() == "7.7.7" diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 9d5846c..8cf9e06 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -1,9 +1,13 @@ +# tests/test_main_window.py from __future__ import annotations +from dataclasses import dataclass from pathlib import Path +from typing import Any import pytest -from PyQt6.QtCore import QSettings +from PyQt6.QtCore import QEvent, QSettings, Qt +from PyQt6.QtGui import QKeyEvent from PyQt6.QtWidgets import QMessageBox, QTextBrowser, QTextEdit from pymd.services.exporters.base import IExporter, IExporterRegistry @@ -14,10 +18,47 @@ from pymd.utils.constants import MAX_RECENTS -# Force the preview widget to be QTextBrowser in tests (avoids WebEngine crashes/headless issues) +# ------------------------------ +# Minimal config + dialog stubs +# ------------------------------ +@dataclass(frozen=True) +class DummyConfig: + """ + Keep this deliberately permissive: AboutDialog may access various config attributes. + Unknown attributes return a sensible empty string. + """ + + app_name: str = "PyMarkdownEditor" + version: str = "1.0.0" + website: str = "" + repo_url: str = "" + author: str = "" + copyright: str = "" + + def __getattr__(self, _: str) -> str: # for any other attribute AboutDialog may touch + return "" + + +class DummyAboutDialog: + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._shown = False + + def show(self) -> None: + self._shown = True + + def raise_(self) -> None: + return None + + def activateWindow(self) -> None: + return None + + +# ------------------------------ +# Force the preview widget to be QTextBrowser in tests (avoid WebEngine) +# ------------------------------ @pytest.fixture(autouse=True) def force_textbrowser_preview(monkeypatch): - def _factory(self): + def _factory(self: MainWindow): w = QTextBrowser(self) w.setOpenExternalLinks(True) return w @@ -30,10 +71,6 @@ def _factory(self): # ------------------------------ @pytest.fixture(autouse=True) def auto_accept_discard(monkeypatch): - """ - Avoid interactive 'Discard changes?' popups during tests. - Always answer Yes. - """ monkeypatch.setattr( "pymd.services.ui.main_window.QMessageBox.question", lambda *a, **k: QMessageBox.StandardButton.Yes, @@ -41,11 +78,9 @@ def auto_accept_discard(monkeypatch): # ------------------------------ -# Fakes & helpers +# Export fakes # ------------------------------ class DummyExporter(IExporter): - """Tiny exporter used for tests; writes the HTML to a .txt file.""" - file_ext = "txt" @property @@ -61,8 +96,6 @@ def export(self, html: str, out_path: Path) -> None: class FakeExporterRegistry(IExporterRegistry): - """Pure in-memory registry to avoid any global/singleton bleed across tests.""" - def __init__(self) -> None: self._map: dict[str, IExporter] = {} @@ -73,10 +106,9 @@ def unregister(self, name: str) -> None: self._map.pop(name, None) def get(self, name: str) -> IExporter: - try: - return self._map[name] - except KeyError: - raise KeyError(name) # noqa: B904 + if name not in self._map: + raise KeyError(name) + return self._map[name] def all(self) -> list[IExporter]: return list(self._map.values()) @@ -90,22 +122,27 @@ def exporter_registry() -> IExporterRegistry: @pytest.fixture() -def window(qapp, tmp_path, exporter_registry: IExporterRegistry) -> MainWindow: +def window(qapp, tmp_path: Path, exporter_registry: IExporterRegistry, monkeypatch) -> MainWindow: """ - Build a MainWindow with file-based QSettings (isolated per test) and an injected - exporter registry that contains a simple DummyExporter. + Build a MainWindow with file-backed QSettings (isolated per test) and a stub AboutDialog, + plus an injected exporter registry containing DummyExporter. """ + # Avoid coupling to AboutDialog internals/expected config attributes + monkeypatch.setattr("pymd.services.ui.main_window.AboutDialog", DummyAboutDialog, raising=True) + qs = QSettings(str(tmp_path / "settings.ini"), QSettings.Format.IniFormat) renderer = MarkdownRenderer() files = FileService() settings = SettingsService(qs) + w = MainWindow( + app_title="Test", + config=DummyConfig(), renderer=renderer, file_service=files, settings=settings, exporter_registry=exporter_registry, start_path=None, - app_title="Test", ) w.show() qapp.processEvents() @@ -118,10 +155,11 @@ def window(qapp, tmp_path, exporter_registry: IExporterRegistry) -> MainWindow: def test_window_initial_state(window: MainWindow): assert window.doc.path is None assert window.doc.modified is False + assert isinstance(window.editor, QTextEdit) + assert isinstance(window.preview, QTextBrowser) - # Preview should render empty doc to valid HTML - html = window.preview.toHtml() - assert " modified window.editor.setPlainText("# Hello\nWorld") assert window.doc.modified is True - # save as dest = tmp_path / "b.md" assert window._write_to(dest) is True assert dest.read_text(encoding="utf-8").endswith("World") @@ -144,71 +180,61 @@ def test_window_open_save_cycle(tmp_path: Path, window: MainWindow): def test_window_write_failure_shows_error(monkeypatch, tmp_path: Path, window: MainWindow): - # Neutralize blocking dialog monkeypatch.setattr(QMessageBox, "critical", lambda *a, **k: None) - # Simulate write failure in FileService - def boom(path: Path, text: str) -> None: + def boom(_self: Any, _path: Path, _text: str) -> None: raise OSError("disk full") monkeypatch.setattr(type(window.file_service), "write_text_atomic", boom, raising=False) assert window._write_to(tmp_path / "bad.md") is False -def test_window_export_action_flows_through_registry( - monkeypatch, tmp_path: Path, window: MainWindow -): - # Put some content and render once +def test_export_action_flows_through_registry(monkeypatch, tmp_path: Path, window: MainWindow): window.editor.setPlainText("# Title\n\nText") - # Pick the first export action created from the registry (our DummyExporter only) - assert window.export_actions, "No export actions were created" + assert window.export_actions act = window.export_actions[0] out = tmp_path / "doc.txt" - # Drive the QFileDialog used by _export_with monkeypatch.setattr( "pymd.services.ui.main_window.QFileDialog.getSaveFileName", lambda *a, **k: (str(out), ""), ) - # Trigger export via the UI action act.trigger() data = out.read_text(encoding="utf-8").lower() - assert data.startswith(" None: + c = w.editor.textCursor() + c.setPosition(start) + c.setPosition(end, c.MoveMode.KeepAnchor) + w.editor.setTextCursor(c) + + +def test_bold_toggle_wraps_and_unwraps(window: MainWindow): window.editor.setPlainText("hello") - c = window.editor.textCursor() - c.setPosition(0) - c.setPosition(5, c.MoveMode.KeepAnchor) - window.editor.setTextCursor(c) + _select_range(window, 0, 5) window.act_bold.trigger() assert window.editor.toPlainText() == "**hello**" - # Prefix line with "# " + # toggle off by selecting wrapped value + window.editor.setPlainText("**hello**") + _select_range(window, 0, len("**hello**")) + window.act_bold.trigger() + assert window.editor.toPlainText() == "hello" + + +def test_italic_toggle_handles_whitespace(window: MainWindow): + window.editor.setPlainText(" hello ") + _select_range(window, 0, len(" hello ")) + window.act_italic.trigger() + assert window.editor.toPlainText() == "_ hello _" # preserves original spacing + + # toggle off even if selection includes whitespace by stripping logic + window.editor.setPlainText(" _hello_ ") + _select_range(window, 0, len(" _hello_ ")) + window.act_italic.trigger() + assert window.editor.toPlainText() == "hello" + + +def test_header_prefix_toggle(window: MainWindow): window.editor.setPlainText("line1\nline2") - c = window.editor.textCursor() - c.setPosition(0) - window.editor.setTextCursor(c) + _select_range(window, 0, 0) # caret on line1 window.act_h1.trigger() assert window.editor.toPlainText().startswith("# line1") - # Multi-line prefix with "- " (full selection) + # toggle back to none + window.act_h1.trigger() + assert window.editor.toPlainText().startswith("line1") + + +def test_prefix_line_multiline_selection(window: MainWindow): window.editor.setPlainText("a\nb\nc") - c = window.editor.textCursor() - c.setPosition(0) - c.movePosition(c.MoveOperation.End, c.MoveMode.KeepAnchor) - window.editor.setTextCursor(c) + _select_range(window, 0, len("a\nb\nc")) window.act_list.trigger() - text = window.editor.toPlainText().splitlines() - assert text[0].startswith("- ") - assert text[1].startswith("- ") - assert text[2].startswith("- ") + assert window.editor.toPlainText().splitlines() == ["- a", "- b", "- c"] def test_prefix_line_partial_multiline_selection(window: MainWindow): - # Ensure only full selected lines are prefixed, no off-by-one past end. window.editor.setPlainText("a\nb\nc\n") - c = window.editor.textCursor() - # Select from start of 'b' line through newline after 'b' (not into 'c') - # Positions: "a\nb\nc\n" -> 0 a,1 \n,2 b,3 \n,4 c,5 \n - c.setPosition(2) # start of 'b' - c.setPosition(4, c.MoveMode.KeepAnchor) # up to '\n' after 'b' - window.editor.setTextCursor(c) + # select only 'b' line (positions: 0 a,1 \n,2 b,3 \n,4 c,5 \n) + _select_range(window, 2, 4) window.act_list.trigger() assert window.editor.toPlainText().splitlines()[:3] == ["a", "- b", "c"] # ------------------------------ -# Find / Replace coverage +# Smart paste: URL -> Markdown link # ------------------------------ -@pytest.mark.parametrize( - "case,whole,needle,expected", - [ - (False, False, "foo", ["foo", "foo", "FOO"]), # substring & case-insensitive - (False, True, "foo", ["foo", "FOO"]), # whole words only; "foobar" excluded - (True, True, "FOO", ["FOO"]), # case + whole word - ], -) -def test_find_options_whole_and_case(window: MainWindow, qapp, case, whole, needle, expected): - window.editor.setPlainText("foo foobar FOO") - dlg = window.find_dialog - dlg.find_edit.setText(needle) - dlg.case_cb.setChecked(case) - dlg.word_cb.setChecked(whole) - dlg.wrap_cb.setChecked(True) - - # Gather forward hits until cursor selection wraps or no selection - hits: list[str] = [] - seen_starts: set[int] = set() - - # perform first find - dlg.find(forward=True) - qapp.processEvents() +def test_paste_as_markdown_link_with_selection(monkeypatch, window: MainWindow, qapp): + cb = qapp.clipboard() + cb.setText("https://example.com") + + window.editor.setPlainText("Click here") + _select_range(window, 0, len("Click here")) + + handled = window._paste_as_markdown_link_if_applicable() + assert handled is True + assert window.editor.toPlainText() == "[Click here](https://example.com)" + - while window.editor.textCursor().hasSelection(): - cur = window.editor.textCursor() - start = cur.selectionStart() - if start in seen_starts: - break # wrapped/cycled - seen_starts.add(start) - hits.append(cur.selectedText()) - dlg.find(forward=True) - qapp.processEvents() - - assert hits == expected - - -def test_find_whole_word_ignores_substrings_with_punct(window: MainWindow, qapp): - window.editor.setPlainText("foo, foobar foo.") - d = window.find_dialog - d.find_edit.setText("foo") - d.word_cb.setChecked(True) - d.case_cb.setChecked(False) - d.wrap_cb.setChecked(True) - - hits = [] - seen_starts: set[int] = set() - for _ in range(5): - d.find(forward=True) - qapp.processEvents() - cur = window.editor.textCursor() - if not cur.hasSelection(): - break - start = cur.selectionStart() - if start in seen_starts: - break # wrapped/cycled - seen_starts.add(start) - hits.append(cur.selectedText()) - - assert hits == ["foo", "foo"] - - -def test_find_backward_wraps(window: MainWindow, qapp): - window.editor.setPlainText("a foo b foo c") - dlg = window.find_dialog - dlg.find_edit.setText("foo") - dlg.case_cb.setChecked(False) - dlg.word_cb.setChecked(True) - dlg.wrap_cb.setChecked(True) - - # Move caret to start then search backward (forces wrap to last) +def test_paste_as_markdown_link_no_selection_places_cursor_inside_brackets( + monkeypatch, window: MainWindow, qapp +): + cb = qapp.clipboard() + cb.setText("www.example.com") + + window.editor.setPlainText("") c = window.editor.textCursor() - c.movePosition(c.MoveOperation.Start) + c.setPosition(0) window.editor.setTextCursor(c) - dlg.find(forward=False) - qapp.processEvents() - sel = window.editor.textCursor().selectedText() - assert sel == "foo" - # and ensure it's the *second* occurrence (after wrap) - assert window.editor.textCursor().selectionStart() > 5 # past "a foo " + handled = window._paste_as_markdown_link_if_applicable() + assert handled is True + assert window.editor.toPlainText() == "[](https://www.example.com)" + # Cursor should be between [ and ] + cur = window.editor.textCursor() + assert cur.position() == 1 -def test_replace_one_and_all(window: MainWindow, qapp): - # Replace-one behavior - window.editor.setPlainText("alpha beta alpha") - window.act_replace.trigger() - qapp.processEvents() +def test_paste_as_markdown_link_ignores_non_url(window: MainWindow, qapp): + qapp.clipboard().setText("not a url") + window.editor.setPlainText("") + assert window._paste_as_markdown_link_if_applicable() is False - dlg = window.find_dialog - dlg.find_edit.setText("alpha") - dlg.replace_edit.setText("ALPHA") - dlg.case_cb.setChecked(False) - dlg.word_cb.setChecked(True) - dlg.wrap_cb.setChecked(True) - # Find first, then replace one - dlg.find(forward=True) - qapp.processEvents() - dlg.replace_one() - qapp.processEvents() - assert window.editor.toPlainText().startswith("ALPHA beta") +def test_event_filter_ctrl_v_handles_url(monkeypatch, window: MainWindow, qapp): + qapp.clipboard().setText("https://example.com") + window.editor.setPlainText("") + window.editor.setFocus() - # Replace all remaining - dlg.replace_all() - qapp.processEvents() - assert "ALPHA beta ALPHA" == window.editor.toPlainText() + ev = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_V, Qt.KeyboardModifier.ControlModifier) + handled = window.eventFilter(window.editor, ev) + assert handled is True + assert window.editor.toPlainText() == "[](https://example.com)" -# ------------------------------ -# Image & Link actions -# ------------------------------ -def test_insert_image_inserts_img_tag(monkeypatch, window: MainWindow, qapp, tmp_path: Path): - fake_img = tmp_path / "pic.png" - # Minimal PNG header; Qt may still log but shouldn't crash - fake_img.write_bytes(b"\x89PNG\r\n\x1a\n") +def test_event_filter_ctrl_b_ctrl_i_require_selection(window: MainWindow): + window.editor.setPlainText("hi") + window.editor.setFocus() - # Stub the file dialog to return our image path - monkeypatch.setattr( - "pymd.services.ui.main_window.QFileDialog.getOpenFileName", - lambda *a, **k: (str(fake_img), "PNG (*.png)"), - ) + # no selection -> should do nothing but event is handled (it intercepts) + ev_b = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_B, Qt.KeyboardModifier.ControlModifier) + handled_b = window.eventFilter(window.editor, ev_b) + assert handled_b is True + assert window.editor.toPlainText() == "hi" + ev_i = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_I, Qt.KeyboardModifier.ControlModifier) + handled_i = window.eventFilter(window.editor, ev_i) + assert handled_i is True + assert window.editor.toPlainText() == "hi" + + +def test_event_filter_ctrl_e_inserts_simple_code_block(window: MainWindow): window.editor.setPlainText("start") c = window.editor.textCursor() c.movePosition(c.MoveOperation.End) window.editor.setTextCursor(c) - window.act_img.trigger() - qapp.processEvents() + ev = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_E, Qt.KeyboardModifier.ControlModifier) + handled = window.eventFilter(window.editor, ev) + assert handled is True text = window.editor.toPlainText() - assert ' None: + self.api: Any | None = None + self.reload_calls = 0 - monkeypatch.setattr(window.link_dialog, "show_create_link", fake_show) - window.act_link.trigger() - qapp.processEvents() - assert called["ok"] is True + def set_api(self, api: Any) -> None: + self.api = api + def reload(self) -> None: + self.reload_calls += 1 -# ------------------------------ -# Inline code & code block actions -# ------------------------------ -def test_act_code_wraps_selection_or_inserts_tick(window: MainWindow, qapp): - # Wraps selection with backticks - window.editor.setPlainText("abc") - c = window.editor.textCursor() - c.setPosition(1) # after 'a' - c.setPosition(3, c.MoveMode.KeepAnchor) # select 'bc' - window.editor.setTextCursor(c) - window.act_code.trigger() - qapp.processEvents() - assert window.editor.toPlainText() == "a`bc`" + # Provide one action via iter_enabled_actions + class _Spec: + title = "Do Thing" + shortcut = "Ctrl+Alt+D" + status_tip = "Run plugin thing" - # With no selection, inserts a single backtick at caret - window.editor.setPlainText("hi") - c = window.editor.textCursor() - c.movePosition(c.MoveOperation.End) - window.editor.setTextCursor(c) - window.act_code.trigger() - qapp.processEvents() - assert window.editor.toPlainText() == "hi`" + def iter_enabled_actions(self, _api: Any): + def handler(api: Any) -> None: + api.insert_text_at_cursor("PLUGIN!") + return [(self._Spec(), handler)] -def test_act_code_block_inserts_fence_with_lang_and_places_caret( - monkeypatch, window: MainWindow, qapp -): - # Force language selection to 'python' - monkeypatch.setattr( - "pymd.services.ui.main_window.QInputDialog.getItem", - lambda *a, **k: ("python", True), - ) - window.editor.setPlainText("start") - c = window.editor.textCursor() - c.movePosition(c.MoveOperation.End) - window.editor.setTextCursor(c) +class FakePluginInstaller: + pass - window.act_code_block.trigger() - qapp.processEvents() - text = window.editor.toPlainText() - # Newline is inserted before fence if not already at BOL - assert text.endswith("```python\n\n```\n") - # Caret should be on the blank line between fences - cur = window.editor.textCursor() - assert cur.block().text() == "" # blank line inside the block +def test_attach_plugins_sets_api_and_does_not_reload(window: MainWindow): + pm = FakePluginManager() + pi = FakePluginInstaller() + window.attach_plugins(plugin_manager=pm, plugin_installer=pi) -def test_act_code_block_inserts_fence_without_lang_when_none_selected( - monkeypatch, window: MainWindow, qapp -): - # User selects "(none)" i.e., empty string - monkeypatch.setattr( - "pymd.services.ui.main_window.QInputDialog.getItem", - lambda *a, **k: ("", True), - ) + assert pm.api is not None # API injected + assert pm.reload_calls == 0 # attach_plugins must not call reload() - window.editor.setPlainText("") - window.act_code_block.trigger() - qapp.processEvents() - text = window.editor.toPlainText() - # No language suffix on the opening fence - assert text.startswith("```\n\n```\n") - # Caret on the blank line inside the block - cur = window.editor.textCursor() - assert cur.block().text() == "" +def test_rebuild_plugin_actions_adds_actions_to_tools_menu(window: MainWindow): + pm = FakePluginManager() + window.attach_plugins(plugin_manager=pm, plugin_installer=FakePluginInstaller()) + # Trigger the newly added plugin action + # The Tools menu already contains Plugins… and a separator; plugin action appended after. + tools_menu = window._plugins_menu + assert tools_menu is not None -# ------------------------------ -# Extra edge cases from review -# ------------------------------ -def test_open_dialog_cancel(monkeypatch, window: MainWindow): - monkeypatch.setattr( - "pymd.services.ui.main_window.QFileDialog.getOpenFileName", - lambda *a, **k: ("", ""), # canceled - ) - window.editor.setPlainText("x") - window._open_dialog() - # nothing changed (still untitled, still has our text) - assert window.doc.path is None - assert window.editor.toPlainText() == "x" + # Find our QAction by text + actions = [a for a in tools_menu.actions() if a.text() == "Do Thing"] + assert actions, "Expected plugin action not found" + act = actions[0] + # Put cursor at end and trigger + window.editor.setPlainText("X") + c = window.editor.textCursor() + c.movePosition(c.MoveOperation.End) + window.editor.setTextCursor(c) -def test_save_as_cancel(monkeypatch, window: MainWindow): - window.editor.setPlainText("x") - monkeypatch.setattr( - "pymd.services.ui.main_window.QFileDialog.getSaveFileName", - lambda *a, **k: ("", ""), # canceled - ) - window._save_as() - assert window.doc.path is None # not changed + act.trigger() + assert window.editor.toPlainText() == "XPLUGIN!" -def test_export_cancel(monkeypatch, window: MainWindow): - window.editor.setPlainText("# hi") - act = window.export_actions[0] +def test_show_plugins_manager_unavailable_shows_info(monkeypatch, window: MainWindow): + called = {"n": 0} monkeypatch.setattr( - "pymd.services.ui.main_window.QFileDialog.getSaveFileName", - lambda *a, **k: ("", ""), # canceled + QMessageBox, "information", lambda *a, **k: called.__setitem__("n", called["n"] + 1) ) - # should no-op without exceptions - act.trigger() - -def test_export_failure_shows_error(monkeypatch, window: MainWindow, tmp_path: Path): - class BoomExporter(DummyExporter): - @property - def name(self) -> str: - return "boom" + window.attach_plugins(plugin_manager=None, plugin_installer=None) + window._show_plugins_manager() - @property - def label(self) -> str: - return "Export BOOM" + assert called["n"] == 1 - def export(self, html: str, out_path: Path) -> None: - raise RuntimeError("boom") - out = tmp_path / "x.boom" - monkeypatch.setattr( - "pymd.services.ui.main_window.QFileDialog.getSaveFileName", - lambda *a, **k: (str(out), ""), - ) +# ------------------------------ +# Themes: apply + persistence +# ------------------------------ +def test_apply_theme_sets_setting(window: MainWindow): + window.apply_theme("midnight") + assert window.settings.get_raw("ui/theme", "default") == "midnight" - called = {"n": 0} - monkeypatch.setattr( - "pymd.services.ui.main_window.QMessageBox.critical", - lambda *a, **k: called.__setitem__("n", called["n"] + 1), - ) + window.apply_theme("paper") + assert window.settings.get_raw("ui/theme", "default") == "paper" - # Call the export path directly with a failing exporter (actions list isn't rebuilt) - window._export_with(BoomExporter()) - assert called["n"] == 1 + window.apply_theme("default") + assert window.settings.get_raw("ui/theme", "default") == "default" +# ------------------------------ +# Recents: dedup + cap +# ------------------------------ def test_recents_dedup_and_order(window: MainWindow, tmp_path: Path): a = tmp_path / "a.md" b = tmp_path / "b.md" - a.write_text("a") - b.write_text("b") + a.write_text("a", encoding="utf-8") + b.write_text("b", encoding="utf-8") + window._open_path(a) window._open_path(b) window._open_path(a) # bring to front again + assert window.recents[:2] == [str(a), str(b)] def test_recents_max_enforced(window: MainWindow, tmp_path: Path): - files = [] + opened: list[str] = [] for i in range(0, 2 * MAX_RECENTS): p = tmp_path / f"f{i}.md" - p.write_text("x") + p.write_text("x", encoding="utf-8") window._open_path(p) - files.append(str(p)) - assert len(window.recents) == MAX_RECENTS - # newest first - assert window.recents[0] == files[-1] + opened.append(str(p)) - -def test_status_message_on_save(window: MainWindow, tmp_path: Path): - p = tmp_path / "z.md" - window.editor.setPlainText("ok") - assert window._write_to(p) - # QStatusBar text is transient; check it is non-empty immediately - assert window.statusBar().currentMessage() != "" + assert len(window.recents) == MAX_RECENTS + assert window.recents[0] == opened[-1] diff --git a/version b/version index 80e0d76..795460f 100644 --- a/version +++ b/version @@ -1 +1 @@ -v1.0.5 +v1.1.0