pygeomatic 0.2__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {pygeomatic-0.2 → pygeomatic-0.2.1}/PKG-INFO +102 -1
  2. {pygeomatic-0.2 → pygeomatic-0.2.1}/README.md +101 -0
  3. {pygeomatic-0.2 → pygeomatic-0.2.1}/pyproject.toml +1 -1
  4. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/__init__.py +3 -0
  5. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/store.py +2 -1
  6. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/tex.py +206 -9
  7. {pygeomatic-0.2 → pygeomatic-0.2.1}/.gitignore +0 -0
  8. {pygeomatic-0.2 → pygeomatic-0.2.1}/LICENSE +0 -0
  9. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/article.py +0 -0
  10. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/coercions.json +0 -0
  11. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/coercions.py +0 -0
  12. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/emit.py +0 -0
  13. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/extensions.py +0 -0
  14. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/__init__.py +0 -0
  15. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/helpers.py +0 -0
  16. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/__init__.py +0 -0
  17. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/annotation_functions.py +0 -0
  18. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/array.py +0 -0
  19. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/autograd_functions.py +0 -0
  20. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/basic_figures.py +0 -0
  21. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/boolean_functions.py +0 -0
  22. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/complex_functions.py +0 -0
  23. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/curve_functions.py +0 -0
  24. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/intersections.py +0 -0
  25. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/ode_functions.py +0 -0
  26. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/planar_geometry.py +0 -0
  27. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/polygons.py +0 -0
  28. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/property_functions.py +0 -0
  29. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/rotation_functions.py +0 -0
  30. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/scalar_functions.py +0 -0
  31. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/special_functions.py +0 -0
  32. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/tangent_functions.py +0 -0
  33. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/tensor_functions.py +0 -0
  34. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/translation_functions.py +0 -0
  35. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/__init__.py +0 -0
  36. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/abs.py +0 -0
  37. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/add.py +0 -0
  38. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/create_overload.py +0 -0
  39. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/div.py +0 -0
  40. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/exp.py +0 -0
  41. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/log.py +0 -0
  42. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/mul.py +0 -0
  43. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/neg.py +0 -0
  44. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/pow.py +0 -0
  45. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/sqrt.py +0 -0
  46. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/sub.py +0 -0
  47. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/generate.py +0 -0
  48. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/inference.py +0 -0
  49. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/macros.json +0 -0
  50. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/macros.py +0 -0
  51. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/nodes.py +0 -0
  52. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/palette.py +0 -0
  53. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/parse.py +0 -0
  54. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/prompting.py +0 -0
  55. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/registry.py +0 -0
  56. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/runner.py +0 -0
  57. {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/system_nodes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeomatic
3
- Version: 0.2
3
+ Version: 0.2.1
4
4
  Summary: Python mirror of the geomatic DSL function library; records calls for deterministic DSL emission
5
5
  Project-URL: Homepage, https://github.com/TinyVolt/pygeomatic
6
6
  Project-URL: Repository, https://github.com/TinyVolt/pygeomatic
@@ -160,6 +160,107 @@ uv run python scripts/compile_articles.py articles/ dist/ # a tree
160
160
  (equivalently `gm.compile_article(md)` in-process or `gm.run_article(md)` in a
161
161
  subprocess; both take `extensions=` / `macros=` / `allow_coercions=`.)
162
162
 
163
+ ### Live formulas (texatlas)
164
+
165
+ Make a KaTeX formula *addressable and reactive* — a slot showing a store node's
166
+ live value, matrix cells highlighted by a store node — with `gm.tex`. It is
167
+ **not** DSL: no `\tex-*` command exists and `emit()` never sees it. Bindings are
168
+ harvested straight from the session into a trailing `<!-- texatlas:v1 … -->`
169
+ comment on the compiled article, which the Nova reader applies at load. Reactivity
170
+ flows through the bound *node*: whatever drives it (a `\scalar` CommandLink, an
171
+ `\animate`) reflows the formula with no re-render.
172
+
173
+ Give the formula an id with a `%id:` line as the first line inside the `$$…$$`
174
+ block, then bind nodes to that id in a fence. (The id syntax is owned entirely
175
+ by the Nova/web reader — pygeomatic never parses the LaTeX; it only needs to
176
+ know which id string a `gm.tex(...)` call refers to.)
177
+
178
+ ````markdown
179
+ $$
180
+ %id:energy
181
+ \int_{a}^{b} x^2 \, dx
182
+ $$
183
+
184
+ ```pygeomatic
185
+ b = gm.scalar(3, out="b")
186
+ energy = gm.tex("energy") # matches %id:energy
187
+ energy.int.upper.bind(b) # show b's value in the upper limit
188
+ ```
189
+
190
+ Change it: {b = 5}(b = gm.scalar(5))
191
+ ````
192
+
193
+ Matrix cells are highlighted by **selectors** built from store nodes — the
194
+ selector returns a weight in `[0,1]`, so a fractional (`\animate`d) node
195
+ crossfades between adjacent rows:
196
+
197
+ ````markdown
198
+ $$
199
+ %id:M
200
+ \begin{pmatrix} a & b & c \\ d & e & f \\ g & h & i \end{pmatrix}
201
+ $$
202
+
203
+ ```pygeomatic
204
+ r = gm.scalar(0, out="r")
205
+ M = gm.tex("M")
206
+ M.highlight(M.rows() == r, color="pink") # row r
207
+ M.triu().highlight(color="blue") # upper triangle
208
+ ```
209
+
210
+ Move it: {row 1}(r = gm.scalar(1)) · {row 2}(r = gm.scalar(2))
211
+ ````
212
+
213
+ - **Value bind**: `t.<family>.<slot>.bind(node, show=, fmt=)`. Families/slots are
214
+ `int`/`sum`/`prod` → `lower`/`upper`/`body`, `frac` → `num`/`denom`,
215
+ `sqrt` → `body` (extend with `gm.register_tex_schema(family, slots)`). `fmt`
216
+ is `".2f"` / `"d"` (omit → trim to ≤4 dp); `show="symbol"` links without
217
+ substituting the glyph. Repeated command? `t.ints[1].upper` picks an
218
+ occurrence (discouraged — an edit silently retargets it).
219
+ - **Highlights**: build a selector over a cell's grid position and paint it.
220
+ Axes are `M.rows()` / `M.cols()` (or module-level `rows` / `cols` / `dim(i)`),
221
+ with arithmetic (`cols - rows`, `+`/`-`). Compare with `==` / `>=` / `<=` /
222
+ `>` / `<` (or `.eq/.ge/.le/.gt/.lt`) against a node or number; select
223
+ axis-aligned boxes with numpy-style slicing (`M[3:, 4:]`, node bounds stay
224
+ reactive); or use the named regions `M.diag()` / `M.triu()` / `M.tril()`.
225
+ Combine with `&` / `|` (`.and_` / `.or_`) and `.scale(node)` to fade a whole
226
+ selection — `scale` by a node + a `\scalar u 1` CommandLink gates a highlight
227
+ behind a click. Palette names (`"pink"`, `"BLUE"`) resolve to hex; raw
228
+ `"#f472b6"` / CSS names pass through. Full reference:
229
+ [docs/tex-highlight-ergonomics.md](docs/tex-highlight-ergonomics.md).
230
+
231
+ Two rules that will bite otherwise:
232
+
233
+ - **Bind replaces content, it never creates structure** — write placeholder
234
+ symbols in every slot you bind (`\int_{a}^{b}`, not a bare `\int`). An empty
235
+ slot fails validation.
236
+ - **The bound node must already exist** when you call `.bind()` / `.eq()` — define
237
+ it (`gm.scalar(…, out="b")`) above the binding so it is part of the runtime env.
238
+
239
+ **Repeated commands (`ints[i]`).** When one formula uses the same command twice
240
+ — e.g. `$$ \int_a^b f + \int_c^d g $$` — `t.int` is ambiguous. `t.ints` is the
241
+ list of all of them: `t.ints[0]` is the first `\int`, `t.ints[1]` the second.
242
+ The index is positional, so editing the formula (reordering or inserting an
243
+ `\int`) silently retargets it — prefer splitting into two formulas with distinct
244
+ ids, each with a single `\int`.
245
+
246
+ **Gating a highlight behind a click (`.scale`).** A bound highlight is on as
247
+ soon as it's bound. To reveal it only after a reader clicks, scale its weight by
248
+ a node that starts at `0`:
249
+
250
+ ```python
251
+ u = gm.scalar(0, out="u") # 0 → highlight invisible
252
+ M.highlight((M.rows() == r).scale(u)) # weight = row-weight × u
253
+ ```
254
+ ```markdown
255
+ {reveal}(u = gm.scalar(1)) # click sets u=1 → highlight appears
256
+ ```
257
+
258
+ `u=0` hides it, `u=1` shows it fully, `u=0.5` at half — the click is just a
259
+ normal CommandLink writing `u`.
260
+
261
+ `gm.harvest_tex_bindings(store)` returns the raw `{texId: {values, highlights}}`
262
+ manifest if you need it directly.
263
+
163
264
  ### Publishing from a content repo (GitHub Action)
164
265
 
165
266
  A content repo publishes compiled articles with one workflow file; a compile
@@ -139,6 +139,107 @@ uv run python scripts/compile_articles.py articles/ dist/ # a tree
139
139
  (equivalently `gm.compile_article(md)` in-process or `gm.run_article(md)` in a
140
140
  subprocess; both take `extensions=` / `macros=` / `allow_coercions=`.)
141
141
 
142
+ ### Live formulas (texatlas)
143
+
144
+ Make a KaTeX formula *addressable and reactive* — a slot showing a store node's
145
+ live value, matrix cells highlighted by a store node — with `gm.tex`. It is
146
+ **not** DSL: no `\tex-*` command exists and `emit()` never sees it. Bindings are
147
+ harvested straight from the session into a trailing `<!-- texatlas:v1 … -->`
148
+ comment on the compiled article, which the Nova reader applies at load. Reactivity
149
+ flows through the bound *node*: whatever drives it (a `\scalar` CommandLink, an
150
+ `\animate`) reflows the formula with no re-render.
151
+
152
+ Give the formula an id with a `%id:` line as the first line inside the `$$…$$`
153
+ block, then bind nodes to that id in a fence. (The id syntax is owned entirely
154
+ by the Nova/web reader — pygeomatic never parses the LaTeX; it only needs to
155
+ know which id string a `gm.tex(...)` call refers to.)
156
+
157
+ ````markdown
158
+ $$
159
+ %id:energy
160
+ \int_{a}^{b} x^2 \, dx
161
+ $$
162
+
163
+ ```pygeomatic
164
+ b = gm.scalar(3, out="b")
165
+ energy = gm.tex("energy") # matches %id:energy
166
+ energy.int.upper.bind(b) # show b's value in the upper limit
167
+ ```
168
+
169
+ Change it: {b = 5}(b = gm.scalar(5))
170
+ ````
171
+
172
+ Matrix cells are highlighted by **selectors** built from store nodes — the
173
+ selector returns a weight in `[0,1]`, so a fractional (`\animate`d) node
174
+ crossfades between adjacent rows:
175
+
176
+ ````markdown
177
+ $$
178
+ %id:M
179
+ \begin{pmatrix} a & b & c \\ d & e & f \\ g & h & i \end{pmatrix}
180
+ $$
181
+
182
+ ```pygeomatic
183
+ r = gm.scalar(0, out="r")
184
+ M = gm.tex("M")
185
+ M.highlight(M.rows() == r, color="pink") # row r
186
+ M.triu().highlight(color="blue") # upper triangle
187
+ ```
188
+
189
+ Move it: {row 1}(r = gm.scalar(1)) · {row 2}(r = gm.scalar(2))
190
+ ````
191
+
192
+ - **Value bind**: `t.<family>.<slot>.bind(node, show=, fmt=)`. Families/slots are
193
+ `int`/`sum`/`prod` → `lower`/`upper`/`body`, `frac` → `num`/`denom`,
194
+ `sqrt` → `body` (extend with `gm.register_tex_schema(family, slots)`). `fmt`
195
+ is `".2f"` / `"d"` (omit → trim to ≤4 dp); `show="symbol"` links without
196
+ substituting the glyph. Repeated command? `t.ints[1].upper` picks an
197
+ occurrence (discouraged — an edit silently retargets it).
198
+ - **Highlights**: build a selector over a cell's grid position and paint it.
199
+ Axes are `M.rows()` / `M.cols()` (or module-level `rows` / `cols` / `dim(i)`),
200
+ with arithmetic (`cols - rows`, `+`/`-`). Compare with `==` / `>=` / `<=` /
201
+ `>` / `<` (or `.eq/.ge/.le/.gt/.lt`) against a node or number; select
202
+ axis-aligned boxes with numpy-style slicing (`M[3:, 4:]`, node bounds stay
203
+ reactive); or use the named regions `M.diag()` / `M.triu()` / `M.tril()`.
204
+ Combine with `&` / `|` (`.and_` / `.or_`) and `.scale(node)` to fade a whole
205
+ selection — `scale` by a node + a `\scalar u 1` CommandLink gates a highlight
206
+ behind a click. Palette names (`"pink"`, `"BLUE"`) resolve to hex; raw
207
+ `"#f472b6"` / CSS names pass through. Full reference:
208
+ [docs/tex-highlight-ergonomics.md](docs/tex-highlight-ergonomics.md).
209
+
210
+ Two rules that will bite otherwise:
211
+
212
+ - **Bind replaces content, it never creates structure** — write placeholder
213
+ symbols in every slot you bind (`\int_{a}^{b}`, not a bare `\int`). An empty
214
+ slot fails validation.
215
+ - **The bound node must already exist** when you call `.bind()` / `.eq()` — define
216
+ it (`gm.scalar(…, out="b")`) above the binding so it is part of the runtime env.
217
+
218
+ **Repeated commands (`ints[i]`).** When one formula uses the same command twice
219
+ — e.g. `$$ \int_a^b f + \int_c^d g $$` — `t.int` is ambiguous. `t.ints` is the
220
+ list of all of them: `t.ints[0]` is the first `\int`, `t.ints[1]` the second.
221
+ The index is positional, so editing the formula (reordering or inserting an
222
+ `\int`) silently retargets it — prefer splitting into two formulas with distinct
223
+ ids, each with a single `\int`.
224
+
225
+ **Gating a highlight behind a click (`.scale`).** A bound highlight is on as
226
+ soon as it's bound. To reveal it only after a reader clicks, scale its weight by
227
+ a node that starts at `0`:
228
+
229
+ ```python
230
+ u = gm.scalar(0, out="u") # 0 → highlight invisible
231
+ M.highlight((M.rows() == r).scale(u)) # weight = row-weight × u
232
+ ```
233
+ ```markdown
234
+ {reveal}(u = gm.scalar(1)) # click sets u=1 → highlight appears
235
+ ```
236
+
237
+ `u=0` hides it, `u=1` shows it fully, `u=0.5` at half — the click is just a
238
+ normal CommandLink writing `u`.
239
+
240
+ `gm.harvest_tex_bindings(store)` returns the raw `{texId: {values, highlights}}`
241
+ manifest if you need it directly.
242
+
142
243
  ### Publishing from a content repo (GitHub Action)
143
244
 
144
245
  A content repo publishes compiled articles with one workflow file; a compile
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pygeomatic"
3
- version = "0.2"
3
+ version = "0.2.1"
4
4
  description = "Python mirror of the geomatic DSL function library; records calls for deterministic DSL emission"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -111,8 +111,11 @@ from .tex import ( # noqa: F401
111
111
  Selector,
112
112
  Tex,
113
113
  TexError,
114
+ cols,
115
+ dim,
114
116
  harvest_tex_bindings,
115
117
  register_tex_schema,
118
+ rows,
116
119
  tex,
117
120
  )
118
121
 
@@ -247,7 +247,8 @@ class Store:
247
247
  # texatlas bindings (gm.tex): declarative formula↔node links recorded on
248
248
  # a channel SEPARATE from the command tape — they never emit DSL and are
249
249
  # harvested straight from the session (tex.harvest_tex_bindings). Keyed
250
- # by `$$[#id]` formula id {"values": [...], "highlights": [...]}.
250
+ # by formula id (the `%id:` line inside a `$$…$$` block) →
251
+ # {"values": [...], "highlights": [...]}.
251
252
  self.tex_bindings: dict[str, dict] = {}
252
253
  self._token = None
253
254
  # Every canvas starts with the engine's default nodes (`p0`, `T`/`F`,
@@ -1,6 +1,7 @@
1
1
  """gm.tex — texatlas bindings recorder (addressable, reactively styled KaTeX).
2
2
 
3
- A `$$[#id]$$` formula in a markdown article is made addressable and reactive
3
+ A `$$…$$` formula in a markdown article (given an id by a `%id:` line as its
4
+ first line) is made addressable and reactive
4
5
  NOT by any DSL command but by declarative *bindings* recorded here: a schema
5
6
  slot can show a store node's live value (`energy.int.upper.bind(a)`), and matrix
6
7
  cells can be highlighted by selectors built from store nodes
@@ -122,6 +123,26 @@ def _env_ref(value: Union[GNode, str, int, float], *, what: str) -> dict:
122
123
  return {"node": _node_id(value, what=what)}
123
124
 
124
125
 
126
+ def _matrix_index(matrix: int) -> int:
127
+ """Validate an author-supplied matrix occurrence index (a non-negative int).
128
+
129
+ This is which matrix in a multi-matrix formula a highlight paints — see
130
+ `Tex.highlight` for the source-order counting rule. Python never counts the
131
+ matrices itself (it does not parse LaTeX); the author supplies the integer."""
132
+ if isinstance(matrix, bool) or not isinstance(matrix, int) or matrix < 0:
133
+ raise TexError(f"matrix index must be a non-negative int, got {matrix!r}")
134
+ return matrix
135
+
136
+
137
+ def _highlight_entry(selector_json: dict, color: str, matrix: int) -> dict:
138
+ """A `HighlightBinding` wire entry. `matrix` (occurrence index) is omitted
139
+ when 0 so single-matrix formulas stay byte-identical to the v1 output."""
140
+ entry: dict = {"selector": selector_json, "color": _resolve_color(color)}
141
+ if _matrix_index(matrix):
142
+ entry["matrix"] = matrix
143
+ return entry
144
+
145
+
125
146
  def _resolve_color(color: str) -> str:
126
147
  """Resolve a palette name to its hex before it crosses the wire; pass any
127
148
  other CSS color string (`#f472b6`, `pink`, `rgb(...)`) through unchanged."""
@@ -163,6 +184,40 @@ class AxisExpr:
163
184
  def le(self, value: Union[GNode, str, int, float]) -> "Selector":
164
185
  return Selector({"op": "le", "axis": self._json(), "value": _env_ref(value, what="selector value")})
165
186
 
187
+ def gt(self, value: Union[GNode, str, int, float]) -> "Selector":
188
+ return Selector({"op": "gt", "axis": self._json(), "value": _env_ref(value, what="selector value")})
189
+
190
+ def lt(self, value: Union[GNode, str, int, float]) -> "Selector":
191
+ return Selector({"op": "lt", "axis": self._json(), "value": _env_ref(value, what="selector value")})
192
+
193
+ # Rich comparison sugar: `axis == v` / `>= v` / `<= v` / `> v` / `< v` are the
194
+ # `.eq/.ge/.le/.gt/.lt` wire ops. Strict `>`/`<` are first-class in the runtime
195
+ # (CONTRACT.md), so a node bound is fine — no integer shift here.
196
+ def __eq__(self, value: object) -> "Selector": # type: ignore[override]
197
+ return self.eq(value) # type: ignore[arg-type]
198
+
199
+ def __ge__(self, value: Union[GNode, str, int, float]) -> "Selector":
200
+ return self.ge(value)
201
+
202
+ def __le__(self, value: Union[GNode, str, int, float]) -> "Selector":
203
+ return self.le(value)
204
+
205
+ def __gt__(self, value: Union[GNode, str, int, float]) -> "Selector":
206
+ return self.gt(value)
207
+
208
+ def __lt__(self, value: Union[GNode, str, int, float]) -> "Selector":
209
+ return self.lt(value)
210
+
211
+ def __radd__(self, other: Union[int, float]) -> "AxisExpr": # 1 + rows
212
+ return _as_axis(other).add(self)
213
+
214
+ def __rsub__(self, other: Union[int, float]) -> "AxisExpr": # 1 - rows
215
+ return _as_axis(other).sub(self)
216
+
217
+ # `__eq__` makes instances non-hashable by Python's rules; that is correct
218
+ # here (an axis is an expression builder, never a dict key or set member).
219
+ __hash__ = None # type: ignore[assignment]
220
+
166
221
  def _json(self) -> dict: # pragma: no cover - overridden
167
222
  raise NotImplementedError
168
223
 
@@ -199,6 +254,31 @@ def _as_axis(value: Union[AxisExpr, int, float]) -> AxisExpr:
199
254
  return _AxisConst(value)
200
255
 
201
256
 
257
+ # Free axis handles — an axis carries no reference to any formula, so these are
258
+ # shared module singletons (immutable: every operator returns a NEW node). Write
259
+ # `rows`/`cols`/`dim(i)` directly instead of `M.rows()`/`M.cols()`.
260
+ _AXIS_NAMES: tuple[str, ...] = ("row", "col")
261
+
262
+
263
+ def dim(i: int) -> AxisExpr:
264
+ """The `i`-th grid axis as an expression (`dim(0)` == `rows`, `dim(1)` == `cols`).
265
+
266
+ Matrices are 2-D browser-side, so only dims 0 and 1 resolve today; higher
267
+ ranks await browser-schema support (keeping the wire's `row`/`col` names)."""
268
+ if isinstance(i, bool) or not isinstance(i, int) or i < 0:
269
+ raise TexError(f"axis index must be a non-negative int, got {i!r}")
270
+ if i >= len(_AXIS_NAMES):
271
+ raise TexError(
272
+ f"axis {i} is unsupported: matrices are 2-D browser-side (dims 0 and 1); "
273
+ "rank > 2 awaits browser-schema support"
274
+ )
275
+ return _NamedAxis(_AXIS_NAMES[i])
276
+
277
+
278
+ rows: AxisExpr = _NamedAxis("row")
279
+ cols: AxisExpr = _NamedAxis("col")
280
+
281
+
202
282
  class Selector:
203
283
  """A highlight selector: `(cell, env) -> weight`. Combine with `and_`/`or_`
204
284
  (min/max) or fade the whole selection with `scale`."""
@@ -228,6 +308,37 @@ def _as_selector(value: "Selector") -> dict:
228
308
  return value._json_
229
309
 
230
310
 
311
+ class _Region(Selector):
312
+ """A selector that remembers which formula it targets, so it can paint
313
+ itself directly: `M[3:, 4:].highlight()`, `M.triu().highlight(color=...)`.
314
+ It IS a `Selector`, so it still composes (`M[3:, :] | M[:, 4:]`) and can be
315
+ passed to `M.highlight(...)`; the combinators keep the target so the result
316
+ stays paintable."""
317
+
318
+ def __init__(self, tex_id: str, json: dict) -> None:
319
+ super().__init__(json)
320
+ self._tex_id = tex_id
321
+
322
+ def highlight(self, *, color: str = "COLOR-YELLOW", matrix: int = 0) -> None:
323
+ """Paint this region on its formula, in `color`, on the `matrix`-th
324
+ matrix of the formula (see `Tex.highlight` for both)."""
325
+ _bindings(self._tex_id)["highlights"].append(
326
+ _highlight_entry(self._json_, color, matrix)
327
+ )
328
+
329
+ def and_(self, other: "Selector") -> "_Region":
330
+ return _Region(self._tex_id, {"op": "and", "a": self._json_, "b": _as_selector(other)})
331
+
332
+ def or_(self, other: "Selector") -> "_Region":
333
+ return _Region(self._tex_id, {"op": "or", "a": self._json_, "b": _as_selector(other)})
334
+
335
+ def scale(self, by: Union[GNode, str, int, float]) -> "_Region":
336
+ return _Region(self._tex_id, {"op": "scale", "sel": self._json_, "by": _env_ref(by, what="scale factor")})
337
+
338
+ __and__ = and_
339
+ __or__ = or_
340
+
341
+
231
342
  # ---------------------------------------------------------------------------
232
343
  # Slot addressing — energy.int.upper.bind(a)
233
344
  # ---------------------------------------------------------------------------
@@ -313,14 +424,23 @@ class _FamilyList:
313
424
 
314
425
 
315
426
  # ---------------------------------------------------------------------------
316
- # Tex — the callable matching a $$[#id]$$ formula
427
+ # Tex — the callable matching a $$…$$ formula (by its `%id:` id)
317
428
  # ---------------------------------------------------------------------------
318
429
 
319
430
 
320
431
  class Tex:
321
- """Handle to a `$$[#id]$$` formula. Descend into a slot family to bind a
322
- value (`t.int.upper.bind(a)`), or use the matrix axis selectors to highlight
323
- cells (`t.highlight(t.rows().eq(r))`)."""
432
+ """Handle to a `$$…$$` formula (addressed by its `%id:` id). Descend into a slot family to bind a
433
+ value (`t.int.upper.bind(a)`), or highlight matrix cells. Highlights have
434
+ several ergonomic surfaces, all lowering to the same selector JSON (see
435
+ docs/tex-highlight-ergonomics.md):
436
+
437
+ t.highlight(rows == r, color="pink") # free axes + operators (#1, #2)
438
+ t.highlight(cols - rows > 0) # cross-axis relation
439
+ t[3:, 4:].highlight(color="green") # numpy-style box (#3)
440
+ t.triu().highlight() # named region (#6)
441
+
442
+ `rows`/`cols`/`dim(i)` are module-level axis handles; `t.rows()`/`t.cols()`
443
+ remain as aliases."""
324
444
 
325
445
  def __init__(self, tex_id: str) -> None:
326
446
  self.id = tex_id
@@ -336,13 +456,90 @@ class Tex:
336
456
  """The cell's column index, as an axis expression."""
337
457
  return _NamedAxis("col")
338
458
 
339
- def highlight(self, selector: Selector, *, color: str = "COLOR-YELLOW") -> None:
459
+ def highlight(
460
+ self, selector: Selector, *, color: str = "COLOR-YELLOW", matrix: int = 0
461
+ ) -> None:
340
462
  """Paint the cells the selector weights, in `color` (a palette name is
341
- resolved to hex here; any other CSS color passes through)."""
463
+ resolved to hex here; any other CSS color passes through).
464
+
465
+ `matrix` is the 0-based occurrence index of which matrix in the formula
466
+ to paint, in document (source) order — for a formula with more than one
467
+ matrix. It defaults to 0 (the first / only matrix) and is omitted from
468
+ the wire JSON when 0, so single-matrix formulas are unchanged. Each
469
+ highlight carries its own index, so different highlights on one formula
470
+ may target different matrices.
471
+
472
+ Python does NOT parse the LaTeX to count matrices — you supply the
473
+ integer — so you must count the SAME way the browser does: count only
474
+ genuine matrices, in source order, SKIPPING equation-layout blocks.
475
+ Skip: `aligned`, `align`, `split`, `alignat`, `gathered`, `gather`, `CD`.
476
+ Count: `matrix` / `pmatrix` / `bmatrix` / `Bmatrix` / `vmatrix` /
477
+ `Vmatrix` (and `*`-variants), plain `array`, `cases` / `dcases` /
478
+ `rcases`, `smallmatrix`, `subarray`. So in
479
+ `\\begin{aligned} ... \\begin{pmatrix}...\\end{pmatrix} ... \\end{aligned}`
480
+ the pmatrix is matrix index 0 (the aligned wrapper is not counted). An
481
+ out-of-range index is non-fatal browser-side (that highlight paints
482
+ nothing; the rest still render), so no error is raised here."""
342
483
  _bindings(self.id)["highlights"].append(
343
- {"selector": _as_selector(selector), "color": _resolve_color(color)}
484
+ _highlight_entry(_as_selector(selector), color, matrix)
344
485
  )
345
486
 
487
+ def __getitem__(self, key) -> _Region:
488
+ """numpy-style cell region: `M[3:, 4:]`, `M[:, c]`, `M[r, ...]`.
489
+
490
+ Each index constrains one axis (0 = row, 1 = col, ...): a slice is an
491
+ axis-aligned box (`start` inclusive, `stop` exclusive), a bare int or
492
+ node is an exact index (`==`). A node as a `start` or an index stays
493
+ reactive — retarget it with `\\scalar` and the region follows. Omitted
494
+ or `:` axes are unconstrained; a trailing `...` documents that. Returns a
495
+ paintable region: `M[3:, 4:].highlight(color="pink")`. Cross-axis
496
+ relations (diagonals, triangles) are NOT boxes — use the axis operators
497
+ or `.diag()`/`.triu()`/`.tril()`."""
498
+ keys = key if isinstance(key, tuple) else (key,)
499
+ # Ellipsis is a no-op (unmentioned axes are already unconstrained), but
500
+ # accepted at the end for readability. Use identity — `==` is overloaded.
501
+ if any(k is Ellipsis for k in keys):
502
+ if sum(1 for k in keys if k is Ellipsis) > 1 or keys[-1] is not Ellipsis:
503
+ raise TexError(
504
+ "'...' is only supported as the final index "
505
+ "(trailing axes are unconstrained by default)"
506
+ )
507
+ keys = keys[:-1]
508
+ parts: list[Selector] = []
509
+ for axis_i, k in enumerate(keys):
510
+ ax = dim(axis_i)
511
+ if isinstance(k, slice):
512
+ if k.step not in (None, 1):
513
+ raise TexError("slice step is not supported in a cell region")
514
+ if k.start is not None:
515
+ parts.append(ax.ge(k.start)) # inclusive start (node or int)
516
+ if k.stop is not None:
517
+ parts.append(ax.lt(k.stop)) # exclusive stop -> < stop (node or int)
518
+ elif isinstance(k, GNode) or (isinstance(k, int) and not isinstance(k, bool)) or isinstance(k, str):
519
+ parts.append(ax.eq(k)) # exact index
520
+ else:
521
+ raise TexError(f"unsupported cell index {k!r}: use an int, a node, a node id, or a slice")
522
+ if not parts:
523
+ raise TexError(
524
+ "cell region selects the whole matrix — constrain at least one axis"
525
+ )
526
+ sel: Selector = parts[0]
527
+ for p in parts[1:]:
528
+ sel = sel.and_(p)
529
+ return _Region(self.id, sel._json())
530
+
531
+ def diag(self, k: int = 0) -> _Region:
532
+ """The `k`-th diagonal (`col - row == k`; `k=0` is the main diagonal)."""
533
+ return _Region(self.id, cols.sub(rows).eq(k)._json())
534
+
535
+ def triu(self, k: int = 0) -> _Region:
536
+ """Upper triangle from the `k`-th diagonal up (`col - row >= k`)."""
537
+ return _Region(self.id, cols.sub(rows).ge(k)._json())
538
+
539
+ def tril(self, k: int = 0) -> _Region:
540
+ """Lower triangle from the `k`-th diagonal down (`col - row <= k`)."""
541
+ return _Region(self.id, cols.sub(rows).le(k)._json())
542
+
346
543
  # -- slot families ------------------------------------------------------
347
544
 
348
545
  def __getattr__(self, name: str) -> Union[_Family, _FamilyList]:
@@ -360,7 +557,7 @@ class Tex:
360
557
 
361
558
 
362
559
  def tex(tex_id: str) -> Tex:
363
- """A handle to the `$$[#id]$$` formula in the current article, for binding
560
+ """A handle to the `$$…$$` formula (by its `%id:` id) in the current article, for binding
364
561
  values and highlights to it. Its bindings record on the active store's
365
562
  texatlas channel, harvested (not emitted) into the compiled article."""
366
563
  if not isinstance(tex_id, str) or not tex_id:
File without changes
File without changes