refactor(input): own pointer and callback lifetime
Move camera pointer, framebuffer resize, and retained/devtools input edges behind focused reversible owners. Preserve input priority while making shutdown deactivate callbacks before live-session retirement and retry physical detach without stranding transport teardown.
This commit is contained in:
parent
d09e246d3a
commit
8b8afeefa3
42 changed files with 4029 additions and 461 deletions
18
AGENTS.md
18
AGENTS.md
|
|
@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are
|
|||
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||
Release builds; 7,341 tests pass / 5 skip.
|
||||
Release builds; 7,477 tests pass / 5 skip.
|
||||
|
||||
**Active prerequisite before new M4 subsystem work:** continue the
|
||||
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
|
||||
|
|
@ -127,15 +127,19 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation,
|
|||
update-frame orchestration, and render-frame orchestration all have typed
|
||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||
the frame owners have no direct window or anonymous callback-bag ownership.
|
||||
Slice 8 checkpoints A–D are also complete: dead host residue is removed,
|
||||
Slice 8 checkpoints A–E are also complete: dead host residue is removed,
|
||||
native-window callback intake has reversible lifetime ownership, and
|
||||
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||
session controller. `WorldEnvironmentController` solely owns the clock, DAT
|
||||
sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow`
|
||||
is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from
|
||||
the 15,723-line campaign baseline. The 7,419-test
|
||||
Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second
|
||||
synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action
|
||||
sky/day group, weather, synchronization, and AdminEnvirons state.
|
||||
`CameraPointerInputController` and `FramebufferResizeController` now own raw
|
||||
camera input/focus/scroll and physical framebuffer publication. Silk,
|
||||
dispatcher, retained-UI, and devtools device edges are reversible,
|
||||
transactional, and terminal after disposal; live-session retirement precedes
|
||||
physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65
|
||||
methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The
|
||||
7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and
|
||||
395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints F–L—action
|
||||
routing, settings, resource ownership, ordered composition,
|
||||
retryable shutdown, soak snapshots, and final connected gates—remain active.
|
||||
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||
|
|
|
|||
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are
|
|||
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||
Release builds; 7,341 tests pass / 5 skip.
|
||||
Release builds; 7,477 tests pass / 5 skip.
|
||||
|
||||
**Active prerequisite before new M4 subsystem work:** continue the
|
||||
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
|
||||
|
|
@ -125,15 +125,19 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation,
|
|||
update-frame orchestration, and render-frame orchestration all have typed
|
||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||
the frame owners have no direct window or anonymous callback-bag ownership.
|
||||
Slice 8 checkpoints A–D are also complete: dead host residue is removed,
|
||||
Slice 8 checkpoints A–E are also complete: dead host residue is removed,
|
||||
native-window callback intake has reversible lifetime ownership, and
|
||||
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||
session controller. `WorldEnvironmentController` solely owns the clock, DAT
|
||||
sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow`
|
||||
is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from
|
||||
the 15,723-line campaign baseline. The 7,419-test
|
||||
Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second
|
||||
synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action
|
||||
sky/day group, weather, synchronization, and AdminEnvirons state.
|
||||
`CameraPointerInputController` and `FramebufferResizeController` now own raw
|
||||
camera input/focus/scroll and physical framebuffer publication. Silk,
|
||||
dispatcher, retained-UI, and devtools device edges are reversible,
|
||||
transactional, and terminal after disposal; live-session retirement precedes
|
||||
physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65
|
||||
methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The
|
||||
7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and
|
||||
395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints F–L—action
|
||||
routing, settings, resource ownership, ordered composition,
|
||||
retryable shutdown, soak snapshots, and final connected gates—remain active.
|
||||
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||
|
|
|
|||
|
|
@ -27,11 +27,12 @@ What does NOT go here:
|
|||
## Current queue — 2026-07-22
|
||||
|
||||
- **Active structural work:** `GameWindow` decomposition. Slices 1–7 are
|
||||
complete; Slice 8 checkpoints A–D now own native callbacks, live-session
|
||||
composition, and world-environment state outside the shell.
|
||||
complete; Slice 8 checkpoints A–E now own native callbacks, live-session
|
||||
composition, world-environment state, reversible device input/raw pointer,
|
||||
and physical framebuffer publication outside the shell.
|
||||
`GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration
|
||||
handoffs and the class is 4,330 lines / 192 fields / 67 methods. Remaining
|
||||
Slice 8 input/settings/composition/shutdown
|
||||
handoffs and the class is 4,266 lines / 194 fields / 65 methods. Remaining
|
||||
Slice 8 action-routing/settings/composition/shutdown
|
||||
cleanup is active in
|
||||
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
|
||||
This is the behavior-preserving prerequisite before new M4 feature bodies.
|
||||
|
|
|
|||
|
|
@ -827,9 +827,13 @@ Current movement/collision ownership:
|
|||
session generation into `LiveSessionController` plus its focused host and
|
||||
routers; Slice 4 moved hydration, teardown, inbound authority, network
|
||||
updates, and reusable motion-runtime policy over canonical
|
||||
`LiveEntityRuntime`. Slice 5 landblock presentation is next. The reconciled
|
||||
ledger and sequence live in `docs/architecture/code-structure.md`. This decision
|
||||
does not reclassify the remaining L.2 conformance work or Track LH.
|
||||
`LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the
|
||||
update/render frame graphs. Slice 8 checkpoints A–E now own the native
|
||||
callback shell, live-session composition, world environment, reversible
|
||||
input/pointer lifetime, and physical framebuffer publication. Gameplay action
|
||||
routing is the next checkpoint. The reconciled ledger and sequence live in
|
||||
`docs/architecture/code-structure.md`. This decision does not reclassify the
|
||||
remaining L.2 conformance work or Track LH.
|
||||
|
||||
Full `GameEntity` type aggregation remains a separate, higher-risk target after
|
||||
the thin-`GameWindow` campaign. The current campaign reuses canonical
|
||||
|
|
|
|||
|
|
@ -690,6 +690,16 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction.
|
|||
Silk callbacks become narrow calls into the input, update, render, resize,
|
||||
focus, and shutdown owners.
|
||||
|
||||
Checkpoint E is complete. `CameraPointerInputController` owns raw pointer,
|
||||
camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController`
|
||||
alone publishes physical framebuffer changes. All Silk, dispatcher, retained
|
||||
UI, and optional devtools device edges are reversible, transactional, and
|
||||
terminal after disposal. Logical cutoff precedes live-session retirement;
|
||||
physical detach follows it, so neither copied callbacks nor a failed event
|
||||
remove can interfere with graceful transport teardown. `GameWindow` is now
|
||||
4,266 raw lines / 194 fields / 65 methods. Checkpoints F–L remain active, with
|
||||
gameplay action routing next.
|
||||
|
||||
### 4.4 Exit criteria
|
||||
|
||||
The campaign is complete when:
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
|
||||
| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 37 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
|
||||
## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -263,6 +263,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
|
||||
| TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` |
|
||||
| TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) |
|
||||
| TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# acdream — strategic roadmap
|
||||
|
||||
**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–D are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
|
||||
**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–E are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
|
||||
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
|
||||
|
||||
---
|
||||
|
|
@ -79,12 +79,17 @@ second synchronized soak pass with graceful exits, and the six stable PNG
|
|||
checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse
|
||||
process-residency gate's diagnostic blind spot; no tolerance was loosened.
|
||||
|
||||
Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–D now own
|
||||
Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–E now own
|
||||
the frozen shell boundary, all nine native callback edges, live-session
|
||||
reset/selection/entry/route composition, and world clock/sky/day/weather/
|
||||
AdminEnvirons state. `GameWindow` is 4,330 lines / 192 fields / 67 methods;
|
||||
the Checkpoint D Release suite passes 7,419 tests / 5 skips. Input, settings,
|
||||
startup/resource transfer, and shutdown checkpoints E–L remain.
|
||||
AdminEnvirons state. Checkpoint E adds reversible, transactional Silk,
|
||||
dispatcher, retained-UI, and devtools input edges; a focused raw pointer owner;
|
||||
and one physical framebuffer-size publisher. Logical callback cutoff and
|
||||
live-session retirement precede physical removal, so failed device detach
|
||||
cannot block graceful transport close. `GameWindow` is 4,266 lines / 194
|
||||
fields / 65 methods; the Checkpoint E Release suite passes 7,477 tests / 5
|
||||
skips. Gameplay routing, settings, startup/resource transfer, shutdown, soak,
|
||||
and closeout checkpoints F–L remain.
|
||||
|
||||
This is a behavior-preserving structural program. Severe regressions still get
|
||||
root-cause fixes in separate commits; ordinary feature work resumes with M4
|
||||
|
|
|
|||
|
|
@ -39,12 +39,15 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is
|
|||
campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the
|
||||
315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized
|
||||
nine-destination soak pass with graceful exits, and six stable PNG checkpoints
|
||||
preserve Slice 6 presentation. Final Slice 8 checkpoints A–D are complete:
|
||||
preserve Slice 6 presentation. Final Slice 8 checkpoints A–E are complete:
|
||||
native callbacks and live-session composition now have explicit retryable
|
||||
owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky,
|
||||
day group, weather, and AdminEnvirons state. `GameWindow` is 4,330 lines / 192
|
||||
fields / 67 methods; 7,419 Release tests pass / 5 skip. Checkpoints E–L remain
|
||||
active. Issue #232 tracks process-residency variance in the soak without
|
||||
day group, weather, and AdminEnvirons state. Checkpoint E adds terminal,
|
||||
transactional ownership for Silk/dispatcher/retained/devtools input, the raw
|
||||
camera pointer, focus, and physical framebuffer size. Graceful live-session
|
||||
retirement now precedes physical callback removal. `GameWindow` is 4,266 lines
|
||||
/ 194 fields / 65 methods; 7,477 Release tests pass / 5 skip. Checkpoints F–L
|
||||
remain active. Issue #232 tracks process-residency variance in the soak without
|
||||
loosening its leak threshold.
|
||||
|
||||
Carried:
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ audit.
|
|||
- [x] C — extract live-session host/reset/binding callbacks and verify the
|
||||
embedded skill formula against named retail.
|
||||
- [x] D — extract retail world-environment/day/weather behavior.
|
||||
- [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer
|
||||
- [x] E — extract two-phase raw pointer/camera input, focus, and framebuffer
|
||||
resize behind symmetric named subscriptions.
|
||||
- [ ] F — extract the sole gameplay input-action router plus focused combat and
|
||||
diagnostic command owners.
|
||||
|
|
@ -497,6 +497,27 @@ this behavior-preserving ownership checkpoint.
|
|||
- Require rollback after every Nth event add and silence between every shutdown
|
||||
stage for raw/retained/devtools device events.
|
||||
|
||||
Result: `CameraPointerInputController` now owns raw move, camera-mode cursor,
|
||||
focus-loss, scroll, and per-mode sensitivity policy; its gameplay-frame edge is
|
||||
late-bound without resubscription. Silk keyboard/mouse sources,
|
||||
`InputDispatcher`, retained UI bindings, and the devtools
|
||||
`QuiescentInputContext` are publish-before-attach, terminal after disposal,
|
||||
transactional on every side-effecting event add, and retry only the physical
|
||||
edges that remain pending. Mouse-only hosts retain camera/cursor behavior.
|
||||
`FramebufferResizeController` is the sole physical framebuffer publisher and
|
||||
seeds viewport/aspect/camera state from `FramebufferSize`, while logical UI and
|
||||
frame dimensions remain `Window.Size`. Shutdown now performs no-throw logical
|
||||
input cutoff, retires the live session, then removes physical callbacks; the
|
||||
post-session mouse-look release therefore cannot send a final movement packet,
|
||||
and a bad Silk remove accessor cannot block F653/transport teardown. TS-56
|
||||
records the carried non-retail camera input scalars. Three corrected-diff review
|
||||
loops are clean; 66 focused App tests, 9 dispatcher-lifetime tests, 5 Core input
|
||||
integration tests, the warning-free production Release build, and the complete
|
||||
Release suite (7,477 pass / 5 intentional skips) pass. `GameWindow` is 4,266
|
||||
raw lines / 194 fields / 65 methods. No connected gate was required for this
|
||||
behavior-preserving ownership checkpoint; Checkpoint L retains the final
|
||||
connected lifecycle and visual gates.
|
||||
|
||||
### F — gameplay action and command routing
|
||||
|
||||
- Move the frozen action-priority graph into the sole Fired subscriber.
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
## Current state
|
||||
|
||||
The behavior-preserving App ownership campaign is complete through Slice 7 and
|
||||
Slice 8 checkpoints A–D. `GameWindow.cs` moved from the 2026-07-21 baseline of
|
||||
15,723 lines / 278 fields / 205 methods to 4,330 lines / 192 fields / 67
|
||||
methods: 11,393 lines (72.5%) were removed without changing accepted gameplay
|
||||
Slice 8 checkpoints A–E. `GameWindow.cs` moved from the 2026-07-21 baseline of
|
||||
15,723 lines / 278 fields / 205 methods to 4,266 lines / 194 fields / 65
|
||||
methods: 11,457 lines (72.9%) were removed without changing accepted gameplay
|
||||
or rendering behavior.
|
||||
|
||||
| Slice | Ownership moved out | Closeout size |
|
||||
|
|
@ -17,7 +17,7 @@ or rendering behavior.
|
|||
| 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 |
|
||||
| 6 | complete update-frame orchestration | 7,026 / 241 / 108 |
|
||||
| 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 |
|
||||
| 8 A–D | shell freeze, native callbacks, live-session composition, world environment | 4,330 / 192 / 67 |
|
||||
| 8 A–E | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 |
|
||||
|
||||
Slice 8 is an ordered checkpoint campaign rather than another feature-body
|
||||
move. A froze/pruned the shell boundary; B gave all nine native window callback
|
||||
|
|
@ -34,6 +34,15 @@ composition edges. The named-oracle correction is
|
|||
`SkyDesc::CalcPresentDayGroup @ 0x00500E10`; the carried AdminEnvirons audio and
|
||||
full fog/ambient/radar gaps are TS-54/TS-55.
|
||||
|
||||
E adds `CameraPointerInputController`, `FramebufferResizeController`, terminal
|
||||
publish-before-attach Silk/dispatcher/retained input owners, and a quiescent
|
||||
non-owning input-context view for optional devtools. Mouse-only hosts still
|
||||
receive raw camera/cursor behavior. Logical callback deactivation runs before
|
||||
the graceful session close; physical removal and the local mouse-look release
|
||||
run only after session retirement. This prevents both copied callback reentry
|
||||
and a failed event accessor from blocking F653/transport teardown. The carried
|
||||
non-retail post-filter camera scalars are explicitly registered as TS-56.
|
||||
|
||||
Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`,
|
||||
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
|
||||
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
|
||||
|
|
@ -90,6 +99,9 @@ review rule live in `docs/architecture/code-structure.md`.
|
|||
|
||||
## Accepted verification
|
||||
|
||||
- Slice 8 Checkpoint E Release suite: 7,477 passed / 5 fixture or environment
|
||||
skips; App 3,108/3 skips, UI Abstractions 533/0, and all three independent
|
||||
corrected-diff reviews are clean.
|
||||
- Slice 8 Checkpoint D Release suite: 7,419 passed / 5 fixture or environment
|
||||
skips; App 3,059/3 skips. Focused environment/boundary tests and all three
|
||||
independent corrected-diff reviews are clean.
|
||||
|
|
@ -119,7 +131,7 @@ review rule live in `docs/architecture/code-structure.md`.
|
|||
|
||||
## Next work
|
||||
|
||||
Slice 8 checkpoints E–L group the remaining input, settings, resource
|
||||
Slice 8 checkpoints F–L group the remaining action routing, settings, resource
|
||||
ownership, startup, and shutdown wiring and reduce Silk callbacks to
|
||||
narrow calls into input, update, render, resize, focus, and shutdown owners.
|
||||
It removes leftover feature state only where Slices 1–7 already established a
|
||||
|
|
|
|||
449
src/AcDream.App/Input/CameraPointerInputController.cs
Normal file
449
src/AcDream.App/Input/CameraPointerInputController.cs
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Narrow raw-pointer surface. The production adapter keeps the Silk event
|
||||
/// accessor out of the camera policy and lets failure tests exercise an event
|
||||
/// add/remove without implementing Silk's complete mouse contract.
|
||||
/// </summary>
|
||||
internal interface IRawPointerSurface
|
||||
{
|
||||
void AddMouseMove(Action<Vector2> callback);
|
||||
void RemoveMouseMove(Action<Vector2> callback);
|
||||
}
|
||||
|
||||
internal sealed class SilkRawPointerSurface : IRawPointerSurface
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private Action<Vector2>? _callback;
|
||||
private readonly Action<IMouse, Vector2> _mouseMove;
|
||||
|
||||
public SilkRawPointerSurface(IMouse mouse)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_mouseMove = OnMouseMove;
|
||||
}
|
||||
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if (_callback is not null)
|
||||
throw new InvalidOperationException("The raw mouse surface is already attached.");
|
||||
|
||||
// Publish before invoking the custom event accessor: an accessor may
|
||||
// perform its side effect and then throw, in which case rollback still
|
||||
// has the exact delegate needed to remove it.
|
||||
_callback = callback;
|
||||
_mouse.MouseMove += _mouseMove;
|
||||
}
|
||||
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if (!ReferenceEquals(_callback, callback))
|
||||
return;
|
||||
|
||||
_mouse.MouseMove -= _mouseMove;
|
||||
_callback = null;
|
||||
}
|
||||
|
||||
private void OnMouseMove(IMouse _, Vector2 position) =>
|
||||
_callback?.Invoke(position);
|
||||
}
|
||||
|
||||
internal interface IPointerCursorModeTarget
|
||||
{
|
||||
CursorMode CursorMode { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SilkPointerCursorModeTarget(IMouse mouse)
|
||||
: IPointerCursorModeTarget
|
||||
{
|
||||
private readonly IMouse _mouse = mouse
|
||||
?? throw new ArgumentNullException(nameof(mouse));
|
||||
|
||||
public CursorMode CursorMode
|
||||
{
|
||||
get => _mouse.Cursor.CursorMode;
|
||||
set => _mouse.Cursor.CursorMode = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns raw pointer subscriptions and the camera-facing pointer policy that
|
||||
/// previously lived in GameWindow. Physical event removal is retriable; logical
|
||||
/// deactivation is immediate, so a copied or stubborn Silk delegate cannot
|
||||
/// re-enter gameplay while shutdown is closing the live session.
|
||||
/// </summary>
|
||||
internal sealed class CameraPointerInputController : IDisposable
|
||||
{
|
||||
private readonly IReadOnlyList<IRawPointerSurface> _surfaces;
|
||||
private readonly IPointerCursorModeTarget _cursor;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly IInputCaptureSource _capture;
|
||||
private readonly LocalPlayerModeState _playerMode;
|
||||
private readonly CameraController _camera;
|
||||
private readonly ChaseCameraInputState _chase;
|
||||
private readonly IMouseSource _mouse;
|
||||
private readonly PointerPositionState _pointer;
|
||||
private readonly IInputMonotonicClock _clock;
|
||||
private readonly Action<Vector2> _mouseMoved;
|
||||
private readonly Action<bool> _cameraModeChanged;
|
||||
private readonly bool[] _surfaceAttached;
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private GameplayInputFrameController? _gameplayFrame;
|
||||
private bool _cameraAttached;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
private float _flySensitivity = 1f;
|
||||
private float _orbitSensitivity = 1f;
|
||||
|
||||
public CameraPointerInputController(
|
||||
IReadOnlyList<IRawPointerSurface> surfaces,
|
||||
IPointerCursorModeTarget cursor,
|
||||
HostQuiescenceGate quiescence,
|
||||
IInputCaptureSource capture,
|
||||
LocalPlayerModeState playerMode,
|
||||
CameraController camera,
|
||||
ChaseCameraInputState chase,
|
||||
IMouseSource mouse,
|
||||
PointerPositionState pointer,
|
||||
IInputMonotonicClock clock)
|
||||
{
|
||||
_surfaces = surfaces ?? throw new ArgumentNullException(nameof(surfaces));
|
||||
if (_surfaces.Any(static surface => surface is null))
|
||||
throw new ArgumentException("Raw pointer surfaces cannot contain null.", nameof(surfaces));
|
||||
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_surfaceAttached = new bool[_surfaces.Count];
|
||||
_mouseMoved = OnMouseMoved;
|
||||
_cameraModeChanged = OnCameraModeChanged;
|
||||
}
|
||||
|
||||
public static CameraPointerInputController Create(
|
||||
IReadOnlyList<IMouse> mice,
|
||||
HostQuiescenceGate quiescence,
|
||||
IInputCaptureSource capture,
|
||||
LocalPlayerModeState playerMode,
|
||||
CameraController camera,
|
||||
ChaseCameraInputState chase,
|
||||
IMouseSource mouse,
|
||||
PointerPositionState pointer,
|
||||
IInputMonotonicClock clock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mice);
|
||||
if (mice.Count == 0)
|
||||
throw new ArgumentException("At least one mouse is required.", nameof(mice));
|
||||
|
||||
return new CameraPointerInputController(
|
||||
mice.Select(static candidate =>
|
||||
(IRawPointerSurface)new SilkRawPointerSurface(candidate)).ToArray(),
|
||||
new SilkPointerCursorModeTarget(mice[0]),
|
||||
quiescence,
|
||||
capture,
|
||||
playerMode,
|
||||
camera,
|
||||
chase,
|
||||
mouse,
|
||||
pointer,
|
||||
clock);
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete =>
|
||||
!_cameraAttached && _surfaceAttached.All(static attached => !attached);
|
||||
|
||||
public float ActiveSensitivity
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
|
||||
return _chase.Sensitivity;
|
||||
if (_camera.IsFlyMode)
|
||||
return _flySensitivity;
|
||||
return _orbitSensitivity;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RmbOrbitHeld => _chase.RmbOrbitHeld;
|
||||
|
||||
public void AttachRaw()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Raw pointer attachment has already started.");
|
||||
_attachStarted = true;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < _surfaces.Count; i++)
|
||||
{
|
||||
_surfaceAttached[i] = true;
|
||||
_surfaces[i].AddMouseMove(_mouseMoved);
|
||||
}
|
||||
|
||||
_cameraAttached = true;
|
||||
_camera.ModeChanged += _cameraModeChanged;
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Raw pointer registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Raw pointer registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Raw pointer registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the deliberate two-phase composition edge. Binding the frame
|
||||
/// owner never resubscribes or changes event priority.
|
||||
/// </summary>
|
||||
public void BindGameplayFrame(GameplayInputFrameController gameplayFrame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gameplayFrame);
|
||||
if (_gameplayFrame is not null
|
||||
&& !ReferenceEquals(_gameplayFrame, gameplayFrame))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The raw pointer owner is already bound to another gameplay frame.");
|
||||
}
|
||||
|
||||
_gameplayFrame = gameplayFrame;
|
||||
}
|
||||
|
||||
public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gameplayFrame);
|
||||
if (ReferenceEquals(_gameplayFrame, gameplayFrame))
|
||||
_gameplayFrame = null;
|
||||
}
|
||||
|
||||
public void HandleFocusChanged(bool focused)
|
||||
{
|
||||
if (!focused)
|
||||
_gameplayFrame?.EndMouseLook();
|
||||
}
|
||||
|
||||
public void HandleScroll(InputAction action)
|
||||
{
|
||||
float direction = action switch
|
||||
{
|
||||
InputAction.ScrollUp => 1f,
|
||||
InputAction.ScrollDown => -1f,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(action)),
|
||||
};
|
||||
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
|
||||
{
|
||||
if (CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is not null)
|
||||
_chase.Retail.AdjustDistance(-direction * 0.8f);
|
||||
else
|
||||
_chase.Legacy?.AdjustDistance(-direction * 0.8f);
|
||||
}
|
||||
else if (!_camera.IsFlyMode)
|
||||
{
|
||||
_camera.Orbit.Distance = Math.Clamp(
|
||||
_camera.Orbit.Distance - direction * 20f,
|
||||
50f,
|
||||
2000f);
|
||||
}
|
||||
}
|
||||
|
||||
public string AdjustSensitivity(float factor)
|
||||
{
|
||||
string mode;
|
||||
float current;
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
|
||||
{
|
||||
mode = "Chase";
|
||||
current = _chase.Sensitivity;
|
||||
}
|
||||
else if (_camera.IsFlyMode)
|
||||
{
|
||||
mode = "Fly";
|
||||
current = _flySensitivity;
|
||||
}
|
||||
else
|
||||
{
|
||||
mode = "Orbit";
|
||||
current = _orbitSensitivity;
|
||||
}
|
||||
|
||||
float next = MathF.Min(3f, MathF.Max(0.005f, current * factor));
|
||||
if (mode == "Chase")
|
||||
_chase.Sensitivity = next;
|
||||
else if (mode == "Fly")
|
||||
_flySensitivity = next;
|
||||
else
|
||||
_orbitSensitivity = next;
|
||||
|
||||
return $"{mode} sens {next:F3}x";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immediate callback cutoff used before the live-session close. The
|
||||
/// gameplay mouse-look transition is retained until the session has
|
||||
/// retired so it cannot publish one last movement packet.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
Interlocked.Exchange(ref _active, 0);
|
||||
_chase.RmbOrbitHeld = false;
|
||||
}
|
||||
|
||||
/// <summary>Release presentation state only after live-session retirement.</summary>
|
||||
public void ReleaseMouseLookAfterSessionRetirement() =>
|
||||
_gameplayFrame?.EndMouseLook();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnMouseMoved(Vector2 position) =>
|
||||
_quiescence.Invoke(() => ProcessMouseMove(position));
|
||||
|
||||
private void ProcessMouseMove(Vector2 position)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return;
|
||||
|
||||
if (_capture.WantCaptureMouse)
|
||||
{
|
||||
_pointer.X = position.X;
|
||||
_pointer.Y = position.Y;
|
||||
return;
|
||||
}
|
||||
|
||||
float dx = position.X - _pointer.X;
|
||||
float dy = position.Y - _pointer.Y;
|
||||
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode
|
||||
&& _chase.Legacy is not null)
|
||||
{
|
||||
float sensitivity = _chase.Sensitivity;
|
||||
if (_gameplayFrame?.MouseLookActive == true)
|
||||
{
|
||||
_gameplayFrame.QueueRawMouseDelta(dx, dy);
|
||||
}
|
||||
else if (_chase.RmbOrbitHeld)
|
||||
{
|
||||
if (CameraDiagnostics.UseRetailChaseCamera
|
||||
&& _chase.Retail is not null)
|
||||
{
|
||||
var (filteredDx, filteredDy) = _chase.Retail.FilterMouseDelta(
|
||||
rawX: dx,
|
||||
rawY: dy,
|
||||
weight: 0.5f,
|
||||
nowSec: _clock.NowSeconds);
|
||||
_chase.Retail.YawOffset -= filteredDx * 0.004f * sensitivity;
|
||||
_chase.Retail.AdjustPitch(filteredDy * 0.003f * sensitivity);
|
||||
}
|
||||
else
|
||||
{
|
||||
_chase.Legacy.YawOffset -= dx * 0.004f * sensitivity;
|
||||
_chase.Legacy.AdjustPitch(dy * 0.003f * sensitivity);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_camera.IsFlyMode)
|
||||
{
|
||||
_camera.Fly.Look(dx * _flySensitivity, dy * _flySensitivity);
|
||||
}
|
||||
else if (_mouse.IsHeld(MouseButton.Left))
|
||||
{
|
||||
_camera.Orbit.Yaw -= dx * 0.005f * _orbitSensitivity;
|
||||
_camera.Orbit.Pitch = Math.Clamp(
|
||||
_camera.Orbit.Pitch + dy * 0.005f * _orbitSensitivity,
|
||||
0.1f,
|
||||
1.5f);
|
||||
}
|
||||
|
||||
_pointer.X = position.X;
|
||||
_pointer.Y = position.Y;
|
||||
}
|
||||
|
||||
private void OnCameraModeChanged(bool _) =>
|
||||
_quiescence.Invoke(ApplyCursorForCameraMode);
|
||||
|
||||
private void ApplyCursorForCameraMode()
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return;
|
||||
|
||||
if (_gameplayFrame?.MouseLookActive == true && !_camera.IsChaseMode)
|
||||
_gameplayFrame.EndMouseLook();
|
||||
|
||||
_cursor.CursorMode = _camera.IsFlyMode
|
||||
? CursorMode.Raw
|
||||
: CursorMode.Normal;
|
||||
}
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction()
|
||||
{
|
||||
if (_detach is not null)
|
||||
return _detach;
|
||||
|
||||
var operations = new List<ResourceShutdownOperation>
|
||||
{
|
||||
new("camera mode", RemoveCameraMode),
|
||||
};
|
||||
for (int i = _surfaces.Count - 1; i >= 0; i--)
|
||||
{
|
||||
int index = i;
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
$"raw mouse {index}",
|
||||
() => RemoveSurface(index)));
|
||||
}
|
||||
|
||||
_detach = new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("raw pointer callbacks", operations.ToArray()));
|
||||
return _detach;
|
||||
}
|
||||
|
||||
private void RemoveCameraMode()
|
||||
{
|
||||
if (!_cameraAttached)
|
||||
return;
|
||||
_camera.ModeChanged -= _cameraModeChanged;
|
||||
_cameraAttached = false;
|
||||
}
|
||||
|
||||
private void RemoveSurface(int index)
|
||||
{
|
||||
if (!_surfaceAttached[index])
|
||||
return;
|
||||
_surfaces[index].RemoveMouseMove(_mouseMoved);
|
||||
_surfaceAttached[index] = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,13 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
|
|||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public void Unbind(InputDispatcher dispatcher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
if (ReferenceEquals(_dispatcher, dispatcher))
|
||||
_dispatcher = null;
|
||||
}
|
||||
|
||||
public FlyCameraInput CaptureFly()
|
||||
{
|
||||
if (_dispatcher is not { } dispatcher)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
|||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public void Unbind(InputDispatcher dispatcher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
if (ReferenceEquals(_dispatcher, dispatcher))
|
||||
_dispatcher = null;
|
||||
}
|
||||
|
||||
public MovementInput Capture()
|
||||
{
|
||||
// Devtools owns the whole gameplay keyboard while active, including
|
||||
|
|
|
|||
|
|
@ -52,23 +52,3 @@ internal sealed class CompositeInputCaptureSource : IInputCaptureSource
|
|||
public bool DevToolsWantCaptureKeyboard =>
|
||||
_devTools.WantCaptureKeyboard;
|
||||
}
|
||||
|
||||
internal sealed class DelegateInputCaptureSource : IInputCaptureSource
|
||||
{
|
||||
private readonly Func<bool> _wantCaptureMouse;
|
||||
private readonly Func<bool> _wantCaptureKeyboard;
|
||||
|
||||
public DelegateInputCaptureSource(
|
||||
Func<bool> wantCaptureMouse,
|
||||
Func<bool> wantCaptureKeyboard)
|
||||
{
|
||||
_wantCaptureMouse = wantCaptureMouse
|
||||
?? throw new ArgumentNullException(nameof(wantCaptureMouse));
|
||||
_wantCaptureKeyboard = wantCaptureKeyboard
|
||||
?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
|
||||
}
|
||||
|
||||
public bool WantCaptureMouse => _wantCaptureMouse();
|
||||
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
|
||||
public bool DevToolsWantCaptureKeyboard => false;
|
||||
}
|
||||
|
|
|
|||
486
src/AcDream.App/Input/QuiescentInputContext.cs
Normal file
486
src/AcDream.App/Input/QuiescentInputContext.cs
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Non-owning input-context view for third-party frontends such as Silk's
|
||||
/// ImGuiController. It interposes named device relays so frontend callbacks can
|
||||
/// be silenced before a long live-session close and physically detached later,
|
||||
/// without disposing the canonical Silk input context.
|
||||
/// </summary>
|
||||
internal sealed class QuiescentInputContext : IInputContext
|
||||
{
|
||||
private readonly IInputContext _inner;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly QuiescentKeyboard[] _keyboards;
|
||||
private readonly QuiescentMouse[] _mice;
|
||||
private readonly Action<IInputDevice, bool> _connectionChanged;
|
||||
private Action<IInputDevice, bool>? _connectionSubscribers;
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _connectionAttached;
|
||||
private int _active;
|
||||
private bool _activationStarted;
|
||||
private int _disposeRequested;
|
||||
|
||||
public QuiescentInputContext(
|
||||
IInputContext inner,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_keyboards = inner.Keyboards
|
||||
.Select(keyboard => new QuiescentKeyboard(keyboard, quiescence))
|
||||
.ToArray();
|
||||
_mice = inner.Mice
|
||||
.Select(mouse => new QuiescentMouse(mouse, quiescence))
|
||||
.ToArray();
|
||||
_connectionChanged = OnConnectionChanged;
|
||||
}
|
||||
|
||||
public nint Handle => _inner.Handle;
|
||||
public IReadOnlyList<IGamepad> Gamepads => _inner.Gamepads;
|
||||
public IReadOnlyList<IJoystick> Joysticks => _inner.Joysticks;
|
||||
public IReadOnlyList<IKeyboard> Keyboards => _keyboards;
|
||||
public IReadOnlyList<IMouse> Mice => _mice;
|
||||
public IReadOnlyList<IInputDevice> OtherDevices => _inner.OtherDevices;
|
||||
|
||||
public event Action<IInputDevice, bool>? ConnectionChanged
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_connectionSubscribers += value;
|
||||
if (_connectionAttached) return;
|
||||
_connectionAttached = true;
|
||||
_inner.ConnectionChanged += _connectionChanged;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_connectionSubscribers -= value;
|
||||
if (_connectionSubscribers is not null || !_connectionAttached) return;
|
||||
_inner.ConnectionChanged -= _connectionChanged;
|
||||
_connectionAttached = false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete =>
|
||||
!_connectionAttached
|
||||
&& _keyboards.All(static keyboard => keyboard.IsDisposalComplete)
|
||||
&& _mice.All(static mouse => mouse.IsDisposalComplete);
|
||||
|
||||
/// <summary>
|
||||
/// Opens delivery only after the third-party frontend constructor has
|
||||
/// finished installing every callback. An event raised reentrantly from an
|
||||
/// add accessor therefore cannot enter a partially constructed frontend.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_activationStarted)
|
||||
throw new InvalidOperationException(
|
||||
"Frontend input activation has already been attempted.");
|
||||
_activationStarted = true;
|
||||
foreach (QuiescentKeyboard keyboard in _keyboards)
|
||||
keyboard.Activate();
|
||||
foreach (QuiescentMouse mouse in _mice)
|
||||
mouse.Activate();
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
Interlocked.Exchange(ref _active, 0);
|
||||
foreach (QuiescentKeyboard keyboard in _keyboards)
|
||||
keyboard.Deactivate();
|
||||
foreach (QuiescentMouse mouse in _mice)
|
||||
mouse.Deactivate();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
foreach (QuiescentKeyboard keyboard in _keyboards)
|
||||
keyboard.RequestDisposal();
|
||||
foreach (QuiescentMouse mouse in _mice)
|
||||
mouse.RequestDisposal();
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
|
||||
private void OnConnectionChanged(IInputDevice device, bool connected) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
_connectionSubscribers?.Invoke(device, connected);
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction()
|
||||
{
|
||||
if (_detach is not null)
|
||||
return _detach;
|
||||
|
||||
var operations = new List<ResourceShutdownOperation>();
|
||||
for (int i = _mice.Length - 1; i >= 0; i--)
|
||||
{
|
||||
int index = i;
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
$"frontend mouse {index}",
|
||||
_mice[index].Dispose));
|
||||
}
|
||||
for (int i = _keyboards.Length - 1; i >= 0; i--)
|
||||
{
|
||||
int index = i;
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
$"frontend keyboard {index}",
|
||||
_keyboards[index].Dispose));
|
||||
}
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
"frontend connection event",
|
||||
RemoveConnectionChanged));
|
||||
_detach = new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("frontend input callbacks", operations.ToArray()));
|
||||
return _detach;
|
||||
}
|
||||
|
||||
private void RemoveConnectionChanged()
|
||||
{
|
||||
if (!_connectionAttached) return;
|
||||
_inner.ConnectionChanged -= _connectionChanged;
|
||||
_connectionAttached = false;
|
||||
_connectionSubscribers = null;
|
||||
}
|
||||
|
||||
private sealed class QuiescentKeyboard : IKeyboard, IDisposable
|
||||
{
|
||||
private readonly IKeyboard _inner;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<IKeyboard, Key, int> _downRelay;
|
||||
private readonly Action<IKeyboard, Key, int> _upRelay;
|
||||
private readonly Action<IKeyboard, char> _charRelay;
|
||||
private Action<IKeyboard, Key, int>? _downSubscribers;
|
||||
private Action<IKeyboard, Key, int>? _upSubscribers;
|
||||
private Action<IKeyboard, char>? _charSubscribers;
|
||||
private readonly bool[] _attached = new bool[3];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public QuiescentKeyboard(IKeyboard inner, HostQuiescenceGate quiescence)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_quiescence = quiescence;
|
||||
_downRelay = OnDown;
|
||||
_upRelay = OnUp;
|
||||
_charRelay = OnChar;
|
||||
}
|
||||
|
||||
public string Name => _inner.Name;
|
||||
public int Index => _inner.Index;
|
||||
public bool IsConnected => _inner.IsConnected;
|
||||
public IReadOnlyList<Key> SupportedKeys => _inner.SupportedKeys;
|
||||
public string ClipboardText
|
||||
{
|
||||
get => _inner.ClipboardText;
|
||||
set => _inner.ClipboardText = value;
|
||||
}
|
||||
|
||||
public event Action<IKeyboard, Key, int>? KeyDown
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_downSubscribers += value;
|
||||
if (_attached[0]) return;
|
||||
_attached[0] = true;
|
||||
_inner.KeyDown += _downRelay;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_downSubscribers -= value;
|
||||
if (_downSubscribers is not null || !_attached[0]) return;
|
||||
_inner.KeyDown -= _downRelay;
|
||||
_attached[0] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<IKeyboard, Key, int>? KeyUp
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_upSubscribers += value;
|
||||
if (_attached[1]) return;
|
||||
_attached[1] = true;
|
||||
_inner.KeyUp += _upRelay;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_upSubscribers -= value;
|
||||
if (_upSubscribers is not null || !_attached[1]) return;
|
||||
_inner.KeyUp -= _upRelay;
|
||||
_attached[1] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<IKeyboard, char>? KeyChar
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_charSubscribers += value;
|
||||
if (_attached[2]) return;
|
||||
_attached[2] = true;
|
||||
_inner.KeyChar += _charRelay;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_charSubscribers -= value;
|
||||
if (_charSubscribers is not null || !_attached[2]) return;
|
||||
_inner.KeyChar -= _charRelay;
|
||||
_attached[2] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
public bool IsKeyPressed(Key key) => _inner.IsKeyPressed(key);
|
||||
public bool IsScancodePressed(int scancode) => _inner.IsScancodePressed(scancode);
|
||||
public void BeginInput() => _inner.BeginInput();
|
||||
public void EndInput() => _inner.EndInput();
|
||||
public void Activate()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void RequestDisposal() =>
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RequestDisposal();
|
||||
Deactivate();
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("frontend keyboard callbacks",
|
||||
[
|
||||
new("character", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
_detach.CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
|
||||
private void OnDown(IKeyboard _, Key key, int scanCode) =>
|
||||
Invoke(() => _downSubscribers?.Invoke(this, key, scanCode));
|
||||
private void OnUp(IKeyboard _, Key key, int scanCode) =>
|
||||
Invoke(() => _upSubscribers?.Invoke(this, key, scanCode));
|
||||
private void OnChar(IKeyboard _, char value) =>
|
||||
Invoke(() => _charSubscribers?.Invoke(this, value));
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _inner.KeyDown -= _downRelay; _downSubscribers = null; break;
|
||||
case 1: _inner.KeyUp -= _upRelay; _upSubscribers = null; break;
|
||||
case 2: _inner.KeyChar -= _charRelay; _charSubscribers = null; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QuiescentMouse : IMouse, IDisposable
|
||||
{
|
||||
private readonly IMouse _inner;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<IMouse, MouseButton> _downRelay;
|
||||
private readonly Action<IMouse, MouseButton> _upRelay;
|
||||
private readonly Action<IMouse, MouseButton, Vector2> _clickRelay;
|
||||
private readonly Action<IMouse, MouseButton, Vector2> _doubleClickRelay;
|
||||
private readonly Action<IMouse, Vector2> _moveRelay;
|
||||
private readonly Action<IMouse, ScrollWheel> _scrollRelay;
|
||||
private Action<IMouse, MouseButton>? _downSubscribers;
|
||||
private Action<IMouse, MouseButton>? _upSubscribers;
|
||||
private Action<IMouse, MouseButton, Vector2>? _clickSubscribers;
|
||||
private Action<IMouse, MouseButton, Vector2>? _doubleClickSubscribers;
|
||||
private Action<IMouse, Vector2>? _moveSubscribers;
|
||||
private Action<IMouse, ScrollWheel>? _scrollSubscribers;
|
||||
private readonly bool[] _attached = new bool[6];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public QuiescentMouse(IMouse inner, HostQuiescenceGate quiescence)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_quiescence = quiescence;
|
||||
_downRelay = OnDown;
|
||||
_upRelay = OnUp;
|
||||
_clickRelay = OnClick;
|
||||
_doubleClickRelay = OnDoubleClick;
|
||||
_moveRelay = OnMove;
|
||||
_scrollRelay = OnScroll;
|
||||
}
|
||||
|
||||
public string Name => _inner.Name;
|
||||
public int Index => _inner.Index;
|
||||
public bool IsConnected => _inner.IsConnected;
|
||||
public IReadOnlyList<MouseButton> SupportedButtons => _inner.SupportedButtons;
|
||||
public IReadOnlyList<ScrollWheel> ScrollWheels => _inner.ScrollWheels;
|
||||
public Vector2 Position { get => _inner.Position; set => _inner.Position = value; }
|
||||
public ICursor Cursor => _inner.Cursor;
|
||||
public int DoubleClickTime { get => _inner.DoubleClickTime; set => _inner.DoubleClickTime = value; }
|
||||
public int DoubleClickRange { get => _inner.DoubleClickRange; set => _inner.DoubleClickRange = value; }
|
||||
|
||||
public event Action<IMouse, MouseButton> MouseDown
|
||||
{
|
||||
add => Add(ref _downSubscribers, value, 0, () => _inner.MouseDown += _downRelay);
|
||||
remove => RemoveSubscriber(ref _downSubscribers, value, 0, () => _inner.MouseDown -= _downRelay);
|
||||
}
|
||||
public event Action<IMouse, MouseButton> MouseUp
|
||||
{
|
||||
add => Add(ref _upSubscribers, value, 1, () => _inner.MouseUp += _upRelay);
|
||||
remove => RemoveSubscriber(ref _upSubscribers, value, 1, () => _inner.MouseUp -= _upRelay);
|
||||
}
|
||||
public event Action<IMouse, MouseButton, Vector2> Click
|
||||
{
|
||||
add => Add(ref _clickSubscribers, value, 2, () => _inner.Click += _clickRelay);
|
||||
remove => RemoveSubscriber(ref _clickSubscribers, value, 2, () => _inner.Click -= _clickRelay);
|
||||
}
|
||||
public event Action<IMouse, MouseButton, Vector2> DoubleClick
|
||||
{
|
||||
add => Add(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick += _doubleClickRelay);
|
||||
remove => RemoveSubscriber(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick -= _doubleClickRelay);
|
||||
}
|
||||
public event Action<IMouse, Vector2> MouseMove
|
||||
{
|
||||
add => Add(ref _moveSubscribers, value, 4, () => _inner.MouseMove += _moveRelay);
|
||||
remove => RemoveSubscriber(ref _moveSubscribers, value, 4, () => _inner.MouseMove -= _moveRelay);
|
||||
}
|
||||
public event Action<IMouse, ScrollWheel> Scroll
|
||||
{
|
||||
add => Add(ref _scrollSubscribers, value, 5, () => _inner.Scroll += _scrollRelay);
|
||||
remove => RemoveSubscriber(ref _scrollSubscribers, value, 5, () => _inner.Scroll -= _scrollRelay);
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
public bool IsButtonPressed(MouseButton button) => _inner.IsButtonPressed(button);
|
||||
public void Activate()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void RequestDisposal() =>
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RequestDisposal();
|
||||
Deactivate();
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("frontend mouse callbacks",
|
||||
[
|
||||
new("scroll", () => RemovePhysical(5)),
|
||||
new("move", () => RemovePhysical(4)),
|
||||
new("double click", () => RemovePhysical(3)),
|
||||
new("click", () => RemovePhysical(2)),
|
||||
new("up", () => RemovePhysical(1)),
|
||||
new("down", () => RemovePhysical(0)),
|
||||
]));
|
||||
_detach.CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
|
||||
private void OnDown(IMouse _, MouseButton button) =>
|
||||
Invoke(() => _downSubscribers?.Invoke(this, button));
|
||||
private void OnUp(IMouse _, MouseButton button) =>
|
||||
Invoke(() => _upSubscribers?.Invoke(this, button));
|
||||
private void OnClick(IMouse _, MouseButton button, Vector2 position) =>
|
||||
Invoke(() => _clickSubscribers?.Invoke(this, button, position));
|
||||
private void OnDoubleClick(IMouse _, MouseButton button, Vector2 position) =>
|
||||
Invoke(() => _doubleClickSubscribers?.Invoke(this, button, position));
|
||||
private void OnMove(IMouse _, Vector2 position) =>
|
||||
Invoke(() => _moveSubscribers?.Invoke(this, position));
|
||||
private void OnScroll(IMouse _, ScrollWheel scroll) =>
|
||||
Invoke(() => _scrollSubscribers?.Invoke(this, scroll));
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private void Add<T>(ref T? subscribers, T value, int index, Action attach)
|
||||
where T : Delegate
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
subscribers = (T?)Delegate.Combine(subscribers, value);
|
||||
if (_attached[index]) return;
|
||||
_attached[index] = true;
|
||||
attach();
|
||||
}
|
||||
|
||||
private void RemoveSubscriber<T>(
|
||||
ref T? subscribers,
|
||||
T value,
|
||||
int index,
|
||||
Action detach)
|
||||
where T : Delegate
|
||||
{
|
||||
subscribers = (T?)Delegate.Remove(subscribers, value);
|
||||
if (subscribers is not null || !_attached[index]) return;
|
||||
detach();
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RemovePhysical(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _inner.MouseDown -= _downRelay; _downSubscribers = null; break;
|
||||
case 1: _inner.MouseUp -= _upRelay; _upSubscribers = null; break;
|
||||
case 2: _inner.Click -= _clickRelay; _clickSubscribers = null; break;
|
||||
case 3: _inner.DoubleClick -= _doubleClickRelay; _doubleClickSubscribers = null; break;
|
||||
case 4: _inner.MouseMove -= _moveRelay; _moveSubscribers = null; break;
|
||||
case 5: _inner.Scroll -= _scrollRelay; _scrollSubscribers = null; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +1,218 @@
|
|||
using System;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a Silk.NET <see cref="IKeyboard"/> to the test-fakeable
|
||||
/// <see cref="IKeyboardSource"/> interface used by
|
||||
/// <see cref="InputDispatcher"/>. K.1a wiring: GameWindow constructs
|
||||
/// one of these wrapping the first keyboard from
|
||||
/// <c>IInputContext.Keyboards</c>; the dispatcher fans events out to
|
||||
/// subscribers without ever touching Silk types directly.
|
||||
/// </summary>
|
||||
public sealed class SilkKeyboardSource : IKeyboardSource
|
||||
internal interface IKeyboardEventSurface
|
||||
{
|
||||
void AddKeyDown(Action<Key> callback);
|
||||
void RemoveKeyDown(Action<Key> callback);
|
||||
void AddKeyUp(Action<Key> callback);
|
||||
void RemoveKeyUp(Action<Key> callback);
|
||||
bool IsKeyPressed(Key key);
|
||||
}
|
||||
|
||||
internal sealed class SilkKeyboardEventSurface : IKeyboardEventSurface
|
||||
{
|
||||
private readonly IKeyboard _keyboard;
|
||||
private Action<Key>? _keyDownCallback;
|
||||
private Action<Key>? _keyUpCallback;
|
||||
private readonly Action<IKeyboard, Key, int> _keyDown;
|
||||
private readonly Action<IKeyboard, Key, int> _keyUp;
|
||||
|
||||
public SilkKeyboardEventSurface(IKeyboard keyboard)
|
||||
{
|
||||
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
|
||||
_keyDown = OnKeyDown;
|
||||
_keyUp = OnKeyUp;
|
||||
}
|
||||
|
||||
public void AddKeyDown(Action<Key> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
_keyDownCallback = callback;
|
||||
_keyboard.KeyDown += _keyDown;
|
||||
}
|
||||
|
||||
public void RemoveKeyDown(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_keyDownCallback, callback))
|
||||
return;
|
||||
_keyboard.KeyDown -= _keyDown;
|
||||
_keyDownCallback = null;
|
||||
}
|
||||
|
||||
public void AddKeyUp(Action<Key> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
_keyUpCallback = callback;
|
||||
_keyboard.KeyUp += _keyUp;
|
||||
}
|
||||
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_keyUpCallback, callback))
|
||||
return;
|
||||
_keyboard.KeyUp -= _keyUp;
|
||||
_keyUpCallback = null;
|
||||
}
|
||||
|
||||
public bool IsKeyPressed(Key key) => _keyboard.IsKeyPressed(key);
|
||||
|
||||
private void OnKeyDown(IKeyboard _, Key key, int __) =>
|
||||
_keyDownCallback?.Invoke(key);
|
||||
|
||||
private void OnKeyUp(IKeyboard _, Key key, int __) =>
|
||||
_keyUpCallback?.Invoke(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reversible Silk keyboard bridge. Production publishes the detached owner
|
||||
/// before Attach so a side-effecting event accessor can never orphan a
|
||||
/// subscription. Logical deactivation makes copied delegates inert while
|
||||
/// physical removal is retried.
|
||||
/// </summary>
|
||||
public sealed class SilkKeyboardSource : IKeyboardSource, IDisposable
|
||||
{
|
||||
private readonly IKeyboardEventSurface _surface;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<Key> _keyDown;
|
||||
private readonly Action<Key> _keyUp;
|
||||
private readonly bool[] _attached = new bool[2];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public event Action<Key, ModifierMask>? KeyDown;
|
||||
public event Action<Key, ModifierMask>? KeyUp;
|
||||
|
||||
public SilkKeyboardSource(IKeyboard keyboard)
|
||||
private SilkKeyboardSource(
|
||||
IKeyboardEventSurface surface,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
|
||||
_keyboard.KeyDown += (_, key, _) => KeyDown?.Invoke(key, ReadModifiers());
|
||||
_keyboard.KeyUp += (_, key, _) => KeyUp?.Invoke(key, ReadModifiers());
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_keyDown = OnKeyDown;
|
||||
_keyUp = OnKeyUp;
|
||||
}
|
||||
|
||||
public bool IsHeld(Key key) => _keyboard.IsKeyPressed(key);
|
||||
internal static SilkKeyboardSource CreateDetached(
|
||||
IKeyboard keyboard,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(
|
||||
new SilkKeyboardEventSurface(keyboard),
|
||||
quiescence);
|
||||
|
||||
internal static SilkKeyboardSource CreateDetached(
|
||||
IKeyboardEventSurface surface,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(surface, quiescence);
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Keyboard source attachment has already started.");
|
||||
_attachStarted = true;
|
||||
|
||||
try
|
||||
{
|
||||
_attached[0] = true;
|
||||
_surface.AddKeyDown(_keyDown);
|
||||
_attached[1] = true;
|
||||
_surface.AddKeyUp(_keyUp);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsHeld(Key key) => _surface.IsKeyPressed(key);
|
||||
|
||||
public ModifierMask CurrentModifiers => ReadModifiers();
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnKeyDown(Key key) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
KeyDown?.Invoke(key, ReadModifiers());
|
||||
});
|
||||
|
||||
private void OnKeyUp(Key key) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
KeyUp?.Invoke(key, ReadModifiers());
|
||||
});
|
||||
|
||||
private ModifierMask ReadModifiers()
|
||||
{
|
||||
ModifierMask m = ModifierMask.None;
|
||||
if (_keyboard.IsKeyPressed(Key.ShiftLeft) || _keyboard.IsKeyPressed(Key.ShiftRight))
|
||||
m |= ModifierMask.Shift;
|
||||
if (_keyboard.IsKeyPressed(Key.ControlLeft) || _keyboard.IsKeyPressed(Key.ControlRight))
|
||||
m |= ModifierMask.Ctrl;
|
||||
if (_keyboard.IsKeyPressed(Key.AltLeft) || _keyboard.IsKeyPressed(Key.AltRight))
|
||||
m |= ModifierMask.Alt;
|
||||
if (_keyboard.IsKeyPressed(Key.SuperLeft) || _keyboard.IsKeyPressed(Key.SuperRight))
|
||||
m |= ModifierMask.Win;
|
||||
return m;
|
||||
ModifierMask modifiers = ModifierMask.None;
|
||||
if (_surface.IsKeyPressed(Key.ShiftLeft) || _surface.IsKeyPressed(Key.ShiftRight))
|
||||
modifiers |= ModifierMask.Shift;
|
||||
if (_surface.IsKeyPressed(Key.ControlLeft) || _surface.IsKeyPressed(Key.ControlRight))
|
||||
modifiers |= ModifierMask.Ctrl;
|
||||
if (_surface.IsKeyPressed(Key.AltLeft) || _surface.IsKeyPressed(Key.AltRight))
|
||||
modifiers |= ModifierMask.Alt;
|
||||
if (_surface.IsKeyPressed(Key.SuperLeft) || _surface.IsKeyPressed(Key.SuperRight))
|
||||
modifiers |= ModifierMask.Win;
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("keyboard source callbacks",
|
||||
[
|
||||
new("key up", () => Remove(1)),
|
||||
new("key down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index])
|
||||
return;
|
||||
if (index == 1)
|
||||
_surface.RemoveKeyUp(_keyUp);
|
||||
else
|
||||
_surface.RemoveKeyDown(_keyDown);
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Keyboard source registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Keyboard source registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Keyboard source registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +1,295 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a Silk.NET <see cref="IMouse"/> + the active ImGui IO
|
||||
/// (for <see cref="WantCaptureMouse"/> / <see cref="WantCaptureKeyboard"/>)
|
||||
/// to the test-fakeable <see cref="IMouseSource"/> used by
|
||||
/// <see cref="InputDispatcher"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Production receives a typed capture source, keeping retained-UI and
|
||||
/// developer-UI ownership outside the Silk adapter. The public delegate
|
||||
/// overload remains as a compatibility seam for standalone consumers.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SilkMouseSource : IMouseSource
|
||||
internal interface IMouseEventSurface
|
||||
{
|
||||
void AddMouseDown(Action<MouseButton> callback);
|
||||
void RemoveMouseDown(Action<MouseButton> callback);
|
||||
void AddMouseUp(Action<MouseButton> callback);
|
||||
void RemoveMouseUp(Action<MouseButton> callback);
|
||||
void AddMouseMove(Action<Vector2> callback);
|
||||
void RemoveMouseMove(Action<Vector2> callback);
|
||||
void AddScroll(Action<float> callback);
|
||||
void RemoveScroll(Action<float> callback);
|
||||
bool IsButtonPressed(MouseButton button);
|
||||
}
|
||||
|
||||
internal sealed class SilkMouseEventSurface : IMouseEventSurface
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private Action<MouseButton>? _downCallback;
|
||||
private Action<MouseButton>? _upCallback;
|
||||
private Action<Vector2>? _moveCallback;
|
||||
private Action<float>? _scrollCallback;
|
||||
private readonly Action<IMouse, MouseButton> _down;
|
||||
private readonly Action<IMouse, MouseButton> _up;
|
||||
private readonly Action<IMouse, Vector2> _move;
|
||||
private readonly Action<IMouse, ScrollWheel> _scroll;
|
||||
|
||||
public SilkMouseEventSurface(IMouse mouse)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_down = OnDown;
|
||||
_up = OnUp;
|
||||
_move = OnMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
public void AddMouseDown(Action<MouseButton> callback)
|
||||
{
|
||||
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseDown += _down;
|
||||
}
|
||||
|
||||
public void RemoveMouseDown(Action<MouseButton> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_downCallback, callback)) return;
|
||||
_mouse.MouseDown -= _down;
|
||||
_downCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseUp(Action<MouseButton> callback)
|
||||
{
|
||||
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseUp += _up;
|
||||
}
|
||||
|
||||
public void RemoveMouseUp(Action<MouseButton> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_upCallback, callback)) return;
|
||||
_mouse.MouseUp -= _up;
|
||||
_upCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
_moveCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseMove += _move;
|
||||
}
|
||||
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_moveCallback, callback)) return;
|
||||
_mouse.MouseMove -= _move;
|
||||
_moveCallback = null;
|
||||
}
|
||||
|
||||
public void AddScroll(Action<float> callback)
|
||||
{
|
||||
_scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.Scroll += _scroll;
|
||||
}
|
||||
|
||||
public void RemoveScroll(Action<float> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_scrollCallback, callback)) return;
|
||||
_mouse.Scroll -= _scroll;
|
||||
_scrollCallback = null;
|
||||
}
|
||||
|
||||
public bool IsButtonPressed(MouseButton button) => _mouse.IsButtonPressed(button);
|
||||
|
||||
private void OnDown(IMouse _, MouseButton button) => _downCallback?.Invoke(button);
|
||||
private void OnUp(IMouse _, MouseButton button) => _upCallback?.Invoke(button);
|
||||
private void OnMove(IMouse _, Vector2 position) => _moveCallback?.Invoke(position);
|
||||
private void OnScroll(IMouse _, ScrollWheel scroll) => _scrollCallback?.Invoke(scroll.Y);
|
||||
}
|
||||
|
||||
/// <summary>Reversible Silk mouse bridge with immediate logical cutoff.</summary>
|
||||
public sealed class SilkMouseSource : IMouseSource, IDisposable
|
||||
{
|
||||
private readonly IMouseEventSurface _surface;
|
||||
private readonly IInputCaptureSource _capture;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<MouseButton> _mouseDown;
|
||||
private readonly Action<MouseButton> _mouseUp;
|
||||
private readonly Action<Vector2> _mouseMove;
|
||||
private readonly Action<float> _scroll;
|
||||
private readonly bool[] _attached = new bool[4];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
private float _lastX;
|
||||
private float _lastY;
|
||||
private bool _haveLastPos;
|
||||
private bool _haveLastPosition;
|
||||
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
|
||||
/// <summary>The keyboard source that owns the current modifier mask, so
|
||||
/// mouse events and keyboard events resolve identical chords.</summary>
|
||||
public IKeyboardSource? ModifierSource { get; set; }
|
||||
|
||||
internal SilkMouseSource(
|
||||
private SilkMouseSource(
|
||||
IMouseEventSurface surface,
|
||||
IInputCaptureSource capture,
|
||||
IKeyboardSource? modifierSource,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
|
||||
ModifierSource = modifierSource;
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_mouseDown = OnMouseDown;
|
||||
_mouseUp = OnMouseUp;
|
||||
_mouseMove = OnMouseMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
internal static SilkMouseSource CreateDetached(
|
||||
IMouse mouse,
|
||||
IInputCaptureSource capture,
|
||||
IKeyboardSource modifierSource)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
|
||||
ModifierSource = modifierSource
|
||||
?? throw new ArgumentNullException(nameof(modifierSource));
|
||||
Subscribe();
|
||||
}
|
||||
IKeyboardSource? modifierSource,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(
|
||||
new SilkMouseEventSurface(mouse),
|
||||
capture,
|
||||
modifierSource,
|
||||
quiescence);
|
||||
|
||||
public SilkMouseSource(
|
||||
IMouse mouse,
|
||||
Func<bool> wantCaptureMouse,
|
||||
Func<bool> wantCaptureKeyboard)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_capture = new DelegateInputCaptureSource(
|
||||
wantCaptureMouse,
|
||||
wantCaptureKeyboard);
|
||||
Subscribe();
|
||||
}
|
||||
internal static SilkMouseSource CreateDetached(
|
||||
IMouseEventSurface surface,
|
||||
IInputCaptureSource capture,
|
||||
IKeyboardSource? modifierSource,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(surface, capture, modifierSource, quiescence);
|
||||
|
||||
private void Subscribe()
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers());
|
||||
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers());
|
||||
_mouse.MouseMove += (_, pos) =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Mouse source attachment has already started.");
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
float dx, dy;
|
||||
if (_haveLastPos)
|
||||
{
|
||||
dx = pos.X - _lastX;
|
||||
dy = pos.Y - _lastY;
|
||||
}
|
||||
else
|
||||
{
|
||||
dx = 0f;
|
||||
dy = 0f;
|
||||
_haveLastPos = true;
|
||||
}
|
||||
_lastX = pos.X;
|
||||
_lastY = pos.Y;
|
||||
MouseMove?.Invoke(dx, dy);
|
||||
};
|
||||
_mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y);
|
||||
_attached[0] = true;
|
||||
_surface.AddMouseDown(_mouseDown);
|
||||
_attached[1] = true;
|
||||
_surface.AddMouseUp(_mouseUp);
|
||||
_attached[2] = true;
|
||||
_surface.AddMouseMove(_mouseMove);
|
||||
_attached[3] = true;
|
||||
_surface.AddScroll(_scroll);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsHeld(MouseButton button) => _surface.IsButtonPressed(button);
|
||||
public bool WantCaptureMouse => _capture.WantCaptureMouse;
|
||||
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard;
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private ModifierMask ReadModifiers() =>
|
||||
ModifierSource?.CurrentModifiers ?? ModifierMask.None;
|
||||
|
||||
public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button);
|
||||
private void OnMouseDown(MouseButton button) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
MouseDown?.Invoke(button, ReadModifiers());
|
||||
});
|
||||
|
||||
public bool WantCaptureMouse => _capture.WantCaptureMouse;
|
||||
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard;
|
||||
private void OnMouseUp(MouseButton button) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
MouseUp?.Invoke(button, ReadModifiers());
|
||||
});
|
||||
|
||||
private void OnMouseMove(Vector2 position) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return;
|
||||
|
||||
float dx = 0f;
|
||||
float dy = 0f;
|
||||
if (_haveLastPosition)
|
||||
{
|
||||
dx = position.X - _lastX;
|
||||
dy = position.Y - _lastY;
|
||||
}
|
||||
else
|
||||
{
|
||||
_haveLastPosition = true;
|
||||
}
|
||||
|
||||
_lastX = position.X;
|
||||
_lastY = position.Y;
|
||||
MouseMove?.Invoke(dx, dy);
|
||||
});
|
||||
|
||||
private void OnScroll(float delta) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
Scroll?.Invoke(delta);
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("mouse source callbacks",
|
||||
[
|
||||
new("scroll", () => Remove(3)),
|
||||
new("move", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index])
|
||||
return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _surface.RemoveMouseDown(_mouseDown); break;
|
||||
case 1: _surface.RemoveMouseUp(_mouseUp); break;
|
||||
case 2: _surface.RemoveMouseMove(_mouseMove); break;
|
||||
case 3: _surface.RemoveScroll(_scroll); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Mouse source registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Mouse source registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Mouse source registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
118
src/AcDream.App/Rendering/FramebufferResizeController.cs
Normal file
118
src/AcDream.App/Rendering/FramebufferResizeController.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
using AcDream.App.Input;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
internal interface IFramebufferViewportTarget
|
||||
{
|
||||
void ResizeViewport(int width, int height);
|
||||
}
|
||||
|
||||
internal sealed class SilkFramebufferViewportTarget(GL gl)
|
||||
: IFramebufferViewportTarget
|
||||
{
|
||||
private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
|
||||
public void ResizeViewport(int width, int height) =>
|
||||
_gl.Viewport(0, 0, (uint)width, (uint)height);
|
||||
}
|
||||
|
||||
internal interface IFramebufferCameraTarget
|
||||
{
|
||||
void SetAspect(float aspect);
|
||||
}
|
||||
|
||||
internal sealed class CameraFramebufferTarget(CameraController camera)
|
||||
: IFramebufferCameraTarget
|
||||
{
|
||||
private readonly CameraController _camera = camera
|
||||
?? throw new ArgumentNullException(nameof(camera));
|
||||
|
||||
public void SetAspect(float aspect) => _camera.SetAspect(aspect);
|
||||
}
|
||||
|
||||
internal interface IFramebufferDevToolsTarget
|
||||
{
|
||||
void ResetLayout(int width, int height);
|
||||
}
|
||||
|
||||
internal sealed class DevToolsFramebufferTarget(DevToolsFramePresenter presenter)
|
||||
: IFramebufferDevToolsTarget
|
||||
{
|
||||
private readonly DevToolsFramePresenter _presenter = presenter
|
||||
?? throw new ArgumentNullException(nameof(presenter));
|
||||
|
||||
public void ResetLayout(int width, int height) =>
|
||||
_presenter.ResetLayout(
|
||||
width,
|
||||
height,
|
||||
DevToolsPanelLayoutCondition.Always);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one framebuffer-size target. Logical gameplay/UI dimensions continue
|
||||
/// to come from Window.Size; this owner only applies physical framebuffer
|
||||
/// viewport/aspect changes in the frozen acdream host order.
|
||||
/// </summary>
|
||||
internal sealed class FramebufferResizeController
|
||||
{
|
||||
private readonly ViewportAspectState _viewportAspect;
|
||||
private IFramebufferViewportTarget? _viewport;
|
||||
private IFramebufferCameraTarget? _camera;
|
||||
private IFramebufferDevToolsTarget? _devTools;
|
||||
|
||||
public FramebufferResizeController(ViewportAspectState viewportAspect) =>
|
||||
_viewportAspect = viewportAspect
|
||||
?? throw new ArgumentNullException(nameof(viewportAspect));
|
||||
|
||||
public void BindViewport(IFramebufferViewportTarget viewport)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(viewport);
|
||||
BindOnce(ref _viewport, viewport, "viewport");
|
||||
}
|
||||
|
||||
public void BindCamera(IFramebufferCameraTarget camera)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(camera);
|
||||
BindOnce(ref _camera, camera, "camera");
|
||||
}
|
||||
|
||||
public void BindDevTools(IFramebufferDevToolsTarget devTools)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(devTools);
|
||||
BindOnce(ref _devTools, devTools, "developer tools");
|
||||
}
|
||||
|
||||
public void UnbindDevTools(IFramebufferDevToolsTarget devTools)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(devTools);
|
||||
if (ReferenceEquals(_devTools, devTools))
|
||||
_devTools = null;
|
||||
}
|
||||
|
||||
public void Resize(Vector2D<int> newSize) => Resize(newSize.X, newSize.Y);
|
||||
|
||||
public void Resize(int width, int height)
|
||||
{
|
||||
if (width <= 0 || height <= 0)
|
||||
return;
|
||||
|
||||
// Frozen order: GL viewport, shared aspect publication, camera, then
|
||||
// optional ImGui layout reset. Late binding never replays an earlier
|
||||
// resize transition.
|
||||
_viewport?.ResizeViewport(width, height);
|
||||
_viewportAspect.Update(width, height);
|
||||
_camera?.SetAspect(width / (float)height);
|
||||
_devTools?.ResetLayout(width, height);
|
||||
}
|
||||
|
||||
private static void BindOnce<T>(ref T? slot, T value, string name)
|
||||
where T : class
|
||||
{
|
||||
if (slot is not null && !ReferenceEquals(slot, value))
|
||||
throw new InvalidOperationException(
|
||||
$"The framebuffer {name} target is already bound.");
|
||||
slot = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ public sealed class GameWindow : IDisposable
|
|||
private SilkWindowCallbackBinding? _windowCallbacks;
|
||||
private GL? _gl;
|
||||
private IInputContext? _input;
|
||||
private AcDream.App.Input.QuiescentInputContext? _devToolsInputContext;
|
||||
private TerrainModernRenderer? _terrain;
|
||||
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
|
||||
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
|
||||
|
|
@ -34,16 +35,7 @@ public sealed class GameWindow : IDisposable
|
|||
private CameraController? _cameraController;
|
||||
private IDatReaderWriter? _dats;
|
||||
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
|
||||
private float _lastMouseX
|
||||
{
|
||||
get => _pointerPosition.X;
|
||||
set => _pointerPosition.X = value;
|
||||
}
|
||||
private float _lastMouseY
|
||||
{
|
||||
get => _pointerPosition.Y;
|
||||
set => _pointerPosition.Y = value;
|
||||
}
|
||||
private AcDream.App.Input.CameraPointerInputController? _cameraPointerInput;
|
||||
private Shader? _meshShader;
|
||||
private TextureCache? _textureCache;
|
||||
/// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
|
||||
|
|
@ -434,6 +426,8 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
|
||||
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
|
||||
private readonly FramebufferResizeController _framebufferResize;
|
||||
private IFramebufferDevToolsTarget? _framebufferDevToolsTarget;
|
||||
private AcDream.App.Input.PlayerModeController? _playerModeController;
|
||||
private readonly AcDream.App.Interaction.PlayerApproachCompletionState
|
||||
_playerApproachCompletions = new();
|
||||
|
|
@ -444,26 +438,6 @@ public sealed class GameWindow : IDisposable
|
|||
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
|
||||
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
|
||||
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
|
||||
// Mouse sensitivity multipliers — one per camera mode because the visual
|
||||
// feel is very different. Adjust via F8 / F9 for whichever mode is
|
||||
// currently active. Chase default is low because the character + camera
|
||||
// rotating together is overwhelming at fly speeds.
|
||||
private float _sensChase
|
||||
{
|
||||
get => _chaseCameraInput.Sensitivity;
|
||||
set => _chaseCameraInput.Sensitivity = value;
|
||||
}
|
||||
private float _sensFly = 1.0f;
|
||||
private float _sensOrbit = 1.0f;
|
||||
|
||||
// Right-mouse-button held → free-orbit the chase camera around the
|
||||
// player without turning the character. Release leaves the camera at
|
||||
// the orbited position (no snap back).
|
||||
private bool _rmbHeld
|
||||
{
|
||||
get => _chaseCameraInput.RmbOrbitHeld;
|
||||
}
|
||||
|
||||
// Phase K.2 — auto-enter player mode after a successful login. Armed
|
||||
// by LiveSessionHost's entered-world transition; ticked from
|
||||
// OnUpdate; disarmed if the user manually enters fly mode (or any
|
||||
|
|
@ -579,6 +553,7 @@ public sealed class GameWindow : IDisposable
|
|||
_retainedInputCapture);
|
||||
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
|
||||
_inputCapture);
|
||||
_framebufferResize = new FramebufferResizeController(_viewportAspect);
|
||||
_datDir = options.DatDir;
|
||||
_worldGameState = worldGameState;
|
||||
_worldEvents = worldEvents;
|
||||
|
|
@ -676,6 +651,8 @@ public sealed class GameWindow : IDisposable
|
|||
_physicsEngine.DataCache = _physicsDataCache;
|
||||
|
||||
_gl = GL.GetApi(_window!);
|
||||
_framebufferResize.BindViewport(
|
||||
new SilkFramebufferViewportTarget(_gl));
|
||||
_gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl);
|
||||
var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics(
|
||||
new AcDream.App.Rendering.SilkRenderGlStateReader(_gl),
|
||||
|
|
@ -692,113 +669,36 @@ public sealed class GameWindow : IDisposable
|
|||
// per K.1b plan §D).
|
||||
var firstKb = _input.Keyboards.FirstOrDefault();
|
||||
var firstMouse = _input.Mice.FirstOrDefault();
|
||||
if (firstKb is not null && firstMouse is not null)
|
||||
if (firstKb is not null)
|
||||
{
|
||||
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
|
||||
_mouseSource = new AcDream.App.Input.SilkMouseSource(
|
||||
_kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(
|
||||
firstKb,
|
||||
_hostQuiescence);
|
||||
_kbSource.Attach();
|
||||
}
|
||||
if (firstMouse is not null)
|
||||
{
|
||||
_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(
|
||||
firstMouse,
|
||||
_inputCapture,
|
||||
_kbSource);
|
||||
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
||||
_kbSource,
|
||||
_hostQuiescence);
|
||||
_mouseSource.Attach();
|
||||
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
|
||||
}
|
||||
if (_kbSource is not null && _mouseSource is not null)
|
||||
{
|
||||
_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(
|
||||
_kbSource, _mouseSource, _keyBindings);
|
||||
_inputDispatcher.Attach();
|
||||
_movementInput.Bind(_inputDispatcher);
|
||||
_cameraInput.Bind(_inputDispatcher);
|
||||
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
|
||||
_inputDispatcher.Fired += OnInputAction;
|
||||
Combat.CombatModeChanged += SetInputCombatScope;
|
||||
SetInputCombatScope(Combat.CurrentMode);
|
||||
|
||||
}
|
||||
|
||||
// Mouse delta handler — kept direct because Silk.NET delivers mouse
|
||||
// moves as continuous (x, y) positions, not chord events. We gate
|
||||
// on WantCaptureMouse so panel hover never drives camera. The
|
||||
// previous "_playerMouseDeltaX += dx * sens" branch is GONE — mouse
|
||||
// never drives character yaw in K.1b. RMB-held orbit is wired via
|
||||
// the dispatcher's AcdreamRmbOrbitHold action (subscriber sets
|
||||
// _rmbHeld below).
|
||||
foreach (var mouse in _input.Mice)
|
||||
{
|
||||
mouse.MouseMove += (m, pos) =>
|
||||
{
|
||||
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
|
||||
// surviving direct-mouse handler. Suppresses RMB orbit /
|
||||
// FlyCamera look while ImGui has the mouse focus.
|
||||
if (_inputCapture.WantCaptureMouse)
|
||||
{
|
||||
_lastMouseX = pos.X;
|
||||
_lastMouseY = pos.Y;
|
||||
return;
|
||||
}
|
||||
if (_cameraController is null) return;
|
||||
|
||||
float dx = pos.X - _lastMouseX;
|
||||
float dy = pos.Y - _lastMouseY;
|
||||
|
||||
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
|
||||
{
|
||||
float sens = _sensChase;
|
||||
if (_gameplayInputFrame?.MouseLookActive == true)
|
||||
{
|
||||
// DirectInput may deliver several device records before
|
||||
// one retail GetInput pass. Accumulate raw motion here;
|
||||
// the update loop filters the aggregate exactly once.
|
||||
_gameplayInputFrame.QueueRawMouseDelta(dx, dy);
|
||||
}
|
||||
else if (_rmbHeld)
|
||||
{
|
||||
// Hold-RMB orbit: player stays the central point, camera
|
||||
// free-orbits around. X rotates around, Y pitches. On release
|
||||
// the camera STAYS at the new angle (no snap back).
|
||||
// K.1b: this is the ONLY mouse-delta path that affects
|
||||
// ANYTHING when in player mode — character yaw is
|
||||
// dispatcher-only (A/D keys). K.2: MMB mouse-look path
|
||||
// above takes precedence when active.
|
||||
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
|
||||
{
|
||||
float nowSec = (float)(Environment.TickCount64 / 1000.0);
|
||||
var (filteredDx, filteredDy) = _retailChaseCamera.FilterMouseDelta(
|
||||
rawX: dx, rawY: dy, weight: 0.5f, nowSec: nowSec);
|
||||
_retailChaseCamera.YawOffset -= filteredDx * 0.004f * sens;
|
||||
_retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
|
||||
}
|
||||
else
|
||||
{
|
||||
_chaseCamera.YawOffset -= dx * 0.004f * sens;
|
||||
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
|
||||
}
|
||||
}
|
||||
// K-fix1 (2026-04-26): no default-pitch path. With
|
||||
// neither MMB nor RMB held, mouse moves the cursor
|
||||
// freely so the user can interact with panels +
|
||||
// (eventually) click selectables in the world. Pitch
|
||||
// is gated on a deliberate hold input.
|
||||
}
|
||||
else if (_cameraController.IsFlyMode)
|
||||
{
|
||||
float sens = _sensFly;
|
||||
_cameraController.Fly.Look(dx * sens, dy * sens);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Orbit-camera mode (offline / pre-login): hold LMB to
|
||||
// drag-rotate. K.1b reads the mouse-button state via
|
||||
// IMouseSource so all "is button held" queries go
|
||||
// through the same abstraction the dispatcher uses.
|
||||
float sens = _sensOrbit;
|
||||
if (_mouseSource is not null && _mouseSource.IsHeld(MouseButton.Left))
|
||||
{
|
||||
_cameraController.Orbit.Yaw -= dx * 0.005f * sens;
|
||||
_cameraController.Orbit.Pitch = Math.Clamp(
|
||||
_cameraController.Orbit.Pitch + dy * 0.005f * sens,
|
||||
0.1f, 1.5f);
|
||||
}
|
||||
}
|
||||
_lastMouseX = pos.X;
|
||||
_lastMouseY = pos.Y;
|
||||
};
|
||||
}
|
||||
|
||||
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
|
||||
|
|
@ -849,10 +749,26 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine("world-hud font: no system monospace font found");
|
||||
}
|
||||
|
||||
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
|
||||
var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y };
|
||||
var orbit = new OrbitCamera();
|
||||
var fly = new FlyCamera();
|
||||
_cameraController = new CameraController(orbit, fly);
|
||||
_cameraController.ModeChanged += OnCameraModeChanged;
|
||||
_framebufferResize.BindCamera(
|
||||
new CameraFramebufferTarget(_cameraController));
|
||||
_framebufferResize.Resize(_window!.FramebufferSize);
|
||||
if (_mouseSource is not null && firstMouse is not null)
|
||||
{
|
||||
_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(
|
||||
_input.Mice,
|
||||
_hostQuiescence,
|
||||
_inputCapture,
|
||||
_localPlayerMode,
|
||||
_cameraController,
|
||||
_chaseCameraInput,
|
||||
_mouseSource,
|
||||
_pointerPosition,
|
||||
new AcDream.App.Input.EnvironmentInputMonotonicClock());
|
||||
_cameraPointerInput.AttachRaw();
|
||||
}
|
||||
|
||||
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);
|
||||
_magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats);
|
||||
|
|
@ -959,8 +875,15 @@ public sealed class GameWindow : IDisposable
|
|||
AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null;
|
||||
try
|
||||
{
|
||||
_devToolsInputContext = new AcDream.App.Input.QuiescentInputContext(
|
||||
_input!,
|
||||
_hostQuiescence);
|
||||
imguiBootstrap =
|
||||
new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
|
||||
new AcDream.UI.ImGui.ImGuiBootstrapper(
|
||||
_gl!,
|
||||
_window!,
|
||||
_devToolsInputContext);
|
||||
_devToolsInputContext.Activate();
|
||||
var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
|
||||
|
||||
// VitalsVM: GUID=0 at construction; set later at EnterWorld
|
||||
|
|
@ -1021,9 +944,11 @@ public sealed class GameWindow : IDisposable
|
|||
_worldSceneDebugState.CollisionWireframesVisible,
|
||||
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
|
||||
|
||||
getMouseSensitivity: () => GetActiveSensitivity(),
|
||||
getMouseSensitivity: () =>
|
||||
_cameraPointerInput?.ActiveSensitivity ?? 1f,
|
||||
getChaseDistance: () => _chaseCamera?.Distance ?? 0f,
|
||||
getRmbOrbit: () => _rmbHeld,
|
||||
getRmbOrbit: () =>
|
||||
_cameraPointerInput?.RmbOrbitHeld ?? false,
|
||||
getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(),
|
||||
getDayFraction: () => (float)WorldTime.DayFraction,
|
||||
getWeather: () => Weather.Kind.ToString(),
|
||||
|
|
@ -1229,6 +1154,9 @@ public sealed class GameWindow : IDisposable
|
|||
debugPanel,
|
||||
_debugVm,
|
||||
settingsPanel));
|
||||
_framebufferDevToolsTarget = new DevToolsFramebufferTarget(
|
||||
_devToolsFramePresenter);
|
||||
_framebufferResize.BindDevTools(_framebufferDevToolsTarget);
|
||||
_devToolsFramePresenter.ResetLayout(
|
||||
_window!.Size.X,
|
||||
_window.Size.Y,
|
||||
|
|
@ -1241,6 +1169,13 @@ public sealed class GameWindow : IDisposable
|
|||
// operation. Construction failure still owns this local and
|
||||
// must release it before abandoning the optional frontend.
|
||||
imguiBootstrap?.Dispose();
|
||||
if (_framebufferDevToolsTarget is { } framebufferTarget)
|
||||
{
|
||||
_framebufferResize.UnbindDevTools(framebufferTarget);
|
||||
_framebufferDevToolsTarget = null;
|
||||
}
|
||||
_devToolsInputContext?.Dispose();
|
||||
_devToolsInputContext = null;
|
||||
_devToolsBackend = null;
|
||||
_devToolsFramePresenter = null;
|
||||
_devToolsCameraMenu = null;
|
||||
|
|
@ -1429,7 +1364,11 @@ public sealed class GameWindow : IDisposable
|
|||
if (_options.RetailUi)
|
||||
{
|
||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||
_uiHost = new AcDream.App.UI.UiHost(
|
||||
_gl,
|
||||
shadersDir,
|
||||
_debugFont,
|
||||
_hostQuiescence);
|
||||
_retainedInputCapture.Root = _uiHost.Root;
|
||||
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||
|
|
@ -2031,7 +1970,9 @@ public sealed class GameWindow : IDisposable
|
|||
camera.Projection,
|
||||
camera.Viewport);
|
||||
},
|
||||
() => new System.Numerics.Vector2(_lastMouseX, _lastMouseY),
|
||||
() => new System.Numerics.Vector2(
|
||||
_pointerPosition.X,
|
||||
_pointerPosition.Y),
|
||||
() => _playerController is { } player
|
||||
? new AcDream.App.Interaction.PlayerInteractionPose(
|
||||
player.CellId,
|
||||
|
|
@ -2472,6 +2413,7 @@ public sealed class GameWindow : IDisposable
|
|||
mouseLookController,
|
||||
new AcDream.App.Input.CombatAttackInputFrameAdapter(
|
||||
_combatAttackController!));
|
||||
_cameraPointerInput?.BindGameplayFrame(_gameplayInputFrame);
|
||||
var streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
|
||||
_options.LiveMode,
|
||||
_localPlayerMode,
|
||||
|
|
@ -2559,7 +2501,6 @@ public sealed class GameWindow : IDisposable
|
|||
_animatedEntities,
|
||||
_equippedChildRenderer!,
|
||||
liveEffectFrame);
|
||||
_viewportAspect.Update(_window!.Size.X, _window.Size.Y);
|
||||
_playerModeController = new AcDream.App.Input.PlayerModeController(
|
||||
_localPlayerMode,
|
||||
_playerControllerSlot,
|
||||
|
|
@ -3169,31 +3110,6 @@ public sealed class GameWindow : IDisposable
|
|||
new AcDream.App.Update.UpdateFrameInput(dt));
|
||||
}
|
||||
|
||||
private void OnCameraModeChanged(bool _modeBool)
|
||||
{
|
||||
if (_gameplayInputFrame?.MouseLookActive == true
|
||||
&& _cameraController?.IsChaseMode != true)
|
||||
{
|
||||
_gameplayInputFrame?.EndMouseLook();
|
||||
}
|
||||
|
||||
if (_input is null) return;
|
||||
var mouse = _input.Mice.FirstOrDefault();
|
||||
if (mouse is null) return;
|
||||
|
||||
// K-fix2 (2026-04-26): the bool passed to ModeChanged is NOT
|
||||
// reliably "isFlyMode" — CameraController.EnterChaseMode invokes
|
||||
// it with IsChaseMode (true), CameraController.ToggleFly invokes
|
||||
// it with IsFlyMode, and CameraController.ExitChaseMode invokes
|
||||
// it with IsFlyMode. Reading the controller state directly is
|
||||
// the only correct gate. Cursor visible by default in chase /
|
||||
// orbit modes; Raw cursor only in fly mode (continuous
|
||||
// look-and-fly affordance). Mouse-look (raw mode) when MMB is
|
||||
// held is handled separately by the gameplay input owner.
|
||||
bool needsRawCursor = _cameraController?.IsFlyMode == true;
|
||||
mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal;
|
||||
}
|
||||
|
||||
// Performance overlay state — updated every ~0.5s and written to the
|
||||
// window title so there's zero rendering cost (no font/overlay needed).
|
||||
private void OnRender(double deltaSeconds)
|
||||
|
|
@ -3325,13 +3241,6 @@ public sealed class GameWindow : IDisposable
|
|||
private bool GetDebugPlayerOnGround() =>
|
||||
_playerMode && _playerController is not null && !_playerController.IsAirborne;
|
||||
|
||||
private float GetActiveSensitivity()
|
||||
{
|
||||
if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase;
|
||||
if (_cameraController?.IsFlyMode == true) return _sensFly;
|
||||
return _sensOrbit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cycle the time-of-day debug override. Same body as the old F7
|
||||
/// keybind handler; called by both the keybind AND the DebugPanel
|
||||
|
|
@ -3611,21 +3520,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// previously off the new viewport snap back to default positions.
|
||||
/// </summary>
|
||||
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
|
||||
{
|
||||
if (newSize.X <= 0 || newSize.Y <= 0) return;
|
||||
_gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y);
|
||||
_viewportAspect.Update(newSize.X, newSize.Y);
|
||||
_cameraController?.SetAspect(newSize.X / (float)newSize.Y);
|
||||
// Resize is always a force-reset — the alternative ("clamp
|
||||
// existing positions") would require tracking each panel's
|
||||
// current pos+size, which ImGuiNET doesn't expose by name.
|
||||
// Force-reset is acceptable UX because resizing happens rarely
|
||||
// and the user can always drag panels back where they want.
|
||||
_devToolsFramePresenter?.ResetLayout(
|
||||
newSize.X,
|
||||
newSize.Y,
|
||||
AcDream.App.Rendering.DevToolsPanelLayoutCondition.Always);
|
||||
}
|
||||
=> _framebufferResize.Resize(newSize);
|
||||
|
||||
/// <summary>
|
||||
/// L.0 Display tab: apply the window-state-dependent settings
|
||||
|
|
@ -3701,11 +3596,8 @@ public sealed class GameWindow : IDisposable
|
|||
// Diagnostic — kept from K.1a; helpful for K.1c verification.
|
||||
Console.WriteLine($"[input] {action} {activation}");
|
||||
|
||||
// RMB-orbit hold: track press/release transitions explicitly so
|
||||
// _rmbHeld is true exactly while the chord is held. Hold-type
|
||||
// chords also fire Press on key-down + Release on key-up; we
|
||||
// ignore the in-between Hold ticks here (the mouse-move handler
|
||||
// checks _rmbHeld each frame anyway).
|
||||
// RMB orbit and instant mouse-look remain the first accepted input
|
||||
// transition. Their state is owned by the gameplay/pointer owners.
|
||||
if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true)
|
||||
return;
|
||||
|
||||
|
|
@ -3958,20 +3850,10 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void AdjustActiveSensitivity(float factor)
|
||||
{
|
||||
string modeLabel;
|
||||
float current;
|
||||
if (_playerMode && _cameraController?.IsChaseMode == true)
|
||||
{ modeLabel = "Chase"; current = _sensChase; }
|
||||
else if (_cameraController?.IsFlyMode == true)
|
||||
{ modeLabel = "Fly"; current = _sensFly; }
|
||||
else
|
||||
{ modeLabel = "Orbit"; current = _sensOrbit; }
|
||||
|
||||
float next = MathF.Min(3.0f, MathF.Max(0.005f, current * factor));
|
||||
if (modeLabel == "Chase") _sensChase = next;
|
||||
else if (modeLabel == "Fly") _sensFly = next;
|
||||
else _sensOrbit = next;
|
||||
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
|
||||
if (_cameraPointerInput is not { } pointer)
|
||||
return;
|
||||
string message = pointer.AdjustSensitivity(factor);
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -4040,28 +3922,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// retail wheel feel).
|
||||
/// </summary>
|
||||
private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action)
|
||||
{
|
||||
if (_cameraController is null) return;
|
||||
float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f;
|
||||
|
||||
if (_playerMode && _cameraController.IsChaseMode)
|
||||
{
|
||||
// Chase mode: zoom (closer on ScrollUp).
|
||||
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
|
||||
_retailChaseCamera.AdjustDistance(-dir * 0.8f);
|
||||
else if (_chaseCamera is not null)
|
||||
_chaseCamera.AdjustDistance(-dir * 0.8f);
|
||||
}
|
||||
else if (_cameraController.IsFlyMode)
|
||||
{
|
||||
// Fly mode: no-op (could adjust move speed later).
|
||||
}
|
||||
else
|
||||
{
|
||||
_cameraController.Orbit.Distance = Math.Clamp(
|
||||
_cameraController.Orbit.Distance - dir * 20f, 50f, 2000f);
|
||||
}
|
||||
}
|
||||
=> _cameraPointerInput?.HandleScroll(action);
|
||||
|
||||
private void OnClosing()
|
||||
{
|
||||
|
|
@ -4088,6 +3949,20 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
private ResourceShutdownTransaction CreateShutdownTransaction() => new(
|
||||
// Logical cutoff precedes the live session's potentially long graceful
|
||||
// close. Physical event removal follows session retirement, so a bad
|
||||
// Silk accessor cannot prevent F653/transport teardown. The input
|
||||
// context and UI owners remain alive for reset, while copied callbacks
|
||||
// are already inert.
|
||||
new ResourceShutdownStage("input callback deactivation",
|
||||
[
|
||||
new("camera pointer", () => _cameraPointerInput?.Deactivate()),
|
||||
new("dispatcher", () => _inputDispatcher?.Deactivate()),
|
||||
new("mouse source", () => _mouseSource?.Deactivate()),
|
||||
new("keyboard source", () => _kbSource?.Deactivate()),
|
||||
new("retained UI input", () => _uiHost?.QuiesceInput()),
|
||||
new("developer tools input", () => _devToolsInputContext?.Deactivate()),
|
||||
]),
|
||||
// Live-session reset retires the complete streaming window. Every
|
||||
// reset callback owner, especially LandblockStreamer, must remain live
|
||||
// until that transaction converges.
|
||||
|
|
@ -4114,6 +3989,57 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}),
|
||||
]),
|
||||
new ResourceShutdownStage("input callback detach",
|
||||
[
|
||||
new("retained UI input", () => _uiHost?.DeactivateInput()),
|
||||
new("developer tools input", () => _devToolsInputContext?.Dispose()),
|
||||
new("camera pointer", () =>
|
||||
{
|
||||
AcDream.App.Input.CameraPointerInputController? pointer =
|
||||
_cameraPointerInput;
|
||||
if (pointer is null) return;
|
||||
pointer.Dispose();
|
||||
if (!pointer.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Camera pointer callback removal remains pending.");
|
||||
}),
|
||||
new("combat input subscription", () =>
|
||||
Combat.CombatModeChanged -= SetInputCombatScope),
|
||||
new("dispatcher", () =>
|
||||
{
|
||||
AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher =
|
||||
_inputDispatcher;
|
||||
if (dispatcher is null) return;
|
||||
dispatcher.Fired -= OnInputAction;
|
||||
_movementInput.Unbind(dispatcher);
|
||||
_cameraInput.Unbind(dispatcher);
|
||||
dispatcher.Dispose();
|
||||
if (!dispatcher.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Input dispatcher source removal remains pending.");
|
||||
_inputDispatcher = null;
|
||||
}),
|
||||
new("mouse source", () =>
|
||||
{
|
||||
AcDream.App.Input.SilkMouseSource? source = _mouseSource;
|
||||
if (source is null) return;
|
||||
source.Dispose();
|
||||
if (!source.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Mouse source callback removal remains pending.");
|
||||
_mouseSource = null;
|
||||
}),
|
||||
new("keyboard source", () =>
|
||||
{
|
||||
AcDream.App.Input.SilkKeyboardSource? source = _kbSource;
|
||||
if (source is null) return;
|
||||
source.Dispose();
|
||||
if (!source.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Keyboard source callback removal remains pending.");
|
||||
_kbSource = null;
|
||||
}),
|
||||
]),
|
||||
// Frame composition borrows equipped, effect, audio, and render
|
||||
// owners. Withdraw the graph before the first borrowed owner closes;
|
||||
// the later render-frontend stage still disposes the GL owners.
|
||||
|
|
@ -4126,7 +4052,16 @@ public sealed class GameWindow : IDisposable
|
|||
]),
|
||||
new ResourceShutdownStage("session dependents",
|
||||
[
|
||||
new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()),
|
||||
new("mouse capture", () =>
|
||||
{
|
||||
AcDream.App.Input.CameraPointerInputController? pointer =
|
||||
_cameraPointerInput;
|
||||
if (pointer is null) return;
|
||||
pointer.ReleaseMouseLookAfterSessionRetirement();
|
||||
if (_gameplayInputFrame is { } gameplayFrame)
|
||||
pointer.UnbindGameplayFrame(gameplayFrame);
|
||||
_cameraPointerInput = null;
|
||||
}),
|
||||
new("retail UI", () =>
|
||||
{
|
||||
_retailUiRuntime?.Dispose();
|
||||
|
|
@ -4196,11 +4131,17 @@ public sealed class GameWindow : IDisposable
|
|||
[
|
||||
new("developer tools", () =>
|
||||
{
|
||||
if (_framebufferDevToolsTarget is { } framebufferTarget)
|
||||
{
|
||||
_framebufferResize.UnbindDevTools(framebufferTarget);
|
||||
_framebufferDevToolsTarget = null;
|
||||
}
|
||||
_devToolsFramePresenter = null;
|
||||
_devToolsCameraMenu = null;
|
||||
_devToolsCommandBus = null;
|
||||
_devToolsBackend?.Dispose();
|
||||
_devToolsBackend = null;
|
||||
_devToolsInputContext = null;
|
||||
}),
|
||||
new("portal tunnel", () =>
|
||||
{
|
||||
|
|
@ -4281,8 +4222,6 @@ public sealed class GameWindow : IDisposable
|
|||
if (ReferenceEquals(_windowCallbacks, binding))
|
||||
_windowCallbacks = null;
|
||||
}),
|
||||
new("combat input subscription", ()
|
||||
=> Combat.CombatModeChanged -= SetInputCombatScope),
|
||||
new("input context", () =>
|
||||
{
|
||||
_input?.Dispose();
|
||||
|
|
@ -4299,10 +4238,7 @@ public sealed class GameWindow : IDisposable
|
|||
]));
|
||||
|
||||
private void OnFocusChanged(bool focused)
|
||||
{
|
||||
if (!focused)
|
||||
_gameplayInputFrame?.EndMouseLook();
|
||||
}
|
||||
=> _cameraPointerInput?.HandleFocusChanged(focused);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
|
|||
420
src/AcDream.App/UI/RetainedUiInputBinding.cs
Normal file
420
src/AcDream.App/UI/RetainedUiInputBinding.cs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
internal interface IRetainedUiInputBinding : IDisposable
|
||||
{
|
||||
bool IsDisposalComplete { get; }
|
||||
void Attach();
|
||||
void Deactivate();
|
||||
}
|
||||
|
||||
internal interface IRetainedMouseSurface
|
||||
{
|
||||
void AddMouseDown(Action<MouseButton, int, int> callback);
|
||||
void RemoveMouseDown(Action<MouseButton, int, int> callback);
|
||||
void AddMouseUp(Action<MouseButton, int, int> callback);
|
||||
void RemoveMouseUp(Action<MouseButton, int, int> callback);
|
||||
void AddMouseMove(Action<int, int> callback);
|
||||
void RemoveMouseMove(Action<int, int> callback);
|
||||
void AddScroll(Action<int> callback);
|
||||
void RemoveScroll(Action<int> callback);
|
||||
}
|
||||
|
||||
internal sealed class SilkRetainedMouseSurface : IRetainedMouseSurface
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private Action<MouseButton, int, int>? _downCallback;
|
||||
private Action<MouseButton, int, int>? _upCallback;
|
||||
private Action<int, int>? _moveCallback;
|
||||
private Action<int>? _scrollCallback;
|
||||
private readonly Action<IMouse, MouseButton> _down;
|
||||
private readonly Action<IMouse, MouseButton> _up;
|
||||
private readonly Action<IMouse, Vector2> _move;
|
||||
private readonly Action<IMouse, ScrollWheel> _scroll;
|
||||
|
||||
public SilkRetainedMouseSurface(IMouse mouse)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_down = OnDown;
|
||||
_up = OnUp;
|
||||
_move = OnMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
public void AddMouseDown(Action<MouseButton, int, int> callback)
|
||||
{
|
||||
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseDown += _down;
|
||||
}
|
||||
|
||||
public void RemoveMouseDown(Action<MouseButton, int, int> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_downCallback, callback)) return;
|
||||
_mouse.MouseDown -= _down;
|
||||
_downCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseUp(Action<MouseButton, int, int> callback)
|
||||
{
|
||||
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseUp += _up;
|
||||
}
|
||||
|
||||
public void RemoveMouseUp(Action<MouseButton, int, int> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_upCallback, callback)) return;
|
||||
_mouse.MouseUp -= _up;
|
||||
_upCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseMove(Action<int, int> callback)
|
||||
{
|
||||
_moveCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseMove += _move;
|
||||
}
|
||||
|
||||
public void RemoveMouseMove(Action<int, int> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_moveCallback, callback)) return;
|
||||
_mouse.MouseMove -= _move;
|
||||
_moveCallback = null;
|
||||
}
|
||||
|
||||
public void AddScroll(Action<int> callback)
|
||||
{
|
||||
_scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.Scroll += _scroll;
|
||||
}
|
||||
|
||||
public void RemoveScroll(Action<int> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_scrollCallback, callback)) return;
|
||||
_mouse.Scroll -= _scroll;
|
||||
_scrollCallback = null;
|
||||
}
|
||||
|
||||
private void OnDown(IMouse sender, MouseButton button) =>
|
||||
_downCallback?.Invoke(button, (int)sender.Position.X, (int)sender.Position.Y);
|
||||
private void OnUp(IMouse sender, MouseButton button) =>
|
||||
_upCallback?.Invoke(button, (int)sender.Position.X, (int)sender.Position.Y);
|
||||
private void OnMove(IMouse _, Vector2 position) =>
|
||||
_moveCallback?.Invoke((int)position.X, (int)position.Y);
|
||||
private void OnScroll(IMouse _, ScrollWheel scroll) =>
|
||||
_scrollCallback?.Invoke((int)scroll.Y);
|
||||
}
|
||||
|
||||
internal sealed class RetainedMouseInputBinding : IRetainedUiInputBinding
|
||||
{
|
||||
private readonly IRetainedMouseSurface _surface;
|
||||
private readonly UiRoot _root;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<MouseButton, int, int> _down;
|
||||
private readonly Action<MouseButton, int, int> _up;
|
||||
private readonly Action<int, int> _move;
|
||||
private readonly Action<int> _scroll;
|
||||
private readonly bool[] _attached = new bool[4];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public RetainedMouseInputBinding(
|
||||
IRetainedMouseSurface surface,
|
||||
UiRoot root,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_down = OnDown;
|
||||
_up = OnUp;
|
||||
_move = OnMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Retained mouse attachment has already started.");
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
_attached[0] = true;
|
||||
_surface.AddMouseDown(_down);
|
||||
_attached[1] = true;
|
||||
_surface.AddMouseUp(_up);
|
||||
_attached[2] = true;
|
||||
_surface.AddMouseMove(_move);
|
||||
_attached[3] = true;
|
||||
_surface.AddScroll(_scroll);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnDown(MouseButton button, int x, int y) =>
|
||||
Invoke(() => _root.OnMouseDown(MapButton(button), x, y));
|
||||
private void OnUp(MouseButton button, int x, int y) =>
|
||||
Invoke(() => _root.OnMouseUp(MapButton(button), x, y));
|
||||
private void OnMove(int x, int y) => Invoke(() => _root.OnMouseMove(x, y));
|
||||
private void OnScroll(int amount) => Invoke(() => _root.OnScroll(amount));
|
||||
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("retained mouse callbacks",
|
||||
[
|
||||
new("scroll", () => Remove(3)),
|
||||
new("move", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _surface.RemoveMouseDown(_down); break;
|
||||
case 1: _surface.RemoveMouseUp(_up); break;
|
||||
case 2: _surface.RemoveMouseMove(_move); break;
|
||||
case 3: _surface.RemoveScroll(_scroll); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Retained mouse registration and rollback both failed.",
|
||||
new InvalidOperationException("Retained mouse registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
"Retained mouse registration failed and was rolled back.", attachError);
|
||||
}
|
||||
|
||||
private static UiMouseButton MapButton(MouseButton button) => button switch
|
||||
{
|
||||
MouseButton.Left => UiMouseButton.Left,
|
||||
MouseButton.Right => UiMouseButton.Right,
|
||||
MouseButton.Middle => UiMouseButton.Middle,
|
||||
_ => UiMouseButton.Left,
|
||||
};
|
||||
}
|
||||
|
||||
internal interface IRetainedKeyboardSurface
|
||||
{
|
||||
void AddKeyDown(Action<Key> callback);
|
||||
void RemoveKeyDown(Action<Key> callback);
|
||||
void AddKeyUp(Action<Key> callback);
|
||||
void RemoveKeyUp(Action<Key> callback);
|
||||
void AddKeyChar(Action<char> callback);
|
||||
void RemoveKeyChar(Action<char> callback);
|
||||
}
|
||||
|
||||
internal sealed class SilkRetainedKeyboardSurface : IRetainedKeyboardSurface
|
||||
{
|
||||
private readonly IKeyboard _keyboard;
|
||||
private Action<Key>? _downCallback;
|
||||
private Action<Key>? _upCallback;
|
||||
private Action<char>? _charCallback;
|
||||
private readonly Action<IKeyboard, Key, int> _down;
|
||||
private readonly Action<IKeyboard, Key, int> _up;
|
||||
private readonly Action<IKeyboard, char> _char;
|
||||
|
||||
public SilkRetainedKeyboardSurface(IKeyboard keyboard)
|
||||
{
|
||||
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
|
||||
_down = OnDown;
|
||||
_up = OnUp;
|
||||
_char = OnChar;
|
||||
}
|
||||
|
||||
public void AddKeyDown(Action<Key> callback)
|
||||
{
|
||||
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_keyboard.KeyDown += _down;
|
||||
}
|
||||
|
||||
public void RemoveKeyDown(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_downCallback, callback)) return;
|
||||
_keyboard.KeyDown -= _down;
|
||||
_downCallback = null;
|
||||
}
|
||||
|
||||
public void AddKeyUp(Action<Key> callback)
|
||||
{
|
||||
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_keyboard.KeyUp += _up;
|
||||
}
|
||||
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_upCallback, callback)) return;
|
||||
_keyboard.KeyUp -= _up;
|
||||
_upCallback = null;
|
||||
}
|
||||
|
||||
public void AddKeyChar(Action<char> callback)
|
||||
{
|
||||
_charCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_keyboard.KeyChar += _char;
|
||||
}
|
||||
|
||||
public void RemoveKeyChar(Action<char> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_charCallback, callback)) return;
|
||||
_keyboard.KeyChar -= _char;
|
||||
_charCallback = null;
|
||||
}
|
||||
|
||||
private void OnDown(IKeyboard _, Key key, int __) => _downCallback?.Invoke(key);
|
||||
private void OnUp(IKeyboard _, Key key, int __) => _upCallback?.Invoke(key);
|
||||
private void OnChar(IKeyboard _, char value) => _charCallback?.Invoke(value);
|
||||
}
|
||||
|
||||
internal sealed class RetainedKeyboardInputBinding : IRetainedUiInputBinding
|
||||
{
|
||||
private readonly IRetainedKeyboardSurface _surface;
|
||||
private readonly UiRoot _root;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<Key> _down;
|
||||
private readonly Action<Key> _up;
|
||||
private readonly Action<char> _char;
|
||||
private readonly bool[] _attached = new bool[3];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public RetainedKeyboardInputBinding(
|
||||
IRetainedKeyboardSurface surface,
|
||||
UiRoot root,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_down = key => Invoke(() => _root.OnKeyDown((int)key));
|
||||
_up = key => Invoke(() => _root.OnKeyUp((int)key));
|
||||
_char = value => Invoke(() => _root.OnChar(value));
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Retained keyboard attachment has already started.");
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
_attached[0] = true;
|
||||
_surface.AddKeyDown(_down);
|
||||
_attached[1] = true;
|
||||
_surface.AddKeyUp(_up);
|
||||
_attached[2] = true;
|
||||
_surface.AddKeyChar(_char);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("retained keyboard callbacks",
|
||||
[
|
||||
new("character", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _surface.RemoveKeyDown(_down); break;
|
||||
case 1: _surface.RemoveKeyUp(_up); break;
|
||||
case 2: _surface.RemoveKeyChar(_char); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Retained keyboard registration and rollback both failed.",
|
||||
new InvalidOperationException("Retained keyboard registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
"Retained keyboard registration failed and was rolled back.", attachError);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,15 +49,27 @@ public sealed class UiHost : System.IDisposable
|
|||
public IKeyboard? Keyboard { get; private set; }
|
||||
|
||||
private long _startTicks = System.Environment.TickCount64;
|
||||
private readonly List<System.Action> _inputUnsubscribers = new();
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly List<IRetainedUiInputBinding> _inputBindings = new();
|
||||
private ResourceShutdownTransaction? _inputShutdown;
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
private bool _disposeRequested;
|
||||
private bool _disposed;
|
||||
|
||||
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
|
||||
: this(gl, shaderDir, defaultFont, new HostQuiescenceGate())
|
||||
{
|
||||
}
|
||||
|
||||
internal UiHost(
|
||||
GL gl,
|
||||
string shaderDir,
|
||||
BitmapFont? defaultFont,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
TextRenderer = new TextRenderer(gl, shaderDir);
|
||||
DefaultFont = defaultFont;
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
}
|
||||
|
||||
// ── Per-frame ──────────────────────────────────────────────────────
|
||||
|
|
@ -86,25 +98,21 @@ public sealed class UiHost : System.IDisposable
|
|||
System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
|
||||
System.ArgumentNullException.ThrowIfNull(mouse);
|
||||
|
||||
void OnMouseDown(IMouse sender, MouseButton button) =>
|
||||
Root.OnMouseDown(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
|
||||
void OnMouseUp(IMouse sender, MouseButton button) =>
|
||||
Root.OnMouseUp(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
|
||||
void OnMouseMove(IMouse sender, Vector2 position) =>
|
||||
Root.OnMouseMove((int)position.X, (int)position.Y);
|
||||
void OnScroll(IMouse sender, ScrollWheel scroll) => Root.OnScroll((int)scroll.Y);
|
||||
|
||||
mouse.MouseDown += OnMouseDown;
|
||||
mouse.MouseUp += OnMouseUp;
|
||||
mouse.MouseMove += OnMouseMove;
|
||||
mouse.Scroll += OnScroll;
|
||||
_inputUnsubscribers.Add(() =>
|
||||
var binding = new RetainedMouseInputBinding(
|
||||
new SilkRetainedMouseSurface(mouse),
|
||||
Root,
|
||||
_quiescence);
|
||||
_inputBindings.Add(binding);
|
||||
try
|
||||
{
|
||||
mouse.MouseDown -= OnMouseDown;
|
||||
mouse.MouseUp -= OnMouseUp;
|
||||
mouse.MouseMove -= OnMouseMove;
|
||||
mouse.Scroll -= OnScroll;
|
||||
});
|
||||
binding.Attach();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (binding.IsDisposalComplete)
|
||||
_inputBindings.Remove(binding);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void WireKeyboard(IKeyboard kb)
|
||||
|
|
@ -112,28 +120,54 @@ public sealed class UiHost : System.IDisposable
|
|||
System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
|
||||
System.ArgumentNullException.ThrowIfNull(kb);
|
||||
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
|
||||
void OnKeyDown(IKeyboard sender, Key key, int scanCode) => Root.OnKeyDown((int)key);
|
||||
void OnKeyUp(IKeyboard sender, Key key, int scanCode) => Root.OnKeyUp((int)key);
|
||||
void OnKeyChar(IKeyboard sender, char value) => Root.OnChar(value);
|
||||
|
||||
kb.KeyDown += OnKeyDown;
|
||||
kb.KeyUp += OnKeyUp;
|
||||
kb.KeyChar += OnKeyChar;
|
||||
_inputUnsubscribers.Add(() =>
|
||||
var binding = new RetainedKeyboardInputBinding(
|
||||
new SilkRetainedKeyboardSurface(kb),
|
||||
Root,
|
||||
_quiescence);
|
||||
_inputBindings.Add(binding);
|
||||
try
|
||||
{
|
||||
kb.KeyDown -= OnKeyDown;
|
||||
kb.KeyUp -= OnKeyUp;
|
||||
kb.KeyChar -= OnKeyChar;
|
||||
});
|
||||
binding.Attach();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (binding.IsDisposalComplete)
|
||||
_inputBindings.Remove(binding);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static UiMouseButton MapButton(MouseButton b) => b switch
|
||||
/// <summary>
|
||||
/// Stops retained-device delivery without retiring the window tree or GL
|
||||
/// renderer. Safe to call before a potentially long live-session close.
|
||||
/// </summary>
|
||||
public void QuiesceInput()
|
||||
{
|
||||
MouseButton.Left => UiMouseButton.Left,
|
||||
MouseButton.Right => UiMouseButton.Right,
|
||||
MouseButton.Middle => UiMouseButton.Middle,
|
||||
_ => UiMouseButton.Left,
|
||||
};
|
||||
_disposeRequested = true;
|
||||
foreach (IRetainedUiInputBinding binding in _inputBindings)
|
||||
binding.Deactivate();
|
||||
Keyboard = null;
|
||||
}
|
||||
|
||||
/// <summary>Physically removes every retained input edge after quiescence.</summary>
|
||||
public void DeactivateInput()
|
||||
{
|
||||
QuiesceInput();
|
||||
|
||||
_inputShutdown ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage(
|
||||
"retained UI input subscriptions",
|
||||
_inputBindings
|
||||
.AsEnumerable()
|
||||
.Reverse()
|
||||
.Select((binding, index) => new ResourceShutdownOperation(
|
||||
$"input binding {index}",
|
||||
binding.Dispose))
|
||||
.ToArray()));
|
||||
_inputShutdown.CompleteOrThrow();
|
||||
if (_inputShutdown.IsComplete)
|
||||
_inputBindings.Clear();
|
||||
}
|
||||
|
||||
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
|
||||
|
||||
|
|
@ -169,12 +203,8 @@ public sealed class UiHost : System.IDisposable
|
|||
|
||||
_disposeRequested = true;
|
||||
_shutdown ??= CreateShutdownTransaction(
|
||||
_inputUnsubscribers.AsEnumerable().Reverse().ToArray(),
|
||||
() =>
|
||||
{
|
||||
_inputUnsubscribers.Clear();
|
||||
Keyboard = null;
|
||||
},
|
||||
[DeactivateInput],
|
||||
() => { },
|
||||
WindowManager.Dispose,
|
||||
TextRenderer.Dispose);
|
||||
_shutdown.CompleteOrThrow();
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ namespace AcDream.UI.Abstractions.Input;
|
|||
/// action stream.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InputDispatcher
|
||||
public sealed class InputDispatcher : IDisposable
|
||||
{
|
||||
private readonly IKeyboardSource _keyboard;
|
||||
private readonly IMouseSource _mouse;
|
||||
|
|
@ -39,6 +39,10 @@ public sealed class InputDispatcher
|
|||
private InputScope? _combatScope;
|
||||
private readonly HashSet<KeyChord> _heldHoldChords = new();
|
||||
private readonly HashSet<InputAction> _automationHeldActions = new();
|
||||
private readonly bool[] _sourceAttached = new bool[5];
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
// Double-click detection. _lastMouseDownButton == null means no recent press.
|
||||
// _lastMouseDownTickMs is Environment.TickCount64 at the time of that press.
|
||||
|
|
@ -57,7 +61,10 @@ public sealed class InputDispatcher
|
|||
/// Multicast — every subscriber gets every event in subscription order.</summary>
|
||||
public event Action<InputAction, ActivationType>? Fired;
|
||||
|
||||
public InputDispatcher(IKeyboardSource keyboard, IMouseSource mouse, KeyBindings bindings)
|
||||
private InputDispatcher(
|
||||
IKeyboardSource keyboard,
|
||||
IMouseSource mouse,
|
||||
KeyBindings bindings)
|
||||
{
|
||||
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
|
|
@ -66,11 +73,84 @@ public sealed class InputDispatcher
|
|||
_scopes.Push(InputScope.Always); // bottom of the stack
|
||||
_scopes.Push(InputScope.Game); // default top for normal play
|
||||
|
||||
_keyboard.KeyDown += OnKeyDown;
|
||||
_keyboard.KeyUp += OnKeyUp;
|
||||
_mouse.MouseDown += OnMouseDown;
|
||||
_mouse.MouseUp += OnMouseUp;
|
||||
_mouse.Scroll += OnScroll;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production two-phase construction seam. The host publishes the owner
|
||||
/// before <see cref="Attach"/> so failed custom event accessors cannot
|
||||
/// orphan a source subscription.
|
||||
/// </summary>
|
||||
public static InputDispatcher CreateDetached(
|
||||
IKeyboardSource keyboard,
|
||||
IMouseSource mouse,
|
||||
KeyBindings bindings) =>
|
||||
new(keyboard, mouse, bindings);
|
||||
|
||||
public bool IsDisposalComplete =>
|
||||
_sourceAttached.All(static attached => !attached);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException(
|
||||
"Input dispatcher attachment has already started.");
|
||||
_attachStarted = true;
|
||||
|
||||
try
|
||||
{
|
||||
_sourceAttached[0] = true;
|
||||
_keyboard.KeyDown += OnKeyDown;
|
||||
_sourceAttached[1] = true;
|
||||
_keyboard.KeyUp += OnKeyUp;
|
||||
_sourceAttached[2] = true;
|
||||
_mouse.MouseDown += OnMouseDown;
|
||||
_sourceAttached[3] = true;
|
||||
_mouse.MouseUp += OnMouseUp;
|
||||
_sourceAttached[4] = true;
|
||||
_mouse.Scroll += OnScroll;
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
List<Exception> rollbackErrors = DetachSources();
|
||||
if (rollbackErrors.Count != 0)
|
||||
{
|
||||
rollbackErrors.Insert(0, new InvalidOperationException(
|
||||
"Input dispatcher source registration failed.",
|
||||
attachError));
|
||||
throw new AggregateException(
|
||||
"Input dispatcher registration and rollback both failed.",
|
||||
rollbackErrors);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Input dispatcher registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Immediate logical cutoff; physical detach remains retriable.</summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
Interlocked.Exchange(ref _active, 0);
|
||||
_captureCallback = null;
|
||||
_heldHoldChords.Clear();
|
||||
_automationHeldActions.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
List<Exception> failures = DetachSources();
|
||||
if (failures.Count != 0)
|
||||
throw new AggregateException(
|
||||
"One or more input dispatcher source callbacks could not be detached.",
|
||||
failures);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -85,7 +165,9 @@ public sealed class InputDispatcher
|
|||
{
|
||||
if (action == InputAction.None || !Enum.IsDefined(action))
|
||||
throw new ArgumentOutOfRangeException(nameof(action));
|
||||
if (_captureCallback is not null || _mouse.WantCaptureKeyboard)
|
||||
if (Volatile.Read(ref _active) == 0
|
||||
|| _captureCallback is not null
|
||||
|| _mouse.WantCaptureKeyboard)
|
||||
return false;
|
||||
|
||||
Fired?.Invoke(action, ActivationType.Press);
|
||||
|
|
@ -105,6 +187,9 @@ public sealed class InputDispatcher
|
|||
if (action == InputAction.None || !Enum.IsDefined(action))
|
||||
throw new ArgumentOutOfRangeException(nameof(action));
|
||||
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return false;
|
||||
|
||||
if (held)
|
||||
{
|
||||
if (_captureCallback is not null || _mouse.WantCaptureKeyboard)
|
||||
|
|
@ -217,7 +302,7 @@ public sealed class InputDispatcher
|
|||
/// </summary>
|
||||
public bool IsActionHeld(InputAction action)
|
||||
{
|
||||
if (action == InputAction.None) return false;
|
||||
if (Volatile.Read(ref _active) == 0 || action == InputAction.None) return false;
|
||||
// While a text field owns the keyboard ("write mode"), held game actions read as
|
||||
// released: typing "swd" must not move the character. This is the polling-path twin
|
||||
// of the WantCaptureKeyboard gate on Fired actions. This suppresses physical keys;
|
||||
|
|
@ -341,7 +426,7 @@ public sealed class InputDispatcher
|
|||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (_mouse.WantCaptureKeyboard) return;
|
||||
if (Volatile.Read(ref _active) == 0 || _mouse.WantCaptureKeyboard) return;
|
||||
|
||||
// Snapshot to avoid issues if a subscriber mutates _heldHoldChords.
|
||||
if (_heldHoldChords.Count == 0) return;
|
||||
|
|
@ -363,6 +448,7 @@ public sealed class InputDispatcher
|
|||
|
||||
private void OnKeyDown(Key key, ModifierMask mods)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0) return;
|
||||
// K.3 modal capture (used by Settings panel's "Rebind" UX) takes
|
||||
// precedence over both WantCaptureKeyboard gating AND normal
|
||||
// binding lookup. Esc cancels capture; modifier-only keys don't
|
||||
|
|
@ -417,6 +503,7 @@ public sealed class InputDispatcher
|
|||
|
||||
private void OnKeyUp(Key key, ModifierMask mods)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0) return;
|
||||
// Release fires regardless of WantCaptureKeyboard so we don't
|
||||
// strand a Hold subscriber in the "held" state if the UI captured
|
||||
// mid-press.
|
||||
|
|
@ -444,6 +531,7 @@ public sealed class InputDispatcher
|
|||
|
||||
private void OnMouseDown(MouseButton button, ModifierMask mods)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0) return;
|
||||
if (_mouse.WantCaptureMouse) return;
|
||||
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
|
||||
|
||||
|
|
@ -478,6 +566,7 @@ public sealed class InputDispatcher
|
|||
|
||||
private void OnMouseUp(MouseButton button, ModifierMask mods)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0) return;
|
||||
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
|
||||
|
||||
var release = FindActive(chord, ActivationType.Release);
|
||||
|
|
@ -500,6 +589,7 @@ public sealed class InputDispatcher
|
|||
|
||||
private void OnScroll(float delta)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0) return;
|
||||
if (_mouse.WantCaptureMouse) return;
|
||||
// K.1b: wheel ticks emit ScrollUp / ScrollDown depending on the
|
||||
// sign of the delta. Magnitude is dropped — the action is a
|
||||
|
|
@ -526,4 +616,34 @@ public sealed class InputDispatcher
|
|||
MouseButton.Button5 => (Key)(-1005),
|
||||
_ => (Key)(-1000 - (int)button),
|
||||
};
|
||||
|
||||
private List<Exception> DetachSources()
|
||||
{
|
||||
var failures = new List<Exception>();
|
||||
for (int index = _sourceAttached.Length - 1; index >= 0; index--)
|
||||
{
|
||||
if (!_sourceAttached[index])
|
||||
continue;
|
||||
try
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case 0: _keyboard.KeyDown -= OnKeyDown; break;
|
||||
case 1: _keyboard.KeyUp -= OnKeyUp; break;
|
||||
case 2: _mouse.MouseDown -= OnMouseDown; break;
|
||||
case 3: _mouse.MouseUp -= OnMouseUp; break;
|
||||
case 4: _mouse.Scroll -= OnScroll; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_sourceAttached[index] = false;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
failures.Add(new InvalidOperationException(
|
||||
$"Input dispatcher source callback {index} could not be detached.",
|
||||
error));
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class CameraPointerInputControllerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void AttachFailureRollsBackRawPrefixAndLeavesCameraUnsubscribed(int failureIndex)
|
||||
{
|
||||
var surfaces = new[] { new RawSurface(), new RawSurface(), new RawSurface() };
|
||||
surfaces[failureIndex].FailAdd = true;
|
||||
CameraPointerInputController owner = Create(surfaces).Owner;
|
||||
|
||||
Assert.ThrowsAny<Exception>(owner.AttachRaw);
|
||||
|
||||
Assert.True(owner.IsDisposalComplete);
|
||||
Assert.All(surfaces, surface => Assert.Equal(0, surface.LiveEdges));
|
||||
Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.AddCalls));
|
||||
Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.RemoveCalls));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlyPointerUsesAbsoluteDeltaAndCaptureAdvancesPointerWithoutLooking()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Camera.ToggleFly();
|
||||
fixture.Owner.AttachRaw();
|
||||
float originalYaw = fixture.Camera.Fly.Yaw;
|
||||
float originalPitch = fixture.Camera.Fly.Pitch;
|
||||
|
||||
surface.Raise(new Vector2(10f, 5f));
|
||||
Assert.Equal(originalYaw - 10f * 0.003f, fixture.Camera.Fly.Yaw, 5);
|
||||
Assert.Equal(originalPitch - 5f * 0.003f, fixture.Camera.Fly.Pitch, 5);
|
||||
|
||||
fixture.Capture.Mouse = true;
|
||||
surface.Raise(new Vector2(100f, 100f));
|
||||
float capturedYaw = fixture.Camera.Fly.Yaw;
|
||||
float capturedPitch = fixture.Camera.Fly.Pitch;
|
||||
fixture.Capture.Mouse = false;
|
||||
surface.Raise(new Vector2(102f, 97f));
|
||||
|
||||
Assert.Equal(capturedYaw - 2f * 0.003f, fixture.Camera.Fly.Yaw, 5);
|
||||
Assert.Equal(capturedPitch + 3f * 0.003f, fixture.Camera.Fly.Pitch, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawMoveRaisedReentrantlyDuringAttachCannotEnterPartialOwner()
|
||||
{
|
||||
var surface = new RawSurface { RaiseDuringAdd = true };
|
||||
var fixture = Create([surface]);
|
||||
fixture.Camera.ToggleFly();
|
||||
float yaw = fixture.Camera.Fly.Yaw;
|
||||
|
||||
fixture.Owner.AttachRaw();
|
||||
Assert.Equal(yaw, fixture.Camera.Fly.Yaw);
|
||||
surface.Raise(new Vector2(3, 0));
|
||||
|
||||
Assert.NotEqual(yaw, fixture.Camera.Fly.Yaw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateGameplayBindDoesNotResubscribeAndFocusLossEndsMouseLook()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Owner.AttachRaw();
|
||||
int adds = surface.AddCalls;
|
||||
var mouseLook = new MouseLook(active: true);
|
||||
var frame = new GameplayInputFrameController(
|
||||
dispatcher: null,
|
||||
new DispatcherMovementInputSource(),
|
||||
mouseLook,
|
||||
new Combat());
|
||||
|
||||
fixture.Owner.BindGameplayFrame(frame);
|
||||
fixture.Owner.HandleFocusChanged(focused: false);
|
||||
|
||||
Assert.Equal(adds, surface.AddCalls);
|
||||
Assert.Equal(1, mouseLook.LifecycleEndCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivateSilencesCopiedRawAndCameraDelegatesBeforeDetach()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Camera.ToggleFly();
|
||||
fixture.Owner.AttachRaw();
|
||||
Action<Vector2> copied = surface.Callback!;
|
||||
float yaw = fixture.Camera.Fly.Yaw;
|
||||
|
||||
fixture.Owner.Deactivate();
|
||||
copied(new Vector2(100, 0));
|
||||
fixture.Camera.ToggleFly();
|
||||
fixture.Owner.Dispose();
|
||||
|
||||
Assert.Equal(yaw, fixture.Camera.Fly.Yaw);
|
||||
Assert.True(fixture.Owner.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCloseCutoffDoesNotEndMouseLookUntilSessionRetirement()
|
||||
{
|
||||
var fixture = Create([new RawSurface()]);
|
||||
var mouseLook = new MouseLook(active: true);
|
||||
var frame = new GameplayInputFrameController(
|
||||
dispatcher: null,
|
||||
new DispatcherMovementInputSource(),
|
||||
mouseLook,
|
||||
new Combat());
|
||||
fixture.Owner.BindGameplayFrame(frame);
|
||||
fixture.Owner.AttachRaw();
|
||||
|
||||
fixture.Owner.Deactivate();
|
||||
fixture.Owner.Dispose();
|
||||
Assert.Equal(0, mouseLook.LifecycleEndCalls);
|
||||
|
||||
fixture.Owner.ReleaseMouseLookAfterSessionRetirement();
|
||||
Assert.Equal(1, mouseLook.LifecycleEndCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedRawDetachRetriesOnlyPendingEdge()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Owner.AttachRaw();
|
||||
surface.FailRemoveOnce = true;
|
||||
|
||||
fixture.Owner.Dispose();
|
||||
int removes = surface.RemoveCalls;
|
||||
fixture.Owner.Dispose();
|
||||
|
||||
Assert.Equal(2, surface.RemoveCalls);
|
||||
Assert.Equal(removes, surface.RemoveCalls);
|
||||
Assert.True(fixture.Owner.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeBeforeAttachMakesPointerOwnerTerminal()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
|
||||
fixture.Owner.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(fixture.Owner.AttachRaw);
|
||||
fixture.Owner.Dispose();
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollAndSensitivityPreserveModeSpecificPolicy()
|
||||
{
|
||||
var fixture = Create([new RawSurface()]);
|
||||
float orbitDistance = fixture.Camera.Orbit.Distance;
|
||||
|
||||
fixture.Owner.HandleScroll(InputAction.ScrollUp);
|
||||
string orbit = fixture.Owner.AdjustSensitivity(1.2f);
|
||||
fixture.Camera.ToggleFly();
|
||||
float flyBefore = fixture.Owner.ActiveSensitivity;
|
||||
string fly = fixture.Owner.AdjustSensitivity(1.2f);
|
||||
|
||||
Assert.Equal(Math.Clamp(orbitDistance - 20f, 50f, 2000f), fixture.Camera.Orbit.Distance);
|
||||
Assert.Equal("Orbit sens 1.200x", orbit);
|
||||
Assert.Equal("Fly sens 1.200x", fly);
|
||||
Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5);
|
||||
}
|
||||
|
||||
private static Fixture Create(IReadOnlyList<RawSurface> surfaces)
|
||||
{
|
||||
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
|
||||
var capture = new Capture();
|
||||
var mouse = new Mouse();
|
||||
var cursor = new Cursor();
|
||||
var owner = new CameraPointerInputController(
|
||||
surfaces,
|
||||
cursor,
|
||||
new HostQuiescenceGate(),
|
||||
capture,
|
||||
new LocalPlayerModeState(),
|
||||
camera,
|
||||
new ChaseCameraInputState(),
|
||||
mouse,
|
||||
new PointerPositionState(),
|
||||
new Clock());
|
||||
return new Fixture(owner, camera, capture, cursor);
|
||||
}
|
||||
|
||||
private sealed record Fixture(
|
||||
CameraPointerInputController Owner,
|
||||
CameraController Camera,
|
||||
Capture Capture,
|
||||
Cursor Cursor);
|
||||
|
||||
private sealed class RawSurface : IRawPointerSurface
|
||||
{
|
||||
public bool FailAdd { get; set; }
|
||||
public bool FailRemoveOnce { get; set; }
|
||||
public bool RaiseDuringAdd { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public Action<Vector2>? Callback { get; private set; }
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
Callback = callback;
|
||||
LiveEdges++;
|
||||
if (RaiseDuringAdd) callback(new Vector2(50, 25));
|
||||
if (FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (FailRemoveOnce)
|
||||
{
|
||||
FailRemoveOnce = false;
|
||||
throw new InvalidOperationException("remove");
|
||||
}
|
||||
if (ReferenceEquals(Callback, callback))
|
||||
{
|
||||
Callback = null;
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
public void Raise(Vector2 position) => Callback?.Invoke(position);
|
||||
}
|
||||
|
||||
private sealed class Cursor : IPointerCursorModeTarget
|
||||
{
|
||||
public CursorMode CursorMode { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Capture : IInputCaptureSource
|
||||
{
|
||||
public bool Mouse { get; set; }
|
||||
public bool WantCaptureMouse => Mouse;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
public bool DevToolsWantCaptureKeyboard => false;
|
||||
}
|
||||
|
||||
private sealed class Mouse : IMouseSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
}
|
||||
|
||||
private sealed class Clock : IInputMonotonicClock
|
||||
{
|
||||
public float NowSeconds => 1f;
|
||||
}
|
||||
|
||||
private sealed class MouseLook(bool active) : IMouseLookInputFrameController
|
||||
{
|
||||
public bool Active { get; } = active;
|
||||
public int LifecycleEndCalls { get; private set; }
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation) => false;
|
||||
public void QueueRawDelta(float dx, float dy) { }
|
||||
public void Tick() { }
|
||||
public void EndAndRestoreCursor() { }
|
||||
public void EndForLifecycle() => LifecycleEndCalls++;
|
||||
public void ResetSession() { }
|
||||
}
|
||||
|
||||
private sealed class Combat : ICombatInputFrameController
|
||||
{
|
||||
public void Tick() { }
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation) { }
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation) => false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class DispatcherCameraInputSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExactUnbindRestoresNeutralCameraSampling()
|
||||
{
|
||||
var keyboard = new Keyboard();
|
||||
var mouse = new Mouse();
|
||||
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
var other = InputDispatcher.CreateDetached(new Keyboard(), new Mouse(), new KeyBindings());
|
||||
other.Attach();
|
||||
var source = new DispatcherCameraInputSource();
|
||||
source.Bind(dispatcher);
|
||||
dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
|
||||
|
||||
source.Unbind(other);
|
||||
Assert.True(source.CaptureFly().Forward);
|
||||
source.Unbind(dispatcher);
|
||||
|
||||
Assert.False(source.IsAvailable);
|
||||
Assert.Equal(default, source.CaptureFly());
|
||||
Assert.Equal(default, source.CaptureChaseAdjustment());
|
||||
}
|
||||
|
||||
private sealed class Keyboard : IKeyboardSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<Key, ModifierMask>? KeyDown;
|
||||
public event Action<Key, ModifierMask>? KeyUp;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(Key key) => false;
|
||||
public ModifierMask CurrentModifiers => ModifierMask.None;
|
||||
}
|
||||
|
||||
private sealed class Mouse : IMouseSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
}
|
||||
}
|
||||
|
|
@ -112,12 +112,34 @@ public sealed class DispatcherMovementInputSourceTests
|
|||
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnbindRequiresExactDispatcherAndRestoresNeutralCapture()
|
||||
{
|
||||
var source = new DispatcherMovementInputSource();
|
||||
var (first, _, _) = CreateDispatcher();
|
||||
var (other, _, _) = CreateDispatcher();
|
||||
source.Bind(first);
|
||||
first.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
|
||||
|
||||
source.Unbind(other);
|
||||
Assert.True(source.Capture().Forward);
|
||||
source.Unbind(first);
|
||||
|
||||
Assert.False(source.IsAvailable);
|
||||
Assert.Equal(default, source.Capture());
|
||||
}
|
||||
|
||||
private static (InputDispatcher Dispatcher, FakeKeyboard Keyboard, FakeMouse Mouse)
|
||||
CreateDispatcher()
|
||||
{
|
||||
var keyboard = new FakeKeyboard();
|
||||
var mouse = new FakeMouse();
|
||||
return (new InputDispatcher(keyboard, mouse, new KeyBindings()), keyboard, mouse);
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
keyboard,
|
||||
mouse,
|
||||
new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
return (dispatcher, keyboard, mouse);
|
||||
}
|
||||
|
||||
private sealed class FakeKeyboard : IKeyboardSource
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ public sealed class GameplayInputFrameControllerTests
|
|||
new KeyChord(Key.W, ModifierMask.None),
|
||||
InputAction.MovementForward,
|
||||
ActivationType.Hold));
|
||||
var dispatcher = new InputDispatcher(keyboard, mouseSource, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouseSource, bindings);
|
||||
dispatcher.Attach();
|
||||
keyboard.Press(Key.W);
|
||||
dispatcher.Fired += (_, activation) =>
|
||||
{
|
||||
|
|
|
|||
344
tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs
Normal file
344
tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
using System.Reflection;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class QuiescentInputContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryFrontendAddFailureRetainsTheCompletePossiblyAttachedPrefix()
|
||||
{
|
||||
for (int failAdd = 1; failAdd <= 10; failAdd++)
|
||||
{
|
||||
var fixture = new Fixture { FailAdd = failAdd };
|
||||
var context = fixture.CreateContext();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => SubscribeAll(context, () => { }));
|
||||
context.Dispose();
|
||||
|
||||
Assert.Equal(failAdd, fixture.AddCalls);
|
||||
Assert.Equal(failAdd, fixture.RemoveCalls);
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
Assert.False(fixture.InnerDisposed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryFrontendRemoveFailureRetriesOnlyThePendingEdge()
|
||||
{
|
||||
for (int failRemoveEdge = 1; failRemoveEdge <= 10; failRemoveEdge++)
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var context = fixture.CreateContext();
|
||||
SubscribeAll(context, () => { });
|
||||
context.Activate();
|
||||
fixture.FailRemoveEdgeOnce = failRemoveEdge;
|
||||
|
||||
context.Dispose();
|
||||
int[] completedCounts = fixture.RemoveCounts.ToArray();
|
||||
context.Dispose();
|
||||
|
||||
for (int edge = 1; edge <= 10; edge++)
|
||||
{
|
||||
Assert.Equal(
|
||||
edge == failRemoveEdge ? 2 : 1,
|
||||
completedCounts[edge]);
|
||||
Assert.Equal(completedCounts[edge], fixture.RemoveCounts[edge]);
|
||||
}
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
Assert.False(fixture.InnerDisposed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivateSilencesKeyboardMouseAndConnectionRelaysBeforePhysicalDetach()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var context = fixture.CreateContext();
|
||||
int calls = 0;
|
||||
SubscribeAll(context, () => calls++);
|
||||
context.Activate();
|
||||
|
||||
fixture.RaiseAll();
|
||||
Assert.Equal(10, calls);
|
||||
|
||||
context.Deactivate();
|
||||
fixture.RaiseAll();
|
||||
context.Dispose();
|
||||
|
||||
Assert.Equal(10, calls);
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventRaisedFromAddAccessorCannotEnterPartialFrontend()
|
||||
{
|
||||
var fixture = new Fixture { RaiseKeyboardDownDuringAdd = true };
|
||||
var context = fixture.CreateContext();
|
||||
int calls = 0;
|
||||
|
||||
context.Keyboards[0].KeyDown += (_, _, _) => calls++;
|
||||
Assert.Equal(0, calls);
|
||||
context.Activate();
|
||||
fixture.Keyboard.RaiseDown();
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeBeforeFrontendConstructionMakesContextAndDeviceViewsTerminal()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var context = fixture.CreateContext();
|
||||
IKeyboard keyboard = context.Keyboards[0];
|
||||
IMouse mouse = context.Mice[0];
|
||||
context.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(context.Activate);
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => keyboard.KeyDown += static (_, _, _) => { });
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => mouse.MouseDown += static (_, _) => { });
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => context.ConnectionChanged += static (_, _) => { });
|
||||
|
||||
context.Dispose();
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
}
|
||||
|
||||
private static void SubscribeAll(QuiescentInputContext context, Action called)
|
||||
{
|
||||
IKeyboard keyboard = context.Keyboards[0];
|
||||
IMouse mouse = context.Mice[0];
|
||||
keyboard.KeyDown += (_, _, _) => called();
|
||||
keyboard.KeyUp += (_, _, _) => called();
|
||||
keyboard.KeyChar += (_, _) => called();
|
||||
mouse.MouseDown += (_, _) => called();
|
||||
mouse.MouseUp += (_, _) => called();
|
||||
mouse.Click += (_, _, _) => called();
|
||||
mouse.DoubleClick += (_, _, _) => called();
|
||||
mouse.MouseMove += (_, _) => called();
|
||||
mouse.Scroll += (_, _) => called();
|
||||
context.ConnectionChanged += (_, _) => called();
|
||||
}
|
||||
|
||||
private sealed class Fixture
|
||||
{
|
||||
private readonly FaultPlan _faults = new();
|
||||
private Context? _inner;
|
||||
private IMouse? _mouse;
|
||||
private MouseProxy? _mouseProxy;
|
||||
|
||||
public Keyboard Keyboard { get; private set; } = null!;
|
||||
public int FailAdd { set => _faults.FailAdd = value; }
|
||||
public int FailRemoveEdgeOnce { set => _faults.FailRemoveEdgeOnce = value; }
|
||||
public bool RaiseKeyboardDownDuringAdd { get; set; }
|
||||
public int AddCalls => _faults.AddCalls;
|
||||
public int RemoveCalls => _faults.RemoveCalls;
|
||||
public int[] RemoveCounts => _faults.RemoveCounts;
|
||||
public int LiveEdges => _faults.LiveEdges;
|
||||
public bool InnerDisposed => _inner?.Disposed == true;
|
||||
|
||||
public QuiescentInputContext CreateContext()
|
||||
{
|
||||
Keyboard = new Keyboard(_faults)
|
||||
{
|
||||
RaiseDuringAdd = RaiseKeyboardDownDuringAdd,
|
||||
};
|
||||
_mouse = DispatchProxy.Create<IMouse, MouseProxy>();
|
||||
_mouseProxy = (MouseProxy)(object)_mouse;
|
||||
_mouseProxy.Initialize(_faults, _mouse);
|
||||
_inner = new Context(_faults, Keyboard, _mouse);
|
||||
return new QuiescentInputContext(_inner, new HostQuiescenceGate());
|
||||
}
|
||||
|
||||
public void RaiseAll()
|
||||
{
|
||||
Keyboard.RaiseAll();
|
||||
_mouseProxy!.RaiseAll();
|
||||
_inner!.RaiseConnection(Keyboard);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FaultPlan
|
||||
{
|
||||
public int FailAdd { get; set; }
|
||||
public int FailRemoveEdgeOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public int[] RemoveCounts { get; } = new int[11];
|
||||
|
||||
public void Add(int edge, Action sideEffect)
|
||||
{
|
||||
AddCalls++;
|
||||
sideEffect();
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd)
|
||||
throw new InvalidOperationException($"add {edge}");
|
||||
}
|
||||
|
||||
public void Remove(int edge, Action sideEffect)
|
||||
{
|
||||
RemoveCalls++;
|
||||
RemoveCounts[edge]++;
|
||||
if (FailRemoveEdgeOnce == edge)
|
||||
{
|
||||
FailRemoveEdgeOnce = 0;
|
||||
throw new InvalidOperationException($"remove {edge}");
|
||||
}
|
||||
|
||||
sideEffect();
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Context(
|
||||
FaultPlan faults,
|
||||
IKeyboard keyboard,
|
||||
IMouse mouse) : IInputContext
|
||||
{
|
||||
private Action<IInputDevice, bool>? _connectionChanged;
|
||||
public bool Disposed { get; private set; }
|
||||
public nint Handle => 1;
|
||||
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
|
||||
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
|
||||
public IReadOnlyList<IKeyboard> Keyboards { get; } = [keyboard];
|
||||
public IReadOnlyList<IMouse> Mice { get; } = [mouse];
|
||||
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
|
||||
|
||||
public event Action<IInputDevice, bool>? ConnectionChanged
|
||||
{
|
||||
add => faults.Add(10, () => _connectionChanged += value);
|
||||
remove => faults.Remove(10, () => _connectionChanged -= value);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
public void RaiseConnection(IInputDevice device) =>
|
||||
_connectionChanged?.Invoke(device, true);
|
||||
}
|
||||
|
||||
private sealed class Keyboard(FaultPlan faults) : IKeyboard
|
||||
{
|
||||
private Action<IKeyboard, Key, int>? _down;
|
||||
private Action<IKeyboard, Key, int>? _up;
|
||||
private Action<IKeyboard, char>? _char;
|
||||
public bool RaiseDuringAdd { get; init; }
|
||||
public string Name => "keyboard";
|
||||
public int Index => 0;
|
||||
public bool IsConnected => true;
|
||||
public IReadOnlyList<Key> SupportedKeys { get; } = [];
|
||||
public string ClipboardText { get; set; } = string.Empty;
|
||||
|
||||
public event Action<IKeyboard, Key, int>? KeyDown
|
||||
{
|
||||
add => faults.Add(1, () =>
|
||||
{
|
||||
_down += value;
|
||||
if (RaiseDuringAdd)
|
||||
_down?.Invoke(this, Key.A, 0);
|
||||
});
|
||||
remove => faults.Remove(1, () => _down -= value);
|
||||
}
|
||||
public event Action<IKeyboard, Key, int>? KeyUp
|
||||
{
|
||||
add => faults.Add(2, () => _up += value);
|
||||
remove => faults.Remove(2, () => _up -= value);
|
||||
}
|
||||
public event Action<IKeyboard, char>? KeyChar
|
||||
{
|
||||
add => faults.Add(3, () => _char += value);
|
||||
remove => faults.Remove(3, () => _char -= value);
|
||||
}
|
||||
|
||||
public bool IsKeyPressed(Key key) => false;
|
||||
public bool IsScancodePressed(int scancode) => false;
|
||||
public void BeginInput() { }
|
||||
public void EndInput() { }
|
||||
public void RaiseDown() => _down?.Invoke(this, Key.A, 0);
|
||||
public void RaiseAll()
|
||||
{
|
||||
_down?.Invoke(this, Key.A, 0);
|
||||
_up?.Invoke(this, Key.A, 0);
|
||||
_char?.Invoke(this, 'a');
|
||||
}
|
||||
}
|
||||
|
||||
public class MouseProxy : DispatchProxy
|
||||
{
|
||||
private readonly Dictionary<string, Delegate?> _events = new();
|
||||
private FaultPlan _faults = null!;
|
||||
private IMouse _self = null!;
|
||||
|
||||
public void Initialize(FaultPlan faults, IMouse self)
|
||||
{
|
||||
_faults = faults;
|
||||
_self = self;
|
||||
}
|
||||
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetMethod);
|
||||
args ??= [];
|
||||
if (targetMethod.Name.StartsWith("add_", StringComparison.Ordinal))
|
||||
{
|
||||
string name = targetMethod.Name[4..];
|
||||
int edge = Edge(name);
|
||||
_faults.Add(edge, () =>
|
||||
_events[name] = Delegate.Combine(_events.GetValueOrDefault(name), (Delegate)args[0]!));
|
||||
return null;
|
||||
}
|
||||
if (targetMethod.Name.StartsWith("remove_", StringComparison.Ordinal))
|
||||
{
|
||||
string name = targetMethod.Name[7..];
|
||||
int edge = Edge(name);
|
||||
_faults.Remove(edge, () =>
|
||||
_events[name] = Delegate.Remove(_events.GetValueOrDefault(name), (Delegate)args[0]!));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetMethod.Name == nameof(IMouse.IsButtonPressed))
|
||||
return false;
|
||||
Type returnType = targetMethod.ReturnType;
|
||||
if (returnType == typeof(void))
|
||||
return null;
|
||||
if (returnType == typeof(string))
|
||||
return string.Empty;
|
||||
return returnType.IsValueType ? Activator.CreateInstance(returnType) : null;
|
||||
}
|
||||
|
||||
public void RaiseAll()
|
||||
{
|
||||
Get<Action<IMouse, MouseButton>>(nameof(IMouse.MouseDown))?.Invoke(_self, MouseButton.Left);
|
||||
Get<Action<IMouse, MouseButton>>(nameof(IMouse.MouseUp))?.Invoke(_self, MouseButton.Left);
|
||||
Get<Action<IMouse, MouseButton, System.Numerics.Vector2>>(nameof(IMouse.Click))
|
||||
?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One);
|
||||
Get<Action<IMouse, MouseButton, System.Numerics.Vector2>>(nameof(IMouse.DoubleClick))
|
||||
?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One);
|
||||
Get<Action<IMouse, System.Numerics.Vector2>>(nameof(IMouse.MouseMove))
|
||||
?.Invoke(_self, System.Numerics.Vector2.One);
|
||||
Get<Action<IMouse, ScrollWheel>>(nameof(IMouse.Scroll))
|
||||
?.Invoke(_self, new ScrollWheel(1, 1));
|
||||
}
|
||||
|
||||
private T? Get<T>(string name) where T : Delegate =>
|
||||
_events.GetValueOrDefault(name) as T;
|
||||
|
||||
private static int Edge(string name) => name switch
|
||||
{
|
||||
nameof(IMouse.MouseDown) => 4,
|
||||
nameof(IMouse.MouseUp) => 5,
|
||||
nameof(IMouse.Click) => 6,
|
||||
nameof(IMouse.DoubleClick) => 7,
|
||||
nameof(IMouse.MouseMove) => 8,
|
||||
nameof(IMouse.Scroll) => 9,
|
||||
_ => throw new InvalidOperationException($"Unexpected mouse event {name}."),
|
||||
};
|
||||
}
|
||||
}
|
||||
284
tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs
Normal file
284
tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class SilkInputSourceLifetimeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new KeyboardSurface { FailAdd = failAdd };
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(source.Attach);
|
||||
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
int calls = 0;
|
||||
source.KeyDown += (_, _) => calls++;
|
||||
source.Attach();
|
||||
Action<Key> copied = surface.Down!;
|
||||
|
||||
surface.RaiseDown(Key.A);
|
||||
source.Deactivate();
|
||||
copied(Key.B);
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardEventRaisedReentrantlyDuringAttachCannotEnterPartialSource()
|
||||
{
|
||||
var surface = new KeyboardSurface { RaiseDuringAdd = true };
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
int calls = 0;
|
||||
source.KeyDown += (_, _) => calls++;
|
||||
|
||||
source.Attach();
|
||||
Assert.Equal(0, calls);
|
||||
surface.RaiseDown(Key.A);
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDetachRetriesOnlyPendingEdge()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
source.Attach();
|
||||
surface.FailRemoveDown = true;
|
||||
|
||||
Assert.Throws<AggregateException>(source.Dispose);
|
||||
int upRemoves = surface.UpRemoveCalls;
|
||||
surface.FailRemoveDown = false;
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(upRemoves, surface.UpRemoveCalls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDisposeBeforeAttachMakesSourceTerminal()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
|
||||
source.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(source.Attach);
|
||||
source.Dispose();
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new MouseSurface { FailAdd = failAdd };
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(source.Attach);
|
||||
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDeactivateSilencesAllCopiedDelegatesBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
int calls = 0;
|
||||
source.MouseDown += (_, _) => calls++;
|
||||
source.MouseUp += (_, _) => calls++;
|
||||
source.MouseMove += (_, _) => calls++;
|
||||
source.Scroll += _ => calls++;
|
||||
source.Attach();
|
||||
var copied = surface.CopyCallbacks();
|
||||
|
||||
surface.RaiseAll();
|
||||
source.Deactivate();
|
||||
copied.Down(MouseButton.Left);
|
||||
copied.Up(MouseButton.Left);
|
||||
copied.Move(new Vector2(5, 6));
|
||||
copied.Scroll(1);
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(4, calls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseFailedDetachRetriesOnlyPendingEdge()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
source.Attach();
|
||||
surface.FailRemoveMoveOnce = true;
|
||||
|
||||
source.Dispose();
|
||||
int removes = surface.RemoveCalls;
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(removes, surface.RemoveCalls);
|
||||
Assert.Equal(2, surface.MoveRemoveCalls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDisposeBeforeAttachMakesSourceTerminal()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
|
||||
source.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(source.Attach);
|
||||
source.Dispose();
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
private sealed class Capture : IInputCaptureSource
|
||||
{
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
public bool DevToolsWantCaptureKeyboard => false;
|
||||
}
|
||||
|
||||
private sealed class KeyboardSurface : IKeyboardEventSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveDown { get; set; }
|
||||
public bool RaiseDuringAdd { get; init; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int UpRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
private Action<Key>? _down;
|
||||
private Action<Key>? _up;
|
||||
public Action<Key>? Down => _down;
|
||||
|
||||
public void AddKeyDown(Action<Key> callback) => Add(ref _down, callback);
|
||||
public void AddKeyUp(Action<Key> callback) => Add(ref _up, callback);
|
||||
public void RemoveKeyDown(Action<Key> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (FailRemoveDown) throw new InvalidOperationException("remove down");
|
||||
if (ReferenceEquals(_down, callback)) { _down = null; LiveEdges--; }
|
||||
}
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
UpRemoveCalls++;
|
||||
if (ReferenceEquals(_up, callback)) { _up = null; LiveEdges--; }
|
||||
}
|
||||
public bool IsKeyPressed(Key key) => false;
|
||||
public void RaiseDown(Key key) => _down?.Invoke(key);
|
||||
|
||||
private void Add(ref Action<Key>? slot, Action<Key> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
slot = callback;
|
||||
LiveEdges++;
|
||||
if (RaiseDuringAdd) callback(Key.A);
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MouseSurface : IMouseEventSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveMoveOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int MoveRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
private Action<MouseButton>? _down;
|
||||
private Action<MouseButton>? _up;
|
||||
private Action<Vector2>? _move;
|
||||
private Action<float>? _scroll;
|
||||
|
||||
public void AddMouseDown(Action<MouseButton> callback) => Add(ref _down, callback);
|
||||
public void AddMouseUp(Action<MouseButton> callback) => Add(ref _up, callback);
|
||||
public void AddMouseMove(Action<Vector2> callback) => Add(ref _move, callback);
|
||||
public void AddScroll(Action<float> callback) => Add(ref _scroll, callback);
|
||||
public void RemoveMouseDown(Action<MouseButton> callback) => Remove(ref _down, callback);
|
||||
public void RemoveMouseUp(Action<MouseButton> callback) => Remove(ref _up, callback);
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
MoveRemoveCalls++;
|
||||
if (FailRemoveMoveOnce)
|
||||
{
|
||||
FailRemoveMoveOnce = false;
|
||||
throw new InvalidOperationException("remove move");
|
||||
}
|
||||
Remove(ref _move, callback);
|
||||
}
|
||||
public void RemoveScroll(Action<float> callback) => Remove(ref _scroll, callback);
|
||||
public bool IsButtonPressed(MouseButton button) => false;
|
||||
|
||||
public (Action<MouseButton> Down, Action<MouseButton> Up, Action<Vector2> Move, Action<float> Scroll)
|
||||
CopyCallbacks() => (_down!, _up!, _move!, _scroll!);
|
||||
|
||||
public void RaiseAll()
|
||||
{
|
||||
_down?.Invoke(MouseButton.Left);
|
||||
_up?.Invoke(MouseButton.Left);
|
||||
_move?.Invoke(Vector2.One);
|
||||
_scroll?.Invoke(1);
|
||||
}
|
||||
|
||||
private void Add<T>(ref Action<T>? slot, Action<T> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
slot = callback;
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
|
||||
private void Remove<T>(ref Action<T>? slot, Action<T> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (ReferenceEquals(slot, callback)) { slot = null; LiveEdges--; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class FramebufferResizeControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResizePreservesViewportAspectCameraDevToolsOrder()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var aspect = new ViewportAspectState();
|
||||
var owner = new FramebufferResizeController(aspect);
|
||||
owner.BindViewport(new Viewport(calls));
|
||||
owner.BindCamera(new Camera(calls));
|
||||
owner.BindDevTools(new DevTools(calls));
|
||||
|
||||
owner.Resize(1600, 900);
|
||||
|
||||
Assert.Equal(["viewport:1600x900", "camera:1.778", "devtools:1600x900"], calls);
|
||||
Assert.Equal(1600f / 900f, aspect.Aspect, 5);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 720)]
|
||||
[InlineData(1280, 0)]
|
||||
[InlineData(-1, 720)]
|
||||
[InlineData(1280, -1)]
|
||||
public void NonPositiveFramebufferIsRejectedWithoutMutatingTargets(int width, int height)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var aspect = new ViewportAspectState();
|
||||
var owner = new FramebufferResizeController(aspect);
|
||||
owner.BindViewport(new Viewport(calls));
|
||||
owner.BindCamera(new Camera(calls));
|
||||
owner.BindDevTools(new DevTools(calls));
|
||||
|
||||
owner.Resize(width, height);
|
||||
|
||||
Assert.Empty(calls);
|
||||
Assert.Equal(16f / 9f, aspect.Aspect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateBindingDoesNotReplayEarlierResize()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var owner = new FramebufferResizeController(new ViewportAspectState());
|
||||
owner.Resize(1024, 768);
|
||||
|
||||
owner.BindViewport(new Viewport(calls));
|
||||
owner.BindCamera(new Camera(calls));
|
||||
owner.BindDevTools(new DevTools(calls));
|
||||
|
||||
Assert.Empty(calls);
|
||||
owner.Resize(800, 600);
|
||||
Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls);
|
||||
}
|
||||
|
||||
private sealed class Viewport(List<string> calls) : IFramebufferViewportTarget
|
||||
{
|
||||
public void ResizeViewport(int width, int height) =>
|
||||
calls.Add($"viewport:{width}x{height}");
|
||||
}
|
||||
|
||||
private sealed class Camera(List<string> calls) : IFramebufferCameraTarget
|
||||
{
|
||||
public void SetAspect(float aspect) => calls.Add($"camera:{aspect:F3}");
|
||||
}
|
||||
|
||||
private sealed class DevTools(List<string> calls) : IFramebufferDevToolsTarget
|
||||
{
|
||||
public void ResetLayout(int width, int height) =>
|
||||
calls.Add($"devtools:{width}x{height}");
|
||||
}
|
||||
}
|
||||
|
|
@ -99,7 +99,13 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source);
|
||||
Assert.Contains("new DisplayFramePacingController(", source);
|
||||
Assert.Contains("_inputCapture.WantCaptureMouse", source);
|
||||
string pointerSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Input",
|
||||
"CameraPointerInputController.cs"));
|
||||
Assert.Contains("_capture.WantCaptureMouse", pointerSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -54,14 +54,18 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
body,
|
||||
"_gl = GL.GetApi(_window!);",
|
||||
"_input = _window!.CreateInput();",
|
||||
"_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);",
|
||||
"_mouseSource = new AcDream.App.Input.SilkMouseSource(",
|
||||
"_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(",
|
||||
"_kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(",
|
||||
"_kbSource.Attach();",
|
||||
"_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(",
|
||||
"_mouseSource.Attach();",
|
||||
"_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(",
|
||||
"_inputDispatcher.Attach();",
|
||||
"_movementInput.Bind(_inputDispatcher);",
|
||||
"_cameraInput.Bind(_inputDispatcher);",
|
||||
"_inputDispatcher.Fired += OnInputAction;",
|
||||
"mouse.MouseMove +=",
|
||||
"_cameraController = new CameraController(orbit, fly);",
|
||||
"_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(",
|
||||
"_cameraPointerInput.AttachRaw();",
|
||||
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
||||
"LoadAndApplyPersistedSettings();",
|
||||
"_uiHost = new AcDream.App.UI.UiHost(",
|
||||
|
|
@ -74,6 +78,16 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"_liveSessionHost = CreateLiveSessionHost();",
|
||||
"_liveSessionHost.Start(_options)");
|
||||
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
|
||||
Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal);
|
||||
Assert.Contains("if (firstMouse is not null)", body, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"if (_kbSource is not null && _mouseSource is not null)",
|
||||
body,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"if (firstKb is not null && firstMouse is not null)",
|
||||
body,
|
||||
StringComparison.Ordinal);
|
||||
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
|
||||
string postStart = body[start..];
|
||||
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
|
||||
|
|
@ -144,20 +158,28 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void FramebufferResize_PreservesViewportCameraAndDevtoolsOrder()
|
||||
public void FramebufferResize_IsOneTypedOwnerHandoff()
|
||||
{
|
||||
string load = MethodBody(
|
||||
"private void OnLoad()",
|
||||
"private AcDream.App.Net.LiveSessionHost");
|
||||
string body = MethodBody(
|
||||
"private void OnFramebufferResize(",
|
||||
"private void ApplyDisplayWindowState(");
|
||||
|
||||
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("SetAspect(", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ResetLayout(", body, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
body,
|
||||
"if (newSize.X <= 0 || newSize.Y <= 0) return;",
|
||||
"_gl?.Viewport(",
|
||||
"_viewportAspect.Update(",
|
||||
"_cameraController?.SetAspect(",
|
||||
"_devToolsFramePresenter?.ResetLayout(");
|
||||
Assert.DoesNotContain("_uiHost", body, StringComparison.Ordinal);
|
||||
load,
|
||||
"_framebufferResize.BindViewport(",
|
||||
"_framebufferResize.BindCamera(",
|
||||
"_framebufferResize.Resize(_window!.FramebufferSize);");
|
||||
Assert.DoesNotContain(
|
||||
"_viewportAspect.Update(_window",
|
||||
load,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -167,7 +189,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
string update = Slice(
|
||||
source,
|
||||
"private void OnUpdate(double dt)",
|
||||
"private void OnCameraModeChanged(");
|
||||
"private void OnRender(double deltaSeconds)");
|
||||
string render = Slice(
|
||||
source,
|
||||
"private void OnRender(double deltaSeconds)",
|
||||
|
|
@ -189,7 +211,10 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"_renderFrameOrchestrator!.Render(",
|
||||
"new AcDream.App.Rendering.RenderFrameInput(");
|
||||
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();");
|
||||
Assert.Contains(
|
||||
"=> _cameraPointerInput?.HandleFocusChanged(focused);",
|
||||
focus,
|
||||
StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
close,
|
||||
"_hostQuiescence.StopAccepting();",
|
||||
|
|
@ -212,7 +237,9 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
|
||||
string[] stages =
|
||||
[
|
||||
"new ResourceShutdownStage(\"input callback deactivation\"",
|
||||
"new ResourceShutdownStage(\"session lifetime\"",
|
||||
"new ResourceShutdownStage(\"input callback detach\"",
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"new ResourceShutdownStage(\"live entities\"",
|
||||
|
|
@ -236,6 +263,17 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"binding.Dispose();",
|
||||
"if (!binding.IsDisposalComplete)",
|
||||
"_windowCallbacks = null;");
|
||||
AssertAppearsInOrder(
|
||||
manifest,
|
||||
"new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())",
|
||||
"new ResourceShutdownStage(\"session lifetime\"",
|
||||
"controller.Dispose();",
|
||||
"new ResourceShutdownStage(\"input callback detach\"",
|
||||
"pointer.Dispose();",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"pointer.ReleaseMouseLookAfterSessionRetirement();",
|
||||
"pointer.UnbindGameplayFrame(gameplayFrame);",
|
||||
"_cameraPointerInput = null;");
|
||||
AssertAppearsInOrder(
|
||||
dispose,
|
||||
"CompleteShutdown();",
|
||||
|
|
|
|||
262
tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs
Normal file
262
tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class RetainedUiInputBindingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new MouseSurface { FailAdd = failAdd };
|
||||
var binding = new RetainedMouseInputBinding(
|
||||
surface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(binding.Attach);
|
||||
|
||||
Assert.True(binding.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new KeyboardSurface { FailAdd = failAdd };
|
||||
var binding = new RetainedKeyboardInputBinding(
|
||||
surface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(binding.Attach);
|
||||
|
||||
Assert.True(binding.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var root = new UiRoot();
|
||||
int worldClicks = 0;
|
||||
root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++;
|
||||
var binding = new RetainedMouseInputBinding(
|
||||
surface,
|
||||
root,
|
||||
new HostQuiescenceGate());
|
||||
binding.Attach();
|
||||
Action<MouseButton, int, int> copied = surface.Down!;
|
||||
|
||||
copied(MouseButton.Left, 10, 20);
|
||||
binding.Deactivate();
|
||||
copied(MouseButton.Left, 10, 20);
|
||||
binding.Dispose();
|
||||
|
||||
Assert.Equal(1, worldClicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseEventRaisedReentrantlyDuringAttachCannotEnterPartialUiBinding()
|
||||
{
|
||||
var surface = new MouseSurface { RaiseDuringAdd = true };
|
||||
var root = new UiRoot();
|
||||
int worldClicks = 0;
|
||||
root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++;
|
||||
var binding = new RetainedMouseInputBinding(
|
||||
surface,
|
||||
root,
|
||||
new HostQuiescenceGate());
|
||||
|
||||
binding.Attach();
|
||||
Assert.Equal(0, worldClicks);
|
||||
surface.Down!(MouseButton.Left, 1, 2);
|
||||
|
||||
Assert.Equal(1, worldClicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var root = new UiRoot();
|
||||
int worldKeys = 0;
|
||||
root.WorldKeyFallThrough += (_, _) => worldKeys++;
|
||||
var binding = new RetainedKeyboardInputBinding(
|
||||
surface,
|
||||
root,
|
||||
new HostQuiescenceGate());
|
||||
binding.Attach();
|
||||
Action<Key> copied = surface.Down!;
|
||||
|
||||
copied(Key.A);
|
||||
binding.Deactivate();
|
||||
copied(Key.B);
|
||||
binding.Dispose();
|
||||
|
||||
Assert.Equal(1, worldKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedBindingsAreTerminalAfterDisposeBeforeAttach()
|
||||
{
|
||||
var mouseSurface = new MouseSurface();
|
||||
var mouse = new RetainedMouseInputBinding(
|
||||
mouseSurface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
var keyboardSurface = new KeyboardSurface();
|
||||
var keyboard = new RetainedKeyboardInputBinding(
|
||||
keyboardSurface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
|
||||
mouse.Dispose();
|
||||
keyboard.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(mouse.Attach);
|
||||
Assert.Throws<ObjectDisposedException>(keyboard.Attach);
|
||||
mouse.Dispose();
|
||||
keyboard.Dispose();
|
||||
Assert.Equal(0, mouseSurface.LiveEdges);
|
||||
Assert.Equal(0, keyboardSurface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedBindingsRetryOnlyFailedPhysicalEdges()
|
||||
{
|
||||
var mouseSurface = new MouseSurface();
|
||||
var mouse = new RetainedMouseInputBinding(
|
||||
mouseSurface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
var keyboardSurface = new KeyboardSurface();
|
||||
var keyboard = new RetainedKeyboardInputBinding(
|
||||
keyboardSurface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
mouse.Attach();
|
||||
keyboard.Attach();
|
||||
mouseSurface.FailRemoveMoveOnce = true;
|
||||
keyboardSurface.FailRemoveUpOnce = true;
|
||||
|
||||
mouse.Dispose();
|
||||
keyboard.Dispose();
|
||||
int mouseRemoves = mouseSurface.RemoveCalls;
|
||||
int keyboardRemoves = keyboardSurface.RemoveCalls;
|
||||
mouse.Dispose();
|
||||
keyboard.Dispose();
|
||||
|
||||
Assert.Equal(mouseRemoves, mouseSurface.RemoveCalls);
|
||||
Assert.Equal(keyboardRemoves, keyboardSurface.RemoveCalls);
|
||||
Assert.Equal(2, mouseSurface.MoveRemoveCalls);
|
||||
Assert.Equal(2, keyboardSurface.UpRemoveCalls);
|
||||
Assert.True(mouse.IsDisposalComplete);
|
||||
Assert.True(keyboard.IsDisposalComplete);
|
||||
}
|
||||
|
||||
private sealed class MouseSurface : IRetainedMouseSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool RaiseDuringAdd { get; init; }
|
||||
public bool FailRemoveMoveOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int MoveRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public Action<MouseButton, int, int>? Down { get; private set; }
|
||||
private Action<MouseButton, int, int>? _up;
|
||||
private Action<int, int>? _move;
|
||||
private Action<int>? _scroll;
|
||||
|
||||
public void AddMouseDown(Action<MouseButton, int, int> callback) => Add(() => Down = callback);
|
||||
public void AddMouseUp(Action<MouseButton, int, int> callback) => Add(() => _up = callback);
|
||||
public void AddMouseMove(Action<int, int> callback) => Add(() => _move = callback);
|
||||
public void AddScroll(Action<int> callback) => Add(() => _scroll = callback);
|
||||
public void RemoveMouseDown(Action<MouseButton, int, int> callback) => Remove(() => Down = null);
|
||||
public void RemoveMouseUp(Action<MouseButton, int, int> callback) => Remove(() => _up = null);
|
||||
public void RemoveMouseMove(Action<int, int> callback)
|
||||
{
|
||||
MoveRemoveCalls++;
|
||||
if (FailRemoveMoveOnce)
|
||||
{
|
||||
FailRemoveMoveOnce = false;
|
||||
throw new InvalidOperationException("remove move");
|
||||
}
|
||||
Remove(() => _move = null);
|
||||
}
|
||||
public void RemoveScroll(Action<int> callback) => Remove(() => _scroll = null);
|
||||
|
||||
private void Add(Action publish)
|
||||
{
|
||||
AddCalls++;
|
||||
publish();
|
||||
LiveEdges++;
|
||||
if (RaiseDuringAdd)
|
||||
Down?.Invoke(MouseButton.Left, 1, 2);
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
private void Remove(Action clear)
|
||||
{
|
||||
RemoveCalls++;
|
||||
clear();
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class KeyboardSurface : IRetainedKeyboardSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveUpOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int UpRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public Action<Key>? Down { get; private set; }
|
||||
private Action<Key>? _up;
|
||||
private Action<char>? _char;
|
||||
|
||||
public void AddKeyDown(Action<Key> callback) => Add(() => Down = callback);
|
||||
public void AddKeyUp(Action<Key> callback) => Add(() => _up = callback);
|
||||
public void AddKeyChar(Action<char> callback) => Add(() => _char = callback);
|
||||
public void RemoveKeyDown(Action<Key> callback) => Remove(() => Down = null);
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
UpRemoveCalls++;
|
||||
if (FailRemoveUpOnce)
|
||||
{
|
||||
FailRemoveUpOnce = false;
|
||||
throw new InvalidOperationException("remove up");
|
||||
}
|
||||
Remove(() => _up = null);
|
||||
}
|
||||
public void RemoveKeyChar(Action<char> callback) => Remove(() => _char = null);
|
||||
|
||||
private void Add(Action publish)
|
||||
{
|
||||
AddCalls++;
|
||||
publish();
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
private void Remove(Action clear)
|
||||
{
|
||||
RemoveCalls++;
|
||||
clear();
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,10 +39,11 @@ public sealed class LiveObjectFrameControllerTests
|
|||
|
||||
private static SettingsVM CreateSettings()
|
||||
{
|
||||
var dispatcher = new InputDispatcher(
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
new NullKeyboardSource(),
|
||||
new NullMouseSource(),
|
||||
new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
return new SettingsVM(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
|
|
|
|||
|
|
@ -409,13 +409,18 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("wantCaptureMouse: ()", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(2, CountOccurrences(
|
||||
source,
|
||||
"_gameplayInputFrame?.EndMouseLook();"));
|
||||
Assert.Contains(
|
||||
"new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())",
|
||||
Assert.DoesNotContain(
|
||||
"_gameplayInputFrame?.EndMouseLook",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
string pointerSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Input",
|
||||
"CameraPointerInputController.cs"));
|
||||
Assert.Contains("_gameplayFrame?.EndMouseLook();", pointerSource,
|
||||
StringComparison.Ordinal);
|
||||
string teleportSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
|
|
@ -438,8 +443,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
AssertAppearsInOrder(
|
||||
source,
|
||||
"private void OnFocusChanged(bool focused)",
|
||||
"if (!focused)",
|
||||
"_gameplayInputFrame?.EndMouseLook();");
|
||||
"_cameraPointerInput?.HandleFocusChanged(focused);");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -696,7 +700,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
"private void OnUpdate(double dt)",
|
||||
"_frameProfiler.BeginStage(",
|
||||
"_updateFrameOrchestrator.Tick(",
|
||||
"private void OnCameraModeChanged");
|
||||
"private void OnRender(double deltaSeconds)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ public class DispatcherToMovementIntegrationTests
|
|||
var kb = new FakeKb();
|
||||
var mouse = new FakeMouse();
|
||||
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
|
||||
kb.Press(Key.W);
|
||||
|
||||
|
|
@ -122,7 +123,8 @@ public class DispatcherToMovementIntegrationTests
|
|||
var kb = new FakeKb();
|
||||
var mouse = new FakeMouse();
|
||||
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
|
||||
kb.Press(Key.W);
|
||||
// Shift pressed alongside W — real keyboard delivers KeyDown(Shift,
|
||||
|
|
@ -140,7 +142,8 @@ public class DispatcherToMovementIntegrationTests
|
|||
var kb = new FakeKb();
|
||||
var mouse = new FakeMouse();
|
||||
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
|
||||
kb.Press(Key.W);
|
||||
Assert.True(BuildInputFromDispatcher(dispatcher).Forward);
|
||||
|
|
@ -188,7 +191,8 @@ public class DispatcherToMovementIntegrationTests
|
|||
var kb = new FakeKb();
|
||||
var mouse = new FakeMouse();
|
||||
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
|
||||
var input = BuildInputFromDispatcher(dispatcher);
|
||||
Assert.False(input.Forward);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ public class InputDispatcherCaptureTests
|
|||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var bindings = new KeyBindings();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
var fired = new List<(InputAction, ActivationType)>();
|
||||
dispatcher.Fired += (a, t) => fired.Add((a, t));
|
||||
return (dispatcher, kb, mouse, bindings, fired);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ public class InputDispatcherDoubleClickTests
|
|||
bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick));
|
||||
bindings.Add(new Binding(rmbChord, InputAction.SelectRight));
|
||||
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
var fired = new List<(InputAction, ActivationType)>();
|
||||
dispatcher.Fired += (a, t) => fired.Add((a, t));
|
||||
return (dispatcher, mouse, fired);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ public class InputDispatcherIsActionHeldTests
|
|||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var bindings = new KeyBindings();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
return (dispatcher, kb, mouse, bindings);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Input;
|
||||
|
||||
public sealed class InputDispatcherLifetimeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var source = new ThrowingSources { FailAdd = failAdd };
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
source,
|
||||
source,
|
||||
new KeyBindings());
|
||||
|
||||
Assert.ThrowsAny<Exception>(dispatcher.Attach);
|
||||
|
||||
Assert.True(dispatcher.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, source.AddCalls);
|
||||
Assert.Equal(failAdd, source.RemoveCalls);
|
||||
Assert.Equal(0, source.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivateSilencesCopiedSourceDelegateBeforePhysicalDetach()
|
||||
{
|
||||
var source = new ThrowingSources();
|
||||
var bindings = new KeyBindings();
|
||||
bindings.Add(new Binding(
|
||||
new KeyChord(Key.W, ModifierMask.None),
|
||||
InputAction.MovementForward));
|
||||
var dispatcher = InputDispatcher.CreateDetached(source, source, bindings);
|
||||
int fired = 0;
|
||||
dispatcher.Fired += (_, _) => fired++;
|
||||
dispatcher.Attach();
|
||||
Action<Key, ModifierMask> copied = source.KeyDownDelegate!;
|
||||
|
||||
copied(Key.W, ModifierMask.None);
|
||||
dispatcher.Deactivate();
|
||||
copied(Key.W, ModifierMask.None);
|
||||
dispatcher.Dispose();
|
||||
|
||||
Assert.Equal(1, fired);
|
||||
Assert.True(dispatcher.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SourceEventRaisedReentrantlyDuringAttachCannotEnterPartialDispatcher()
|
||||
{
|
||||
var source = new ThrowingSources { RaiseDuringKeyDownAdd = true };
|
||||
var bindings = new KeyBindings();
|
||||
bindings.Add(new Binding(
|
||||
new KeyChord(Key.W, ModifierMask.None),
|
||||
InputAction.MovementForward));
|
||||
var dispatcher = InputDispatcher.CreateDetached(source, source, bindings);
|
||||
int fired = 0;
|
||||
dispatcher.Fired += (_, _) => fired++;
|
||||
|
||||
dispatcher.Attach();
|
||||
Assert.Equal(0, fired);
|
||||
source.KeyDownDelegate!(Key.W, ModifierMask.None);
|
||||
|
||||
Assert.Equal(1, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedDetachRetainsOnlyPendingSourceForRetry()
|
||||
{
|
||||
var source = new ThrowingSources();
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
source,
|
||||
source,
|
||||
new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
source.FailRemoveKeyDown = true;
|
||||
|
||||
Assert.Throws<AggregateException>(dispatcher.Dispose);
|
||||
int completedRemoves = source.RemoveCalls - source.KeyDownRemoveCalls;
|
||||
source.FailRemoveKeyDown = false;
|
||||
dispatcher.Dispose();
|
||||
|
||||
Assert.Equal(completedRemoves, source.RemoveCalls - source.KeyDownRemoveCalls);
|
||||
Assert.True(dispatcher.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeBeforeAttachMakesDispatcherTerminal()
|
||||
{
|
||||
var source = new ThrowingSources();
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
source,
|
||||
source,
|
||||
new KeyBindings());
|
||||
|
||||
dispatcher.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(dispatcher.Attach);
|
||||
dispatcher.Dispose();
|
||||
Assert.Equal(0, source.AddCalls);
|
||||
Assert.Equal(0, source.LiveEdges);
|
||||
}
|
||||
|
||||
private sealed class ThrowingSources : IKeyboardSource, IMouseSource
|
||||
{
|
||||
private Action<Key, ModifierMask>? _keyDown;
|
||||
private Action<Key, ModifierMask>? _keyUp;
|
||||
private Action<MouseButton, ModifierMask>? _mouseDown;
|
||||
private Action<MouseButton, ModifierMask>? _mouseUp;
|
||||
private Action<float>? _scroll;
|
||||
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveKeyDown { get; set; }
|
||||
public bool RaiseDuringKeyDownAdd { get; init; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int KeyDownRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public Action<Key, ModifierMask>? KeyDownDelegate => _keyDown;
|
||||
|
||||
public event Action<Key, ModifierMask>? KeyDown
|
||||
{
|
||||
add
|
||||
{
|
||||
Add(ref _keyDown, value!);
|
||||
if (RaiseDuringKeyDownAdd)
|
||||
value?.Invoke(Key.W, ModifierMask.None);
|
||||
}
|
||||
remove
|
||||
{
|
||||
KeyDownRemoveCalls++;
|
||||
if (FailRemoveKeyDown) throw new InvalidOperationException("remove key down");
|
||||
Remove(ref _keyDown, value!);
|
||||
}
|
||||
}
|
||||
public event Action<Key, ModifierMask>? KeyUp
|
||||
{
|
||||
add => Add(ref _keyUp, value!);
|
||||
remove => Remove(ref _keyUp, value!);
|
||||
}
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown
|
||||
{
|
||||
add => Add(ref _mouseDown, value!);
|
||||
remove => Remove(ref _mouseDown, value!);
|
||||
}
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp
|
||||
{
|
||||
add => Add(ref _mouseUp, value!);
|
||||
remove => Remove(ref _mouseUp, value!);
|
||||
}
|
||||
#pragma warning disable CS0067
|
||||
public event Action<float, float>? MouseMove;
|
||||
#pragma warning restore CS0067
|
||||
public event Action<float>? Scroll
|
||||
{
|
||||
add => Add(ref _scroll, value!);
|
||||
remove => Remove(ref _scroll, value!);
|
||||
}
|
||||
|
||||
public ModifierMask CurrentModifiers => ModifierMask.None;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
public bool IsHeld(Key key) => false;
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
|
||||
private void Add<T>(ref T? slot, T value) where T : Delegate
|
||||
{
|
||||
AddCalls++;
|
||||
slot = (T?)Delegate.Combine(slot, value);
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
|
||||
private void Remove<T>(ref T? slot, T value) where T : Delegate
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (slot is null) return;
|
||||
slot = (T?)Delegate.Remove(slot, value);
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,8 @@ public class InputDispatcherTests
|
|||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var bindings = new KeyBindings();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
var fired = new List<(InputAction, ActivationType)>();
|
||||
dispatcher.Fired += (a, t) => fired.Add((a, t));
|
||||
return (dispatcher, kb, mouse, bindings, fired);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ public sealed class SettingsPanelTests
|
|||
var persisted = new KeyBindings();
|
||||
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
|
||||
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
|
||||
dispatcher.Attach();
|
||||
var vm = new SettingsVM(
|
||||
persisted, dispatcher, _ => { },
|
||||
DisplaySettings.Default, _ => { },
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ public sealed class SettingsVMTests
|
|||
persisted ??= MakeMinimalBindings();
|
||||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
|
||||
dispatcher.Attach();
|
||||
var savedHistory = new System.Collections.Generic.List<KeyBindings>();
|
||||
var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>();
|
||||
var savedAudioHistory = new System.Collections.Generic.List<AudioSettings>();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue