Skip to content

Commit 20c70d8

Browse files
sp00ktoberclaude
andcommitted
feat(studio): an outline of triggers, and a trigger's rules as framed rows
An outline beside the Behaviour canvas lists All rules, every event kind, every state kind and every move with a while-playing port, each with its rule count and its places in the order the brain checks them. Picking one shows only its rules, one framed row each (rank and note): its blocks left to right, the move it plays as a dashed reference with the number of rules that play it, a move trigger once on the left. The layout is for display and never written to a block's `at`. The scope lives on the workspace, a finding on a block outside it switches to the block's trigger, and until editing in a trigger exists a drag snaps back and create and wire say so. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent fd6156d commit 20c70d8

9 files changed

Lines changed: 1159 additions & 55 deletions

File tree

‎apps/studio/src/mhfu_studio/monster/behaviour.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
IN, OUT = "in", "out"
2828
PLAY, WHILE, THEN = "play", "while playing", "then"
2929
"""A move node's ports: the input, then the two outputs."""
30+
ALL = "all"
31+
"""The scope that is the whole canvas, as stored; any other is a trigger's key."""
3032
ROLE_TITLES = {
3133
"event": "Events",
3234
"state": "State",

‎apps/studio/src/mhfu_studio/monster/panels/behaviour.py‎

Lines changed: 128 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,28 @@
33
"""The Behaviour tab: the port's behaviour graph (`mhfu_port.behaviour`) on a node canvas.
44
55
A node is a move or a block, drawn compact; a wire is a link the manifest stores
6-
(`monster/behaviour.py`). The selected node's values and note are edited in the bar under the
7-
canvas. The document is the owner: every gesture of the canvas and every edit of the bar arrives
8-
as intents, is applied as one undo step, and is shown again from the document.
6+
(`monster/behaviour.py`). The outline beside the canvas scopes it: All is the canvas as stored,
7+
a trigger shows only its rules, one framed row each in the order they are checked (`rows`), laid
8+
out for display and never written to a block's `at`. The selected node's values and note are
9+
edited in the bar under the canvas. The document is the owner: every gesture of the canvas and
10+
every edit of the bar arrives as intents, is applied as one undo step, and is shown again from
11+
the document.
912
"""
1013

1114
from __future__ import annotations
1215

16+
import dataclasses
1317
from collections.abc import Callable
1418
from typing import TYPE_CHECKING
1519

1620
from mhfu_port import behaviour as model
17-
from mhfu_port.behaviour import KINDS, Kind
21+
from mhfu_port.behaviour import KINDS, Kind, Trigger
1822
from mhfu_port.manifest import Manifest, ManifestError, Move
19-
from PySide6.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
23+
from PySide6.QtWidgets import QHBoxLayout, QSizePolicy, QVBoxLayout, QWidget
2024

2125
from mhfu_studio.monster import behaviour as graph
26+
from mhfu_studio.monster.behaviour import ALL
27+
from mhfu_studio.monster.panels import rows, triggers
2228
from mhfu_studio.monster.panels.common import kind
2329
from mhfu_studio.monster.panels.node_graph import GraphView, LinkSpec, NodeSpec
2430
from mhfu_studio.monster.panels.properties import Detail, PropertyBar
@@ -41,6 +47,8 @@
4147
MOVE_ROLE = "move"
4248
WARN = chr(0x26A0)
4349
HINT = "Right-click adds a block · drag from a port to wire · higher blocks are checked first"
50+
#: said for the intents a trigger's rows do not take yet, and over the rows
51+
SCOPED = "Create and wire in All for now"
4452

4553

4654
class BehaviourPanel(kit.Panel):
@@ -57,8 +65,11 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
5765
self._dropped: dict[str, graph.Point] = {}
5866
#: the block the gesture creates
5967
self._made: str | None = None
60-
#: what the canvas was last drawn from, for the bar
68+
#: what the canvas was last drawn from, for the bar and the outline
6169
self._read: graph.Reading | None = None
70+
self._triggers: list[Trigger] = []
71+
self._scope: str | None = None
72+
self._outlined: object = None
6273

6374
self.title = kit.label(role="title", wrap=False)
6475
self.hint = kit.label(HINT, role="muted", wrap=False)
@@ -79,20 +90,28 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
7990
self.view = GraphView()
8091
self.view.set_palette(graph.palette())
8192
v = self.view
82-
v.link_requested.connect(lambda *w: self._collect(lambda e: graph.link(e, *w)))
83-
v.unlink_requested.connect(lambda *w: self._collect(lambda e: graph.unlink(e, *w)))
84-
v.delete_requested.connect(lambda ids: self._collect(lambda e: graph.delete(e, ids)))
85-
v.moved.connect(lambda i, x, y: self._dropped.__setitem__(i, (x, y)))
93+
v.link_requested.connect(lambda *w: self._collect(self._wire(graph.link, *w)))
94+
v.unlink_requested.connect(lambda *w: self._collect(self._wire(graph.unlink, *w)))
95+
v.delete_requested.connect(self._delete)
96+
v.moved.connect(self._moved)
8697
v.create_requested.connect(self._create)
8798
v.picked.connect(self._picked)
8899
v.settled.connect(self._settle)
89100
self.bar = PropertyBar()
90101
self.bar.param_changed.connect(self._param)
91102
self.bar.label_changed.connect(self._label)
92103

104+
self.outline = triggers.Outline()
105+
self.outline.picked.connect(self._scoped)
93106
page = QWidget()
94-
lay = QVBoxLayout(page)
95-
lay.setContentsMargins(10, 8, 10, 8)
107+
side = QHBoxLayout(page)
108+
side.setContentsMargins(10, 8, 10, 8)
109+
side.setSpacing(10)
110+
side.addWidget(self.outline)
111+
main = QWidget()
112+
side.addWidget(main, 1)
113+
lay = QVBoxLayout(main)
114+
lay.setContentsMargins(0, 0, 0, 0)
96115
lay.setSpacing(6)
97116
lay.addWidget(kit.row(self.title, self.hint, self.fit, self.spread, stretch=True))
98117
lay.addWidget(self.view, 1)
@@ -111,6 +130,11 @@ def _spread(self) -> str:
111130
raise ManifestError("no port manifest open")
112131
return graph.spread(doc)
113132

133+
def _scoped(self, key: str) -> None:
134+
"""A row of the outline picked: that trigger's rules, framed to fit."""
135+
self.ws.scope = key
136+
self.sync()
137+
114138
# what the bar tells: the same intents as the canvas's, one undo step each
115139

116140
def _param(self, node: str, name: str, value: object) -> None:
@@ -131,11 +155,34 @@ def _collect(self, fn: Callable[[graph.Edits], str]) -> None:
131155
self._gesture = graph.Gesture(doc)
132156
self._gesture.run(fn)
133157

158+
def _scope_all(self) -> bool:
159+
return self.ws.scope == ALL
160+
161+
def _wire(self, edit: Callable[..., str], *wire: str) -> Callable[[graph.Edits], str]:
162+
"""The edit of a wire intent, or the refusal a trigger's rows give for it."""
163+
return (lambda e: edit(e, *wire)) if self._scope_all() else self._not_here
164+
165+
@staticmethod
166+
def _not_here(_: graph.Edits) -> str:
167+
raise ManifestError(SCOPED)
168+
169+
def _delete(self, ids: list[str]) -> None:
170+
nodes = list(dict.fromkeys(rows.node_of(i) for i in ids)) # a reference is its move
171+
self._collect(lambda e: graph.delete(e, nodes))
172+
173+
def _moved(self, node: str, x: float, y: float) -> None:
174+
if self._scope_all(): # the rows are laid out for display: a drag snaps back
175+
self._dropped[node] = (x, y)
176+
134177
def _create(self, kind_: str, x: float, y: float) -> None:
135178
doc = self.ws.doc
136-
if doc is not None:
137-
self._made = model.new_id(doc.manifest.behaviour)
138-
self._collect(lambda e: graph.add_block(e, kind_, (x, y)))
179+
if doc is None:
180+
return
181+
if not self._scope_all():
182+
self._collect(self._not_here)
183+
return
184+
self._made = model.new_id(doc.manifest.behaviour)
185+
self._collect(lambda e: graph.add_block(e, kind_, (x, y)))
139186

140187
def _settle(self) -> None:
141188
"""The gesture is whole: one undo step, or one refusal."""
@@ -163,7 +210,7 @@ def edit() -> None:
163210
def _picked(self, node: object) -> None:
164211
"""A move node picks its move, as the Moves table does; the bar shows the node."""
165212
self._show_bar()
166-
name = graph.move_name(node) if isinstance(node, str) else None
213+
name = graph.move_name(rows.node_of(node)) if isinstance(node, str) else None
167214
if name is None or name == self.ws.move:
168215
return
169216

@@ -184,18 +231,36 @@ def sync(self) -> None:
184231
self._theme = theme.current()
185232
self.view.set_roles(ROLES)
186233
self.view.set_canvas(theme.current().view)
234+
self._outlined = None
187235
if self._drawn is not m:
188236
self._drawn = m
237+
self._read = graph.read(m)
238+
self._triggers = model.triggers(m)
239+
self._scope = None
240+
block = self.ws.take_block()
241+
if block in m.behaviour.blocks:
242+
self._reach(block)
243+
if self.ws.scope not in (ALL, *(t.key for t in self._triggers)):
244+
self.ws.scope = ALL # its trigger is gone
245+
if self._scope != self.ws.scope or self._outlined is None:
189246
self._draw(m)
190-
self._follow(m)
247+
self._follow(m, block)
191248
self._show_bar()
192249

250+
def _reach(self, block: str) -> None:
251+
"""A block that a finding named and the scope does not draw: its trigger's rows, or All
252+
for a block on no rule."""
253+
scope = next((t for t in self._triggers if t.key == self.ws.scope), None)
254+
if scope is not None and not any(block in p.blocks for _, p in scope.rules):
255+
self.ws.scope = triggers.owner(self._triggers, block) or ALL
256+
193257
def _show_bar(self) -> None:
194258
"""The bar shows the one node selected, from the document: a refused edit goes back."""
195259
m, ids = self.ws.manifest, self.view.selected()
196260
self.bar.show_detail(self._detail(m, ids[0]) if m is not None and len(ids) == 1 else None)
197261

198262
def _detail(self, m: Manifest, node: str) -> Detail | None:
263+
node = rows.node_of(node)
199264
name = graph.move_name(node)
200265
if name is not None:
201266
mv = m.moves.get(name)
@@ -226,13 +291,52 @@ def _move_tip(mv: Move) -> str:
226291
return "\n".join([f"{kind(mv)} · {plays}", *([f"Note: {mv.label}"] if mv.label else [])])
227292

228293
def _draw(self, m: Manifest) -> None:
229-
r = self._read = graph.read(m)
230-
nodes = self._blocks(m, r) + self._moves(m)
231-
self.view.show(nodes, [LinkSpec(*w) for w in graph.wires(m)])
232-
loose = f" · {len(r.loose)} loose" if r.loose else ""
233-
self.title.setText(
234-
f"{len(m.behaviour.blocks)} blocks · {len(r.paths)} of {model.SEAM_RULES} paths" + loose
294+
r = self._read
295+
assert r is not None
296+
scope = self.ws.scope
297+
total = len(r.paths)
298+
key = (tuple((t.key, t.ranks) for t in self._triggers), total, scope, theme.current())
299+
if key != self._outlined:
300+
self._outlined = key
301+
self.outline.set_triggers(self._triggers, total, scope)
302+
blocks, moves = self._blocks(m, r), self._moves(m)
303+
t = next((t for t in self._triggers if t.key == scope), None)
304+
if t is None:
305+
self.view.show(blocks + moves, [LinkSpec(*w) for w in graph.wires(m)])
306+
loose = f" · {len(r.loose)} loose" if r.loose else ""
307+
self.title.setText(
308+
f"{len(m.behaviour.blocks)} blocks · {total} of {model.SEAM_RULES} paths" + loose
309+
)
310+
else:
311+
self._draw_rows(m, r, t, blocks, moves)
312+
self.title.setText(triggers.says(t, total))
313+
self.hint.setText(HINT if t is None else SCOPED)
314+
self.spread.setEnabled(t is None)
315+
if self._scope is not None and self._scope != scope:
316+
self.view.fit()
317+
self._scope = scope
318+
319+
def _draw_rows(
320+
self,
321+
m: Manifest,
322+
r: graph.Reading,
323+
t: Trigger,
324+
blocks: list[NodeSpec],
325+
moves: list[NodeSpec],
326+
) -> None:
327+
"""`t`'s rules as rows: drawn once to measure the nodes, then placed."""
328+
notes = {i: b.label for i, b in m.behaviour.blocks.items()}
329+
plan = rows.plan(
330+
t,
331+
{n.id: n for n in blocks},
332+
notes,
333+
dict(zip(m.moves, moves, strict=True)),
334+
rows.players(r.paths),
235335
)
336+
links = list(plan.links)
337+
self.view.show([dataclasses.replace(n, at=(0.0, 0.0)) for n in plan.nodes], links)
338+
placed, frames = rows.place(plan, self.view.sizes())
339+
self.view.show(placed, links, frames)
236340

237341
@staticmethod
238342
def _blocks(m: Manifest, r: graph.Reading) -> list[NodeSpec]:
@@ -276,10 +380,9 @@ def _moves(m: Manifest) -> list[NodeSpec]:
276380
)
277381
return out
278382

279-
def _follow(self, m: Manifest) -> None:
383+
def _follow(self, m: Manifest, block: str | None) -> None:
280384
"""A block a finding named is picked and framed; a move picked elsewhere is selected."""
281385
ws = self.ws
282-
block = ws.take_block()
283386
if block in m.behaviour.blocks:
284387
self.view.select([block])
285388
self.view.frame([block])

0 commit comments

Comments
 (0)