22# SPDX-FileCopyrightText: 2026 sp00ktober
33"""The Moves dock: every move of the port, on a base monster's action or its own; an own move
44made from the clip on screen, renamed, deleted, its fields and steer edited, and played in the
5- running game. Its attack windows are drawn on the Timeline. Under them, the rules."""
5+ running game. Its attack windows are drawn on the Timeline. The moves that follow one another are
6+ a sequence, grouped in the table and edited under the picked move. Under them, the rules."""
67
78from __future__ import annotations
89
910from typing import TYPE_CHECKING
1011
12+ from mhfu_port import sequence
1113from mhfu_port .manifest import MOVE_ATTACKS , TURNS , Move
1214from PySide6 .QtWidgets import QAbstractSpinBox , QSpinBox , QVBoxLayout , QWidget
1315
1416from mhfu_studio .monster import rules
1517from mhfu_studio .monster import validate as V
1618from mhfu_studio .monster .panels .common import kind
1719from mhfu_studio .monster .panels .rules import RulesSection
20+ from mhfu_studio .monster .panels .sequence import ClipPicker , StepChips
1821from mhfu_studio .monster .panels .widgets import NoScene
1922from mhfu_studio .shell .findings import Finding
2023from mhfu_studio .shell .text import plain
3841)
3942
4043
44+ def row_name (name : str , place : int , count : int ) -> str :
45+ """A move's cell in the table: a step under its head, or a head with its step count."""
46+ if place :
47+ return f" \u2514 { name } "
48+ return name if count == 1 else f"{ name } \u00b7 { count } steps"
49+
50+
4151def special (box : QSpinBox , text : str ) -> QSpinBox :
4252 """`box` shows `text` at its lowest value, which means "none"."""
4353 box .setSpecialValueText (text )
@@ -55,7 +65,8 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
5565 self .table = kit .Table (
5666 ["Move" , "Plays on" , "Clip" , "Attacks" ],
5767 tip = "Your moves: on one of the base monster's actions, or your own, which the move"
58- " player plays whole. Click one to watch it." ,
68+ " player plays whole. The moves that follow one another are grouped under the first."
69+ " Click one to watch it." ,
5970 )
6071 self .table .picked .connect (
6172 lambda n : act ("pick move" , lambda : ws .select_move (str (n )))() if n else None
@@ -76,6 +87,7 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
7687
7788 self .editor = kit .Section ("This move" , tip = "The picked own move: what the game plays" )
7889 self .title = kit .label (role = "title" , wrap = False )
90+ self .seq = self ._sequence_section ()
7991 self .clip = kit .label (role = "muted" )
8092 self .play_clip = kit .button (
8193 "Play its clip" ,
@@ -230,6 +242,7 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
230242 body .addWidget (w )
231243 top : tuple [QWidget , ...] = (
232244 self .title ,
245+ self .seq ,
233246 kit .row (self .clip , self .play_clip , self .in_game , self .force , stretch = True ),
234247 self .game_hint ,
235248 self .pair_note ,
@@ -265,8 +278,111 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
265278 for box in (self .length , self .main , self .sub , self .rate , self .angle , self .frames , self .dir ):
266279 box .setButtonSymbols (QAbstractSpinBox .ButtonSymbols .NoButtons )
267280
281+ def _sequence_section (self ) -> kit .Section :
282+ """The picked move's sequence: a chip per step, the edits, the clip picker."""
283+ ws , act = self .ws , self .studio .act
284+ sec = kit .Section (
285+ "Sequence" , tip = "The moves that follow one another, each handing to the next by Then"
286+ )
287+ #: how the picker was opened, and for which move
288+ self ._picking : tuple [str , str ] | None = None
289+ self .chips = StepChips ()
290+ self .chips .picked .connect (lambda n : act ("pick step" , lambda : ws .select_move (n ))())
291+ self .add_step = kit .button (
292+ "Add step after" ,
293+ tip = "Lists the clips best fit first after this move's clip, and puts the one you click"
294+ " in a new own move that follows this one" ,
295+ on = lambda : self ._open ("add" ),
296+ icon = "ph.plus" ,
297+ )
298+ self .change_clip = kit .button (
299+ "Change clip" ,
300+ tip = "Lists the clips best fit first after the step before, and plays the one you"
301+ " click in this step" ,
302+ on = lambda : self ._open ("change" ),
303+ icon = "ph.swap" ,
304+ )
305+ self .remove_step = kit .button (
306+ "Remove step" ,
307+ tip = "Deletes this step; the move before it hands to the move after it. Refused while"
308+ " a rule or an effect names it." ,
309+ on = act ("remove step" , ws .remove_step ),
310+ icon = "ph.trash" ,
311+ )
312+ self .earlier = kit .button (
313+ "Earlier" ,
314+ tip = "Swaps this step with the one before it" ,
315+ on = act ("earlier" , lambda : ws .move_step (- 1 )),
316+ icon = "ph.arrow-left" ,
317+ )
318+ self .later = kit .button (
319+ "Later" ,
320+ tip = "Swaps this step with the one after it" ,
321+ on = act ("later" , lambda : ws .move_step (1 )),
322+ icon = "ph.arrow-right" ,
323+ )
324+ self .split = kit .button (
325+ "Split here" ,
326+ tip = "Ends the sequence before this step, which starts one of its own" ,
327+ on = act ("split sequence" , ws .split_step ),
328+ icon = "ph.link-break" ,
329+ )
330+ self .play_seq = kit .button (
331+ "Play sequence" ,
332+ tip = "Plays the sequence from its first step, each clip after the other at the game's"
333+ " speed, each turning as its move steers" ,
334+ on = act ("play sequence" , ws .play_sequence ),
335+ icon = "ph.play" ,
336+ )
337+ self .picker = ClipPicker (ws , self .studio )
338+ self .picker .picked .connect (self ._picked )
339+ self .picker .cancelled .connect (self ._close_picker )
340+ self .picker .hide ()
341+ for w in (
342+ self .chips ,
343+ kit .row (self .add_step , self .change_clip , self .remove_step , stretch = True ),
344+ kit .row (self .earlier , self .later , self .split , self .play_seq , stretch = True ),
345+ self .picker ,
346+ ):
347+ sec .body .addWidget (w )
348+ return sec
349+
268350 # acting
269351
352+ def _open (self , mode : str ) -> None :
353+ if self .ws .move is not None :
354+ self ._picking = mode , self .ws .move
355+ self ._show_picker ()
356+
357+ def _close_picker (self ) -> None :
358+ self ._picking = None
359+ self .picker .hide ()
360+
361+ def _picked (self , clip : str ) -> None :
362+ mode = self ._picking [0 ] if self ._picking else ""
363+ self ._close_picker ()
364+ if mode == "add" :
365+ self .studio .act ("add step" , lambda : self .ws .add_step (clip ))()
366+ elif mode == "change" :
367+ self .studio .act ("change clip" , lambda : self .ws .set_step_clip (clip ))()
368+
369+ def _show_picker (self ) -> None :
370+ """The picker for the move it was opened for, ranked after the clip it follows."""
371+ ws , m = self .ws , self .ws .manifest
372+ if self ._picking is None or m is None or ws .move != self ._picking [1 ]:
373+ self ._close_picker ()
374+ return
375+ mode , name = self ._picking
376+ steps = ws .sequence_of ()
377+ i = steps .index (name )
378+ if mode == "add" :
379+ after , title = m .moves [name ].clip , f"A step after { name } "
380+ else :
381+ after = m .moves [steps [i - 1 ]].clip if i else None
382+ title = f"The clip of { name } "
383+ self .picker .show_after (after , title )
384+ self .picker .show ()
385+
270386 def _rename (self ) -> None :
271387 new = self .name .text ().strip ()
272388 if new and new != self .ws .move :
@@ -294,10 +410,13 @@ def sync(self) -> None:
294410 own = sum (mv .own for mv in m .moves .values ())
295411 self .count .setText (f"{ len (m .moves )} moves, { own } of them own" )
296412 rows , data , tips = [], [], []
297- for n , mv in m .moves .items ():
298- rows .append ([n , kind (mv ), mv .clip or f"anim { mv .anim } " , self ._attacks (mv )])
299- data .append (n )
300- tips .append (mv .label or ("" if mv .pair is None else "edit it in Actions" ))
413+ for steps in sequence .groups (m ).values ():
414+ for k , n in enumerate (steps ):
415+ mv = m .moves [n ]
416+ shown = row_name (n , k , len (steps ))
417+ rows .append ([shown , kind (mv ), mv .clip or f"anim { mv .anim } " , self ._attacks (mv )])
418+ data .append (n )
419+ tips .append (mv .label or ("" if mv .pair is None else "edit it in Actions" ))
301420 self .table .set_rows (rows , data , tips = tips )
302421 self .table .fit (ROWS )
303422 self .table .select_data (ws .move )
@@ -306,6 +425,8 @@ def sync(self) -> None:
306425 picked = None if ws .move is None else m .moves .get (ws .move )
307426 self .delete .setEnabled (picked is not None )
308427 self .editor .setVisible (picked is not None )
428+ if picked is None :
429+ self ._close_picker ()
309430 if picked is not None and ws .move is not None :
310431 self ._sync_move (ws .move , picked )
311432 self .rules .sync (self ._checks ())
@@ -345,8 +466,22 @@ def _sync_move(self, name: str, mv: Move) -> None:
345466 kit .put (self .name , name )
346467 if own :
347468 self ._sync_own (name , mv )
469+ self ._sync_sequence (name , own )
348470 self ._findings (name )
349471
472+ def _sync_sequence (self , name : str , own : bool ) -> None :
473+ ws = self .ws
474+ steps = ws .sequence_of (name )
475+ i = steps .index (name )
476+ self .chips .show_steps ([ws .step_of (n ) for n in steps ], name )
477+ for w in (self .add_step , self .change_clip , self .remove_step ):
478+ w .setEnabled (own )
479+ self .earlier .setEnabled (i > 0 )
480+ self .later .setEnabled (i < len (steps ) - 1 )
481+ self .split .setEnabled (i > 0 )
482+ if self ._picking is not None :
483+ self ._show_picker ()
484+
350485 def _sync_own (self , name : str , mv : Move ) -> None :
351486 ws , m = self .ws , self .ws .manifest
352487 assert m is not None
0 commit comments