Skip to content

Commit 5893d4c

Browse files
committed
Merge branch 'moveset/rules' into moveset/rage
2 parents d942f1d + a962cd9 commit 5893d4c

6 files changed

Lines changed: 357 additions & 24 deletions

File tree

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,12 @@ def _settle(self) -> None:
160160
if gesture is None:
161161
return
162162
done = gesture
163-
self.studio.act("edit behaviour", lambda: self.ws.edit("", done.commit))()
163+
164+
def edit() -> None:
165+
self.ws.edit("", done.commit)
166+
self.sync() # the window syncs a turn on: after the view would have put the canvas back
167+
168+
self.studio.act("edit behaviour", edit)()
164169
m = self.ws.manifest
165170
if made is not None and m is not None and made in m.behaviour.blocks:
166171
self.view.select([made])

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

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,18 @@
2020
from mhfu_port.behaviour import ID
2121
from NodeGraphQt import BaseNode, NodeBaseWidget, NodeGraph, Port
2222
from NodeGraphQt.qgraphics.node_base import NodeItem
23-
from PySide6.QtCore import QRectF, QSignalBlocker, Qt, QTimer, Signal
24-
from PySide6.QtGui import QFont, QKeyEvent, QPainter, QShowEvent, QUndoCommand, QUndoStack
23+
from PySide6.QtCore import QEvent, QObject, QPoint, QRectF, QSignalBlocker, Qt, QTimer, Signal
24+
from PySide6.QtGui import (
25+
QFont,
26+
QInputDevice,
27+
QKeyEvent,
28+
QNativeGestureEvent,
29+
QPainter,
30+
QShowEvent,
31+
QUndoCommand,
32+
QUndoStack,
33+
QWheelEvent,
34+
)
2535
from PySide6.QtWidgets import (
2636
QComboBox,
2737
QDoubleSpinBox,
@@ -53,6 +63,11 @@
5363
NONE = "none"
5464
#: where a number field with no bound ends
5565
_FAR = 10**9
66+
#: a scroll without pixels moves this many pixels per angle unit (a notch of 120 is ~3 lines)
67+
_ANGLE_PX = 0.5
68+
#: the zoom per angle unit of a wheel, as NodeGraphQt has it, and the scale the canvas keeps to
69+
_WHEEL = 1.001
70+
_SCALE = (0.05, 3.0)
5671

5772

5873
class ParamLike(Protocol):
@@ -134,6 +149,65 @@ def endMacro(self) -> None: # noqa: N802
134149
pass
135150

136151

152+
class _Navigation(QObject):
153+
"""The canvas as macOS canvas apps move: two-finger scroll pans; a pinch, Ctrl/Cmd+scroll and
154+
a mouse wheel zoom, within `_SCALE`. NodeGraphQt's own wheel zoom has no limit."""
155+
156+
def __init__(self, viewer: Any) -> None:
157+
super().__init__(viewer)
158+
self._viewer = viewer
159+
viewer.viewport().installEventFilter(self)
160+
161+
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
162+
if isinstance(event, QWheelEvent):
163+
if self._pad(event):
164+
self._pan(event.pixelDelta(), event.angleDelta())
165+
else:
166+
angle = event.angleDelta()
167+
self._zoom(_WHEEL ** (angle.y() or angle.x()), event.position().toPoint())
168+
return True
169+
if (
170+
isinstance(event, QNativeGestureEvent)
171+
and event.gestureType() == Qt.NativeGestureType.ZoomNativeGesture
172+
):
173+
self._zoom(1.0 + event.value(), event.position().toPoint())
174+
return True
175+
return False
176+
177+
@staticmethod
178+
def _pad(ev: QWheelEvent) -> bool:
179+
"""A scroll from a touchpad (or any device sending pixels or phases), not zooming."""
180+
if ev.modifiers() & (
181+
Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier
182+
):
183+
return False
184+
return (
185+
not ev.pixelDelta().isNull()
186+
or ev.phase() != Qt.ScrollPhase.NoScrollPhase
187+
or ev.pointingDevice().type() == QInputDevice.DeviceType.TouchPad
188+
)
189+
190+
def _pan(self, pixels: QPoint, angle: QPoint) -> None:
191+
"""The content follows the fingers: the scene rect moves against them."""
192+
dx, dy = (
193+
(pixels.x(), pixels.y())
194+
if not pixels.isNull()
195+
else (angle.x() * _ANGLE_PX, angle.y() * _ANGLE_PX)
196+
)
197+
v = self._viewer
198+
a, b = v.mapToScene(QPoint(0, 0)), v.mapToScene(QPoint(round(dx), round(dy)))
199+
x, y, w, h = v.scene_rect()
200+
v.set_scene_rect([x + a.x() - b.x(), y + a.y() - b.y(), w, h])
201+
202+
def _zoom(self, factor: float, at: QPoint) -> None:
203+
"""Scales about `at` (view pixels); a canvas past a limit may still come back."""
204+
v = self._viewer
205+
now = v.transform().m11()
206+
lo, hi = min(_SCALE[0], now), max(_SCALE[1], now)
207+
f = min(max(factor, lo / now), hi / now)
208+
v.scale(f, f, v.mapToScene(at))
209+
210+
137211
class _Item(NodeItem): # type: ignore[misc]
138212
"""A node's drawing: a badge in the title bar, and no renaming."""
139213

@@ -423,6 +497,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
423497
self._framed = False # the first non-empty `show` frames its nodes
424498

425499
viewer = self._graph.viewer()
500+
self._navigation = _Navigation(viewer)
426501
viewer.setMinimumSize(240, 160)
427502
for act in (viewer.qaction_for_undo(), viewer.qaction_for_redo()):
428503
act.setShortcuts([]) # else Ctrl+Z stops here, at an empty stack, not at the studio
@@ -614,20 +689,15 @@ def _sync(self, s: _Shown, spec: NodeSpec) -> None:
614689
x, y = view.xy_pos
615690
if abs(x - spec.at[0]) > 1e-3 or abs(y - spec.at[1]) > 1e-3:
616691
view.xy_pos = spec.at
617-
resized = False
618692
for param in spec.params:
619693
f = s.fields[param.name]
620-
opts = self._options(spec, param)
621-
if f.set_options(opts):
622-
resized = True
694+
f.set_options(self._options(spec, param))
623695
want = spec.values.get(param.name)
624696
if (want is not None or f.optional) and not f.same(want):
625697
f.set_value(want)
626698
if not s.fields[NOTE].same(spec.label):
627699
s.fields[NOTE].set_value(spec.label)
628-
if resized:
629-
view.draw_node()
630-
view.setToolTip(spec.tip) # after `draw_node`, which sets NodeGraphQt's own
700+
view.setToolTip(spec.tip) # NodeGraphQt sets its own when it draws the node
631701

632702
def _tint(self, role: str) -> tuple[tuple[int, ...], tuple[int, ...]] | None:
633703
ink = self._roles.get(role)

‎apps/studio/tests/monster/ui/test_behaviour.py‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import itertools
89
from collections.abc import Callable
910
from pathlib import Path
1011
from typing import Any
@@ -22,7 +23,7 @@
2223
from mhfu_studio.shell.workspace import Dock
2324
from mhfu_studio.ui import kit, theme
2425
from mhfu_studio.ui.testing import FakeWorkspace
25-
from PySide6.QtCore import QPoint, QPointF, Qt
26+
from PySide6.QtCore import QPoint, QPointF, QRectF, Qt
2627
from PySide6.QtTest import QTest
2728
from PySide6.QtWidgets import QComboBox, QDockWidget, QToolButton
2829

@@ -178,6 +179,76 @@ def test_the_window_opens_the_dock(
178179
assert dock.height() > 2 * strip.height() and dock.geometry().bottom() < strip.geometry().top()
179180

180181

182+
def open_hosted(
183+
make_window: Callable[..., Any], qtbot: Any, port_doc: PortDocument
184+
) -> tuple[MonsterWorkspace, BehaviourPanel]:
185+
"""The dock in a real `Window`, which syncs a turn after a change, over the `graph` port."""
186+
own = MonsterWorkspace()
187+
own.doc = port_doc
188+
port_doc.edit(graph)
189+
host = Hosted(own)
190+
w = make_window(host)
191+
host.focus = "Behaviour"
192+
w.studio.changed()
193+
w.sync()
194+
qtbot.wait(60)
195+
dock = w.findChild(QDockWidget, "monster/Behaviour")
196+
assert dock is not None and isinstance(dock.widget(), BehaviourPanel)
197+
return own, dock.widget()
198+
199+
200+
def applies(p: BehaviourPanel, node: str, name: str, monkeypatch: pytest.MonkeyPatch) -> list[Any]:
201+
"""Every value of `node.name` the view is told to draw, in order."""
202+
seen: list[Any] = []
203+
real = p.view._apply
204+
205+
def apply(nodes: Any, links: Any) -> None:
206+
seen.extend(n.values[name] for n in nodes if n.id == node)
207+
real(nodes, links)
208+
209+
monkeypatch.setattr(p.view, "_apply", apply)
210+
return seen
211+
212+
213+
def test_an_accepted_edit_applies_once_in_the_window(
214+
make_window: Callable[..., Any],
215+
qtbot: Any,
216+
port_doc: PortDocument,
217+
monkeypatch: pytest.MonkeyPatch,
218+
) -> None:
219+
own, p = open_hosted(make_window, qtbot, port_doc)
220+
seen = applies(p, "b1", "frames", monkeypatch)
221+
field(p, "b1", "frames")._ctl.setValue(45)
222+
qtbot.wait(80)
223+
assert doc(own).manifest.behaviour.blocks["b1"].params == {"frames": 45}
224+
assert seen == [45] and field(p, "b1", "frames").get_value() == 45
225+
226+
227+
def test_a_refused_edit_reverts_once_in_the_window(
228+
make_window: Callable[..., Any],
229+
qtbot: Any,
230+
port_doc: PortDocument,
231+
monkeypatch: pytest.MonkeyPatch,
232+
) -> None:
233+
blk = Block("host_state", (0.0, 0.0), {"mains": [0]})
234+
port_doc.edit(lambda m: m.behaviour.blocks.__setitem__("b4", blk))
235+
own, p = open_hosted(make_window, qtbot, port_doc)
236+
before = doc(own).manifest
237+
seen = applies(p, "b4", "mains", monkeypatch)
238+
field(p, "b4", "mains")._ctl.menu().actions()[0].trigger() # unticks the only main state
239+
qtbot.wait(80)
240+
assert doc(own).manifest is before and "main states" in own.message
241+
assert seen == [[0]] and field(p, "b4", "mains").get_value() == (0,)
242+
243+
244+
def test_a_block_made_in_the_window_is_selected(
245+
make_window: Callable[..., Any], qtbot: Any, port_doc: PortDocument
246+
) -> None:
247+
own, p = open_hosted(make_window, qtbot, port_doc)
248+
create(qtbot, p, KINDS["on_noticed"].title, QPoint(300, 120))
249+
assert "b4" in doc(own).manifest.behaviour.blocks and p.view.selected() == ["b4"]
250+
251+
181252
def test_empty(qtbot: Any) -> None:
182253
p = make(qtbot, MonsterWorkspace())
183254
assert p.pages.currentWidget() is p.no_scene
@@ -247,6 +318,22 @@ def test_the_zinogre_opens(
247318
assert field(p, "b2", "hi").get_value() == 1000.0
248319

249320

321+
def test_a_new_name_keeps_every_node_size_on_the_zinogre(
322+
qtbot: Any, ws: MonsterWorkspace, zinogre_toml: Path, synthetic_pac: bytes
323+
) -> None:
324+
from mhfu_studio.monster.core.scene import Scene
325+
326+
zin = PortDocument.open(zinogre_toml)
327+
ws.load(Scene.from_bytes(synthetic_pac, "z", path=zinogre_toml.with_suffix(".bin")), zin)
328+
p = make(qtbot, ws)
329+
p.view.grab() # a paint: the fitted graph is small, and NodeGraphQt hides small nodes' widgets
330+
sizes = {i: (s.view.width, s.view.height) for i, s in p.view._shown.items()}
331+
flag = next(i for i, b in zin.manifest.behaviour.blocks.items() if "flag" in b.params)
332+
type_name(qtbot, p, flag, "flag", "brand_new")
333+
assert zin.manifest.behaviour.blocks[flag].params["flag"] == "brand_new"
334+
assert {i: (s.view.width, s.view.height) for i, s in p.view._shown.items()} == sizes
335+
336+
250337
def test_spread_out_is_one_step(panel: BehaviourPanel, ws: MonsterWorkspace) -> None:
251338
before = doc(ws).manifest
252339
panel.spread.click()
@@ -256,6 +343,21 @@ def test_spread_out_is_one_step(panel: BehaviourPanel, ws: MonsterWorkspace) ->
256343
assert doc(ws).manifest == before
257344

258345

346+
def test_no_two_nodes_of_the_zinogre_overlap(
347+
qtbot: Any, ws: MonsterWorkspace, zinogre_toml: Path, synthetic_pac: bytes
348+
) -> None:
349+
from mhfu_studio.monster.core.scene import Scene
350+
351+
zin = PortDocument.open(zinogre_toml)
352+
ws.load(Scene.from_bytes(synthetic_pac, "z", path=zinogre_toml.with_suffix(".bin")), zin)
353+
p = make(qtbot, ws)
354+
rects = {
355+
i: QRectF(*n.view.xy_pos, n.view.width, n.view.height) for i, n in p.view._shown.items()
356+
}
357+
clash = [(a, b) for a, b in itertools.combinations(rects, 2) if rects[a].intersects(rects[b])]
358+
assert clash == []
359+
360+
259361
def test_part_and_main_options(panel: BehaviourPanel, ws: MonsterWorkspace) -> None:
260362
doc(ws).edit(lambda m: m.behaviour.blocks.__setitem__("b4", Block("on_flinch", (0.0, 0.0))))
261363
panel.sync()

0 commit comments

Comments
 (0)