33"""The Behaviour tab: the port's behaviour graph (`mhfu_port.behaviour`) on a node canvas.
44
55A 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
1114from __future__ import annotations
1215
16+ import dataclasses
1317from collections .abc import Callable
1418from typing import TYPE_CHECKING
1519
1620from mhfu_port import behaviour as model
17- from mhfu_port .behaviour import KINDS , Kind
21+ from mhfu_port .behaviour import KINDS , Kind , Trigger
1822from 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
2125from mhfu_studio .monster import behaviour as graph
26+ from mhfu_studio .monster .behaviour import ALL
27+ from mhfu_studio .monster .panels import rows , triggers
2228from mhfu_studio .monster .panels .common import kind
2329from mhfu_studio .monster .panels .node_graph import GraphView , LinkSpec , NodeSpec
2430from mhfu_studio .monster .panels .properties import Detail , PropertyBar
4147MOVE_ROLE = "move"
4248WARN = chr (0x26A0 )
4349HINT = "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
4654class 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