Skip to content

Commit eff2119

Browse files
committed
Unify rich help rendering and widget projection
1 parent aa12f1f commit eff2119

10 files changed

Lines changed: 1119 additions & 327 deletions

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pyqt-reactive"
7-
version = "0.1.23"
7+
version = "0.1.24"
88
description = "React-quality reactive form generation framework for PyQt6"
99
authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}]
1010
license = {text = "MIT"}
@@ -24,6 +24,7 @@ classifiers = [
2424
keywords = ["pyqt6", "forms", "reactive", "gui", "widgets", "dataclass"]
2525

2626
dependencies = [
27+
"docutils>=0.20",
2728
"magicgui>=0.7.0",
2829
"metaclass-registry>=0.1.5",
2930
"PyQt6>=6.4.0",

src/pyqt_reactive/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
- Cross-window reactive updates
2020
"""
2121

22-
__version__ = "0.1.23"
22+
__version__ = "0.1.24"
2323

2424
# Public API will be populated as modules are added
2525
__all__ = [
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Generic rich help-document content shared by help surfaces."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from dataclasses import dataclass, replace
7+
from enum import StrEnum
8+
from pathlib import PurePath
9+
from typing import Protocol
10+
11+
from docutils.core import publish_parts
12+
13+
DEFAULT_HELP_DOCUMENT_MAX_CHARS = 50_000
14+
15+
16+
class HelpDocumentFormat(StrEnum):
17+
"""Markup format owned by one help document."""
18+
19+
PLAIN_TEXT = "plain_text"
20+
MARKDOWN = "markdown"
21+
RESTRUCTURED_TEXT = "restructured_text"
22+
23+
@classmethod
24+
def from_source_path(cls, source_path: str) -> HelpDocumentFormat:
25+
"""Resolve markup from a source document's declared filename."""
26+
suffix = PurePath(source_path).suffix.casefold()
27+
if suffix in {".md", ".markdown"}:
28+
return cls.MARKDOWN
29+
if suffix == ".rst":
30+
return cls.RESTRUCTURED_TEXT
31+
return cls.PLAIN_TEXT
32+
33+
34+
class DocstringInfoLike(Protocol):
35+
"""Structured callable/class documentation consumed by the renderer."""
36+
37+
summary: str | None
38+
description: str | None
39+
parameters: dict[str, str] | None
40+
returns: str | None
41+
examples: str | None
42+
43+
44+
@dataclass(frozen=True, slots=True)
45+
class HelpDocument:
46+
"""One renderer-ready help document with explicit markup provenance."""
47+
48+
content: str
49+
markup: HelpDocumentFormat = HelpDocumentFormat.PLAIN_TEXT
50+
title: str | None = None
51+
base_url: str | None = None
52+
53+
@classmethod
54+
def from_docstring_info(
55+
cls,
56+
docstring_info: DocstringInfoLike,
57+
*,
58+
title: str | None = None,
59+
) -> HelpDocument:
60+
"""Project structured introspection into one Markdown document."""
61+
sections: list[str] = []
62+
if title:
63+
sections.append(f"# {_markdown_heading(title)}")
64+
if docstring_info.summary:
65+
sections.append(f"**{_markdown_inline(docstring_info.summary)}**")
66+
if docstring_info.description:
67+
sections.append(docstring_info.description.strip())
68+
if docstring_info.parameters:
69+
parameter_sections = ["## Parameters"]
70+
for name, description in docstring_info.parameters.items():
71+
parameter_sections.append(f"### {_markdown_code_span(name)}")
72+
if description:
73+
parameter_sections.append(description.strip())
74+
sections.append("\n\n".join(parameter_sections))
75+
if docstring_info.returns:
76+
sections.append(f"## Returns\n\n{docstring_info.returns.strip()}")
77+
if docstring_info.examples:
78+
fence = _code_fence(docstring_info.examples)
79+
sections.append(
80+
f"## Examples\n\n{fence}python\n"
81+
f"{docstring_info.examples.rstrip()}\n{fence}"
82+
)
83+
return cls(
84+
content="\n\n".join(section for section in sections if section),
85+
markup=HelpDocumentFormat.MARKDOWN,
86+
title=title,
87+
)
88+
89+
@classmethod
90+
def from_parameter_content(
91+
cls,
92+
*,
93+
summary: str,
94+
description: str,
95+
title: str | None = None,
96+
) -> HelpDocument:
97+
"""Project one parameter-help response into the shared document model."""
98+
sections = []
99+
if title:
100+
sections.append(f"# {_markdown_heading(title)}")
101+
if summary:
102+
sections.append(f"**{_markdown_inline(summary)}**")
103+
if description:
104+
sections.append(description.strip())
105+
return cls(
106+
content="\n\n".join(sections),
107+
markup=HelpDocumentFormat.MARKDOWN,
108+
title=title,
109+
)
110+
111+
def rendered_html(self) -> str:
112+
"""Render reStructuredText safely for Qt's rich-text engine."""
113+
if self.markup is not HelpDocumentFormat.RESTRUCTURED_TEXT:
114+
raise ValueError(
115+
"rendered_html() is only valid for reStructuredText documents"
116+
)
117+
parts = publish_parts(
118+
self.content,
119+
writer="html5",
120+
settings_overrides={
121+
"raw_enabled": False,
122+
"file_insertion_enabled": False,
123+
"report_level": 5,
124+
"halt_level": 6,
125+
"output_encoding": "unicode",
126+
"embed_stylesheet": False,
127+
},
128+
)
129+
return str(parts["html_body"])
130+
131+
def bounded(
132+
self,
133+
max_chars: int = DEFAULT_HELP_DOCUMENT_MAX_CHARS,
134+
) -> HelpDocument:
135+
"""Return this document with a renderer-safe content bound."""
136+
if max_chars < 1:
137+
raise ValueError("max_chars must be at least 1")
138+
if len(self.content) <= max_chars:
139+
return self
140+
if max_chars < 4:
141+
content = f"{self.content[: max_chars - 1]}…"
142+
else:
143+
content = f"{self.content[: max_chars - 3].rstrip()}\n\n…"
144+
return replace(self, content=content)
145+
146+
def without_leading_heading(self, expected_title: str) -> HelpDocument:
147+
"""Remove a matching source heading when chrome already displays it."""
148+
lines = self.content.splitlines()
149+
first_content_index = next(
150+
(index for index, line in enumerate(lines) if line.strip()),
151+
None,
152+
)
153+
if first_content_index is None:
154+
return self
155+
normalized_expected = _normalized_heading(expected_title)
156+
157+
if self.markup is HelpDocumentFormat.MARKDOWN:
158+
match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", lines[first_content_index])
159+
if (
160+
match is None
161+
or _normalized_heading(match.group(1)) != normalized_expected
162+
):
163+
return self
164+
del lines[first_content_index]
165+
elif self.markup is HelpDocumentFormat.RESTRUCTURED_TEXT:
166+
underline_index = first_content_index + 1
167+
if underline_index >= len(lines):
168+
return self
169+
title = lines[first_content_index].strip()
170+
underline = lines[underline_index].strip()
171+
if (
172+
_normalized_heading(title) != normalized_expected
173+
or len(set(underline)) != 1
174+
or next(iter(set(underline)), "") not in "=-~^\"'`:+*#<>_"
175+
or len(underline) < len(title)
176+
):
177+
return self
178+
del lines[first_content_index : underline_index + 1]
179+
else:
180+
return self
181+
182+
while lines and not lines[0].strip():
183+
del lines[0]
184+
return replace(self, content="\n".join(lines))
185+
186+
187+
def _markdown_heading(value: str) -> str:
188+
return " ".join(value.split()).replace("#", r"\#")
189+
190+
191+
def _markdown_inline(value: str) -> str:
192+
normalized = " ".join(value.split())
193+
return re.sub(r"([\\`*_[\]<>])", r"\\\1", normalized)
194+
195+
196+
def _markdown_code_span(value: str) -> str:
197+
delimiter = "`" * max(1, _longest_character_run(value, "`") + 1)
198+
padding = " " if value.startswith(("`", " ")) or value.endswith(("`", " ")) else ""
199+
return f"{delimiter}{padding}{value}{padding}{delimiter}"
200+
201+
202+
def _code_fence(value: str) -> str:
203+
return "`" * max(3, _longest_character_run(value, "`") + 1)
204+
205+
206+
def _longest_character_run(value: str, target: str) -> int:
207+
longest_run = 0
208+
current_run = 0
209+
for character in value:
210+
if character == target:
211+
current_run += 1
212+
longest_run = max(longest_run, current_run)
213+
else:
214+
current_run = 0
215+
return longest_run
216+
217+
218+
def _normalized_heading(value: str) -> str:
219+
return " ".join(value.split()).casefold()

src/pyqt_reactive/services/widget_tree_projection_config.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,64 @@
22

33
from __future__ import annotations
44

5-
from dataclasses import dataclass
6-
from typing import TypeAlias
7-
5+
from collections.abc import Callable
6+
from dataclasses import dataclass, field, fields, is_dataclass
7+
from typing import Any, TypeAlias
88

99
DEFAULT_MAXIMUM_WIDGET_TEXT_LENGTH = 4096
1010
DEFAULT_MAXIMUM_ITEM_MODEL_NODES = 512
1111
DEFAULT_TEXT_TRUNCATION_SUFFIX = "...<truncated>"
1212
WidgetPath: TypeAlias = tuple[int, ...]
13+
CompactFieldPredicate: TypeAlias = Callable[[object, object], bool]
14+
COMPACT_FIELD_PROJECTION_METADATA_KEY = "compact_field_projection"
15+
16+
17+
@dataclass(frozen=True, slots=True)
18+
class CompactFieldProjection:
19+
"""Declaration-owned rules for one field in a compact dataclass projection."""
20+
21+
includes: CompactFieldPredicate
22+
23+
24+
def always_project_compact_field(_owner: object, _value: object) -> bool:
25+
"""Retain a field even when its current value is otherwise empty."""
26+
27+
return True
28+
29+
30+
def _compact_value_carries_information(value: object) -> bool:
31+
if value is None:
32+
return False
33+
if isinstance(value, bool):
34+
return value
35+
if isinstance(value, (str, bytes, tuple, list, dict, set, frozenset)):
36+
return bool(value)
37+
return True
38+
39+
40+
def compact_dataclass_projection(value: object) -> dict[str, Any]:
41+
"""Project one dataclass through its field-owned compactness declarations."""
42+
43+
if isinstance(value, type) or not is_dataclass(value):
44+
raise TypeError(
45+
f"Compact projection requires a dataclass instance, got {value!r}."
46+
)
47+
projected: dict[str, Any] = {}
48+
for declared_field in fields(value):
49+
field_value = getattr(value, declared_field.name)
50+
policy = declared_field.metadata.get(COMPACT_FIELD_PROJECTION_METADATA_KEY)
51+
if policy is None:
52+
includes = _compact_value_carries_information(field_value)
53+
elif isinstance(policy, CompactFieldProjection):
54+
includes = policy.includes(value, field_value)
55+
else:
56+
raise TypeError(
57+
f"{type(value).__name__}.{declared_field.name} declares an invalid "
58+
"compact field projection."
59+
)
60+
if includes:
61+
projected[declared_field.name] = field_value
62+
return projected
1363

1464

1565
@dataclass(frozen=True, slots=True)
@@ -24,10 +74,34 @@ class WidgetTextProjection:
2474
class WidgetNodeIdentity:
2575
"""Stable widget identity fields shared by projector and transport DTOs."""
2676

27-
path: WidgetPath
28-
path_id: str
29-
child_index: int | None
30-
class_name: str
77+
path: WidgetPath = field(
78+
metadata={
79+
COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection(
80+
includes=always_project_compact_field
81+
)
82+
}
83+
)
84+
path_id: str = field(
85+
metadata={
86+
COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection(
87+
includes=always_project_compact_field
88+
)
89+
}
90+
)
91+
child_index: int | None = field(
92+
metadata={
93+
COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection(
94+
includes=always_project_compact_field
95+
)
96+
}
97+
)
98+
class_name: str = field(
99+
metadata={
100+
COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection(
101+
includes=always_project_compact_field
102+
)
103+
}
104+
)
31105
object_name: str
32106
accessible_name: str
33107
accessible_description: str

0 commit comments

Comments
 (0)