Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 69 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand All @@ -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`

---

Expand All @@ -88,7 +125,7 @@ Recommended host wiring:
plugin_manager.set_api(app_api)
plugin_manager.reload()
plugin_manager.on_app_ready()
```
````

Hooks (optional):

Expand Down Expand Up @@ -126,7 +163,7 @@ SOLID-leaning, layered design:
* Strategy-based exporters
* Explicit plugin lifecycle
* Deterministic startup
* Test-safe QtWebEngine behavior
* Test-safe QtWebEngine behaviour

---

Expand Down Expand Up @@ -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.

---

Expand All @@ -229,6 +267,7 @@ All actions exposed via toolbar + menus.
│ │ ├── state.py
│ │ └── builtin/
│ ├── services/
│ │ ├── config/
│ │ ├── exporters/
│ │ ├── file_service.py
│ │ ├── markdown_renderer.py
Expand Down Expand Up @@ -343,7 +382,7 @@ Triggered by version tags.

### Missing pymdownx

```
```bash
pip install pymdown-extensions
```

Expand Down
23 changes: 15 additions & 8 deletions pymd/di/container.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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.")

Expand Down Expand Up @@ -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
Expand All @@ -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,
*,
Expand Down
9 changes: 9 additions & 0 deletions pymd/domain/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
100 changes: 100 additions & 0 deletions pymd/services/config/app_config.py
Original file line number Diff line number Diff line change
@@ -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 <root>/version file.

Precedence for version:
1) <project_root>/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)
Loading
Loading