"""Guide / Quickstart tab - markdown-driven in-app documentation.""" from __future__ import annotations import json from pathlib import Path from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QTextBrowser, QVBoxLayout, QWidget, ) from util.paths import HELP_DIR try: import markdown as _markdown except ImportError: # pragma: no cover - dependency should be installed via requirements _markdown = None _BROWSER_CSS = """ body { color: #d4d4d4; background-color: #1e1e1e; font-family: Segoe UI, sans-serif; font-size: 14px; line-height: 1.45; margin: 12px 16px; } h1, h2, h3, h4 { color: #ffffff; font-weight: 600; margin-top: 1.1em; margin-bottom: 0.4em; } h1 { font-size: 22px; } h2 { font-size: 18px; border-bottom: 1px solid #3c3c3c; padding-bottom: 4px; } h3 { font-size: 15px; color: #e0e0e0; } p, li { color: #d4d4d4; } a { color: #569cd6; } code { background-color: #2d2d30; color: #ce9178; padding: 1px 5px; border-radius: 3px; font-family: Consolas, monospace; font-size: 13px; } pre { background-color: #252526; border: 1px solid #3c3c3c; border-radius: 4px; padding: 10px 12px; overflow-x: auto; } pre code { background: transparent; color: #d4d4d4; padding: 0; } table { border-collapse: collapse; margin: 10px 0; width: 100%; } th, td { border: 1px solid #3c3c3c; padding: 6px 10px; text-align: left; vertical-align: top; } th { background-color: #2d2d30; color: #ffffff; } strong { color: #ffffff; } hr { border: none; border-top: 1px solid #3c3c3c; margin: 16px 0; } blockquote { border-left: 3px solid #007acc; margin: 10px 0; padding: 4px 12px; color: #9d9d9d; background-color: #252526; } """ _LIST_STYLE = """ QListWidget { background-color: #252526; color: #cccccc; border: 1px solid #3c3c3c; border-radius: 4px; outline: none; font-size: 13px; padding: 4px; } QListWidget::item { padding: 8px 10px; border-radius: 3px; } QListWidget::item:selected { background-color: #094771; color: #ffffff; } QListWidget::item:hover:!selected { background-color: #2a2d2e; } """ _BROWSER_STYLE = """ QTextBrowser { background-color: #1e1e1e; color: #d4d4d4; border: 1px solid #3c3c3c; border-radius: 4px; padding: 0; } """ _BTN_STYLE = """ QPushButton { background-color: #0e639c; color: #ffffff; border: none; border-radius: 4px; padding: 8px 14px; font-size: 13px; } QPushButton:hover { background-color: #1177bb; } QPushButton:pressed { background-color: #0a4d78; } """ _SECONDARY_BTN_STYLE = """ QPushButton { background-color: #3c3c3c; color: #d4d4d4; border: 1px solid #555555; border-radius: 4px; padding: 8px 14px; font-size: 13px; } QPushButton:hover { background-color: #4a4a4a; } """ def _md_to_html(source: str) -> str: if _markdown is not None: body = _markdown.markdown( source, extensions=["fenced_code", "tables", "nl2br", "sane_lists"], ) else: escaped = ( source.replace("&", "&") .replace("<", "<") .replace(">", ">") ) body = f"
{escaped}"
return (
""
f""
f"{body}"
)
class GuideTab(QWidget):
"""Sidebar Guide page: section list + rendered markdown from data/help/."""
def __init__(self, parent=None, help_dir: Path | None = None):
super().__init__(parent)
self.parent_window = parent
self.help_dir = Path(help_dir) if help_dir else HELP_DIR
self._sections: list[dict] = []
self._build_ui()
self.reload()
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(20, 16, 20, 16)
root.setSpacing(10)
title = QLabel("Guide / Quickstart")
title.setStyleSheet("color:#ffffff;font-size:18px;font-weight:bold;")
root.addWidget(title)
intro = QLabel(
"In-app docs for requirements, Workflow, and copy-paste examples. "
"Edit markdown under data/help/ to keep this up to date."
)
intro.setWordWrap(True)
intro.setStyleSheet("color:#9d9d9d;font-size:13px;")
root.addWidget(intro)
body = QHBoxLayout()
body.setSpacing(12)
self.section_list = QListWidget()
self.section_list.setFixedWidth(200)
self.section_list.setStyleSheet(_LIST_STYLE)
self.section_list.currentRowChanged.connect(self._on_section_changed)
body.addWidget(self.section_list)
self.browser = QTextBrowser()
self.browser.setOpenExternalLinks(True)
self.browser.setStyleSheet(_BROWSER_STYLE)
body.addWidget(self.browser, 1)
root.addLayout(body, 1)
footer = QHBoxLayout()
footer.setSpacing(8)
self.btn_workflow = QPushButton("Open Workflow")
self.btn_workflow.setStyleSheet(_BTN_STYLE)
self.btn_workflow.setCursor(Qt.PointingHandCursor)
self.btn_workflow.clicked.connect(self._open_workflow)
footer.addWidget(self.btn_workflow)
self.btn_config = QPushButton("Open Configuration")
self.btn_config.setStyleSheet(_SECONDARY_BTN_STYLE)
self.btn_config.setCursor(Qt.PointingHandCursor)
self.btn_config.clicked.connect(self._open_config)
footer.addWidget(self.btn_config)
footer.addStretch()
reload_btn = QPushButton("Reload")
reload_btn.setFixedWidth(90)
reload_btn.setStyleSheet(_SECONDARY_BTN_STYLE)
reload_btn.setToolTip("Reload data/help/index.json and the current section")
reload_btn.clicked.connect(self.reload)
footer.addWidget(reload_btn)
root.addLayout(footer)
def reload(self) -> None:
"""Reload the section index and re-select the current (or first) section."""
previous_id = None
row = self.section_list.currentRow()
if 0 <= row < len(self._sections):
previous_id = self._sections[row].get("id")
self._sections = self._load_index()
self.section_list.blockSignals(True)
self.section_list.clear()
select_row = 0
for i, section in enumerate(self._sections):
title = section.get("title") or section.get("id") or f"Section {i + 1}"
item = QListWidgetItem(title)
item.setData(Qt.UserRole, section)
self.section_list.addItem(item)
if previous_id and section.get("id") == previous_id:
select_row = i
self.section_list.blockSignals(False)
if self._sections:
self.section_list.setCurrentRow(select_row)
else:
self.browser.setHtml(
_md_to_html(
"# Guide unavailable\n\n"
f"No sections found under `{self.help_dir}`.\n"
"Add `index.json` and markdown files to restore this page."
)
)
def _load_index(self) -> list[dict]:
index_path = self.help_dir / "index.json"
if not index_path.is_file():
return []
try:
data = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
if not isinstance(data, list):
return []
return [s for s in data if isinstance(s, dict) and s.get("file")]
def _on_section_changed(self, row: int) -> None:
if row < 0 or row >= len(self._sections):
return
section = self._sections[row]
rel = section.get("file", "")
path = self.help_dir / rel
if not path.is_file():
self.browser.setHtml(
_md_to_html(f"# Missing file\n\nCould not find `{rel}` under `data/help/`.")
)
return
try:
source = path.read_text(encoding="utf-8")
except OSError as exc:
self.browser.setHtml(_md_to_html(f"# Read error\n\n{exc}"))
return
self.browser.setHtml(_md_to_html(source))
self.browser.verticalScrollBar().setValue(0)
def show_section(self, section_id: str) -> bool:
"""Select a section by id. Returns True if found."""
for i, section in enumerate(self._sections):
if section.get("id") == section_id:
self.section_list.setCurrentRow(i)
return True
return False
def _open_workflow(self) -> None:
pw = self.parent_window
if pw is not None and hasattr(pw, "switch_page"):
page = getattr(pw, "PAGE_WORKFLOW", 1)
pw.switch_page(page)
def _open_config(self) -> None:
pw = self.parent_window
if pw is not None and hasattr(pw, "switch_page"):
page = getattr(pw, "PAGE_CONFIG", 6)
pw.switch_page(page)