This is the full developer documentation for FloatSoda # FloatSoda ドキュメント > FloatSoda は、SteamVR Overlay を Flutter のような宣言的な書き心地で作成できる .NET 10 / C# 14 向け UI フレームワークです。SkiaSharp → OpenGL (GLFW/OpenTK) → OpenVR という経路でレンダリングします。 **FloatSoda** は、SteamVR Overlay を Flutter のような宣言的な書き心地で作成できる .NET 10 / C# 14 向け UI フレームワークです。SkiaSharp → OpenGL (GLFW/OpenTK) → OpenVR という経路でレンダリングします。 このページはドキュメント全体の入り口です。各ページは相互リンクでつながっています。 ## ページ一覧 | ページ | 内容 | 対象読者 | |---|---|---| | [TargetUsers](/targetusers/) | FloatSoda が想定する3タイプの作り手と読み進め方 | 利用者 | | [GettingStarted](/gettingstarted/) | 環境構築・サンプル実行・最初のアプリ作成 | 利用者 | | [Architecture](/architecture/) | アセンブリ構成・ツリー構造・スレッドモデル | 利用者 / コントリビュータ | | [WidgetSystem](/widgetsystem/) | Widget / Element システムと組み込みウィジェット一覧 | 利用者 | | [UILayering](/uilayering/) | UI層の3層パッケージ構成(ヘッドレス / デザインシステム)。**設計方針であり未提供** | コントリビュータ | | [Animation](/animation/) | AnimationController・Ticker・Curves によるアニメーション | 利用者 / コントリビュータ | | [BuildPipeline](/buildpipeline/) | BuildOwner による差分ビルドとフレームパイプラインの詳細 | コントリビュータ | | [RenderObjects](/renderobjects/) | RenderObject ツリーのリファレンス(レイアウト・描画) | コントリビュータ | | [OVRIntegration](/ovrintegration/) | OpenVR ラッパー・オーバーレイ種別・イベント処理 | 利用者 / コントリビュータ | | [Input](/input/) | アクション入力(コントローラーのボタン・トリガー・スティック) | 利用者 | | [APIDesign](/apidesign/) | API 設計規約(コンポーネント設計・命名・イミュータビリティ) | コントリビュータ | | [DocumentationComments](/documentationcomments/) | ドキュメントコメント規約(適用範囲・契約・副作用の明記) | コントリビュータ | | [Localization](/localization/) | ローカライゼーション方針(日本語デフォルト・resx・サテライトXML) | コントリビュータ | ## どこから読むか 自分がどのタイプの作り手かを [TargetUsers](/targetusers/) で確認すると、最短の読み進め方がわかります。 - **FloatSoda でオーバーレイを作りたい** → [GettingStarted](/gettingstarted/) → [WidgetSystem](/widgetsystem/) → [OVRIntegration](/ovrintegration/) - **フレームワークの内部を理解したい / コントリビュートしたい** → [Architecture](/architecture/) → [BuildPipeline](/buildpipeline/) → [RenderObjects](/renderobjects/) → [APIDesign](/apidesign/) → [DocumentationComments](/documentationcomments/) ## 全体像: 三つのツリー FloatSoda は Flutter の三ツリーモデルを踏襲しています。宣言的な Widget ツリーが Element ツリーを介して RenderObject ツリーを構築・更新し、RenderObject の描画結果がレイヤーツリーとしてレンダースレッドに渡ります。 ```mermaid graph LR subgraph "Widget / Element ツリー" W["Widget
(immutable record)"] E["Element
(mutable / BuildOwner が差分ビルド)"] W -->|CreateElement| E end subgraph "RenderObject ツリー" RV["RenderView (ルート)"] RB["RenderBox サブクラス群"] RV --> RB end subgraph "レイヤーツリー" CL["ContainerLayer"] PL["PictureLayer (SKPicture)"] CL --> PL end E -->|"CreateRenderObject /
UpdateRenderObject"| RV RB -->|"Paint → PaintingContext"| CL ``` - **Widget** — UI の設計図。`abstract record` で不変。フレームごとに再生成しても等値比較で差分検知できます。→ [WidgetSystem](/widgetsystem/) - **Element** — Widget と RenderObject を橋渡しする永続ノード。`BuildOwner` が dirty な Element だけを再ビルドします。→ [BuildPipeline](/buildpipeline/) - **RenderObject** — レイアウト(`PerformLayout`)と描画(`Paint`)を担い、dirty フラグで差分レイアウト・差分ペイントを行います。→ [RenderObjects](/renderobjects/) - **Layer** — 描画結果の合成ツリー。`Clone()` してレンダースレッドへ渡します。→ [Architecture](/architecture/) ## ロードマップ(Phase) 開発は Phase 単位で進めています。Phase は「フレームワークとして何ができる段階か」を表す機能上の到達点で、NuGet のバージョン番号とは対応しません。バージョンはリリースの通し番号として独立に上がり、同じ Phase 中に複数のバージョンが公開されることがあります(バージョン番号から Phase を推定することはできません。`1.0.0` のみ Phase 7 に対応)。各 Phase の詳細スコープは [GitHub マイルストーン](https://github.com/sumx21t-3310/FloatSoda/milestones) を参照してください。 | Phase | 内容 | 状況 | |---|---|---| | Phase 1 | 入力基盤(HitTest / Pointer / Gesture) | 🚧 進行中 | | Phase 2 | basic.dart 相当の表示系ウィジェット網羅(画像・アイコン含む) | 🚧 進行中 | | Phase 3 | スクロールとアニメーションの充実(Tween / 暗黙的アニメーション / 物理シミュレーション) | 未着手 | | Phase 4 | Hooks・テキスト入力・API安定化 | 未着手 | | Phase 5 | Cream / FizzyPop デザインシステム完成 | 未着手 | | Phase 6 | DX 向上(Storybook・manifest 自動生成・ライフサイクル) | 未着手 | | Phase 7 | 安定版リリース(1.0) | 未着手 | > ⚠️ **ユーザー操作が動くのは、いまのところダッシュボードオーバーレイだけです。** > ヒットテストとジェスチャ認識は実装済みで、`GestureDetector` でタップとパンを受け取れます。 > ただしポインタ座標の供給元(SteamVR のレーザーポインター)がダッシュボードオーバーレイにしか > 接続されていないため、`WorldSpaceWindow` と `DeviceTrackedWindow` は表示専用です。 > また、**UI3層構成(`FloatSoda.UI` と `Cream` / `FizzyPop`)はまだ提供していません。** > 3プロジェクトとも NuGet 未配布で、`Button` は骨組みだけで押下に反応しません(Phase 5 の予定)。 > ボタンは `GestureDetector` で組み立ててください > (→ [WidgetSystem](/widgetsystem/#押せるボタンを作る))。 進行中の2つの Phase に残っている主な作業です。 | Phase | 残件 | |---|---| | Phase 1 | 非ダッシュボードオーバーレイへのポインタ接続(コントローラーレイ経路) | | Phase 2 | `ImageProvider` の拡充、`CustomPaint`、`ViewMetrics`(`MediaQuery` 相当) | ## 実装状況サマリ 現在 Alpha 段階(Phase 1 と Phase 2 が並行して進行中)です。主要コンポーネントの実装状況は以下のとおりです。詳細は各ページの実装状況欄を参照してください。 状況欄の記号は次の意味で使っています。 | 記号 | 意味 | |---|---| | ✓ 実装済み | 公開 API として使える | | △ 部分実装 | 使えるが、機能の一部が未完成 | | ✗ 未実装 | 公開 API からは使えない(型が `internal`、または未接続) | | 予定 | 設計は決まっているが、使える形では提供していない | | 領域 | 状況 | |---|---| | RenderObject ツリー(レイアウト・描画・クリップ・差分更新) | ✓ 実装済み | | レイヤーツリーとレンダースレッド分離 | ✓ 実装済み | | 複数オーバーレイ(ダッシュボード / ワールド座標 / デバイス追従) | ✓ 実装済み | | `StatelessWidget` / `StatelessElement` | ✓ 実装済み | | `BuildOwner` による差分ビルド(dirty list / BuildScope) | ✓ 実装済み | | `SingleChildRenderObjectWidget` 系の更新(`UpdateRenderObject`) | ✓ 実装済み | | `MultiChildRenderObjectElement` の再ビルド(子リストの差分) | ✓ 実装済み(`Key` 対応の両端差分) | | `StatefulWidget` / `StatefulElement`(`SetState` 再ビルド) | ✓ 実装済み | | `InheritedWidget` / `InheritedElement`(依存追跡・通知) | ✓ 実装済み | | `ParentDataWidget`(`Expanded` / `Flexible` / `Positioned` の基盤) | ✓ 実装済み | | `Key` による Element 再利用(`Widget.CanUpdate` = 型 + Key) | ✓ 実装済み | | アニメーション(`AnimationController` / `Ticker` / `Curve`・`Curves` / `FadeTransition`) | ✓ 実装済み | | テキスト表示(`Text` / `RichText` / `TextSpan` / `TextStyle` / `DefaultTextStyle`) | ✓ 実装済み | | 画像表示(描画系の `Paint.Image` + `FileImageProvider`) | ✓ 実装済み | | アイコン表示(描画系の `Paint.Icon` + `IconData` / `FontProvider`) | ✓ 実装済み | | レイアウト系ウィジェット(`Padding` / `Stack` / `Wrap` / `Expanded` / `AspectRatio` ほか) | ✓ 実装済み(→ [WidgetSystem](/widgetsystem/#組み込みウィジェット一覧)) | | 描画系ウィジェット(`DecoratedBox` / `Opacity` / `Transform` / `Clip*` / `RepaintBoundary`) | ✓ 実装済み | | intrinsic 測定(`IntrinsicWidth` / `IntrinsicHeight`) | ✓ 実装済み | | ヒットテスト(座標 → RenderObject の特定) | ✓ 実装済み | | ジェスチャ認識(`GestureDetector` / `Listener` / タップ・パン) | ✓ 実装済み | | ポインタ入力源(SteamVR レーザーポインターの接続) | △ 部分実装(ダッシュボードオーバーレイのみ) | | `Container` | ✓ 実装済み(`Padding` の合成を含む) | | UI3層構成(`FloatSoda.UI` ヘッドレス / `Cream` / `FizzyPop`) | 予定(Phase 5)。NuGet 未配布・骨組みのみ(→ [UILayering](/uilayering/#実装状況)) | | Hooks(`FloatSoda.Hooks` / R3 ベースの `UseState`) | △ 部分実装(フレームワーク未統合) | | スクロール(`ListView` / `GridView` / `SingleChildScrollView`) | ✗ 未実装(`internal`) | ## リポジトリ構成 | プロジェクト | 役割 | |---|---| | `src/FloatSoda.Abstractions` | Engine境界契約、共有値型、入力イベント、フレームペーシング | | `src/FloatSoda.Rendering` | Layerツリー、共通Layer描画、Bitmap描画 | | `src/FloatSoda.Engine` | GLFW/OpenGL・レンダースレッド・フレームリミッタ | | `src/FloatSoda.OVR` | OpenVR ラッパー・オーバーレイ型・イベントディスパッチャ | | `src/FloatSoda` | フレームワーク本体(Widget / Element / RenderObject / パイプライン) | | `src/FloatSoda.Testing` | Widget・RenderObjectのヘッドレスBitmap描画 | | `src/FloatSoda.UI` | ヘッドレスUI層(振る舞いのみ、見た目なし)。Phase 5 の予定 → [UILayering](/uilayering/) | | `src/FloatSoda.UI.Cream` | デザインシステム①(レトロ・クリーミー・フラット)。Phase 5 の予定 | | `src/FloatSoda.UI.FizzyPop` | デザインシステム②(透明感・グラスモーフィズム)。Phase 5 の予定 | | `src/FloatSoda.Hooks` | R3 ベースのフックAPI(部分実装) | | `samples/` | サンプルアプリ(SteamVR 必須) | | `tests/` | xunit テスト | # アニメーション > AnimationController・Ticker・Curves によるアニメーション > **実装状況:** > - **実装済み:** `AnimationController`(Forward / Reverse / Stop / AnimateWith)、`WidgetTicker` / `ITickerProvider` / `TickerProviderState`、`Curve` / `Curves`(標準イージング一式)、`InterpolationSimulation`、`FadeTransition`(`RenderAnimatedOpacity` 経由のペイントのみ更新)が動作します。 > - **未実装:** `Tween` / `CurvedAnimation` などのアニメーション合成、`AnimatedContainer` 系の暗黙的アニメーション、スプリング等の物理シミュレーションは未実装です(`ISimulation` を実装すれば `AnimateWith` で駆動は可能)。 FloatSoda のアニメーションは Flutter のアニメーション基盤を踏襲しています。**Ticker がフレームごとに経過時間を供給し、AnimationController がそれを 0.0〜1.0 の値に変換し、値の変化を購読したものだけが再描画される**という構造です。`SetState()` によるリビルドを介さずにペイントだけを更新できるため、毎フレームのアニメーションでも Widget ツリーの再ビルドコストがかかりません。 ## 全体像 ``` WidgetBinding (IFrameScheduler) │ ScheduleFrameCallback / フレームごとのタイムスタンプ ▼ WidgetTicker ← 開始からの相対時間を通知 │ onTick(elapsed) ▼ AnimationController : IAnimation │ ISimulation.X(t) で値を計算(Curve 適用) │ Changed / StatusChanged イベント ▼ FadeTransition → RenderAnimatedOpacity └─ Changed 購読 → MarkNeedsPaint() → 再ペイントのみ ``` | クラス | 役割 | |---|---| | `IFrameScheduler` | フレームコールバックの登録・キャンセル。通常は `WidgetBinding` が実装(テストでは Fake に差し替え可能) | | `WidgetTicker` | フレームごとに「開始からの相対時間」を通知。Flutter の `Ticker` 相当 | | `ITickerProvider` / `TickerProvider` | Ticker の生成・追跡。`AnimationController.Vsync` に渡す | | `TickerProviderState` | `ITickerProvider` を提供する `State` 基底。Flutter の `TickerProviderStateMixin` 相当 | | `IAnimation` | 時間経過で変化する値。`Value` / `Status` / `Changed` / `StatusChanged` を公開。Flutter の `Animation` 相当 | | `AnimationController` | `IAnimation` の駆動役。Forward / Reverse / Stop / AnimateWith | | `ISimulation` | 時間→値の関数。標準実装は `InterpolationSimulation`(begin→end を Duration と Curve で補間) | | `ICurve` / `Curve` / `Curves` | イージング曲線。`Curves` に標準インスタンス一式 | ## AnimationController `IAnimation` の標準実装で、`LowerBound`(既定 0.0)〜 `UpperBound`(既定 1.0)の間を `Duration` かけて往復させます。 ```csharp var controller = new AnimationController { Vsync = this, // TickerProviderState を継承した State Duration = TimeSpan.FromSeconds(1.5), Curve = Curves.EaseInOut, // 省略時は Linear }; controller.Forward(); // UpperBound へ再生 controller.Reverse(); // LowerBound へ再生 controller.Forward(from: 0.5); // 指定値から再生(残り割合で Duration をスケール) controller.Stop(); // 停止(Value は現在値のまま) ``` ### AnimationStatus `Status` は値がどちら側にあるか・どちらへ進行中かを表します。 | Status | 意味 | |---|---| | `Dismissed` | 停止・先頭(`LowerBound`) | | `Forward` | 順方向に進行中 | | `Reverse` | 逆方向に進行中 | | `Completed` | 停止・末尾(`UpperBound`) | `StatusChanged` を購読すると完了・折り返しを検知できます。往復アニメーションは `Completed` で `Reverse()`、`Dismissed` で `Forward()` を呼ぶのが定石です(`samples/FloatSoda.Samples.OverlayApp/PulseWidget.cs` 参照)。 ### AnimateWith(カスタムシミュレーション) `Forward` / `Reverse` は内部で `InterpolationSimulation` を使いますが、`AnimateWith(ISimulation)` に任意の `ISimulation` 実装を渡せば、スプリングなど時間関数が非線形なアニメーションも駆動できます。 ## Ticker と TickerProvider `AnimationController` は自分では時計を持ちません。`Vsync` に渡された `ITickerProvider` から `WidgetTicker` を生成し、フレームコールバック(`IFrameScheduler`、通常は `WidgetBinding`)経由でタイムスタンプを受け取ります。 `State` でコントローラを使う場合は `TickerProviderState` を継承するのが最も簡単です: ```csharp public record PulseState : TickerProviderState { private AnimationController? _opacity; public override void InitState() { _opacity = new AnimationController { Vsync = this, Duration = TimeSpan.FromSeconds(1.5), }; _opacity.Forward(); } } ``` `WidgetTicker` は `Muted = true` で(経過時間の基準を保ったまま)一時停止できます。`Dispose()` で Provider の追跡から外れます。 ## Curve と Curves `ICurve.Transform(t)` は正規化された時刻 t∈[0,1] を写像します。抽象基底 `Curve` は Flutter と同じ契約(端点 t==0 / t==1 はそのまま返し、間だけ `TransformInternal` に委譲)を保証し、`Flipped` で時間・値軸を反転したカーブを得られます。 ### カーブ型 | 型 | 説明 | |---|---| | `LinearCurve` | 恒等写像 | | `Cubic(a, b, c, d)` | 3次ベジェ。CSS の `cubic-bezier` 相当 | | `ThreePointCubic` | 2つの `Cubic` を中点で連結(強調イージング用) | | `SawTooth(count)` | 0→1 を count 回繰り返すノコギリ波 | | `Interval(begin, end, curve)` | 指定区間だけ curve を適用、区間外はクランプ | | `Threshold(t)` | t 未満は 0、以上は 1 のステップ | | `FlippedCurve(curve)` | `1 - curve(1 - t)` | | `DecelerateCurve` | 放物線状の減速 | | `ElasticIn/Out/InOutCurve(period)` | ゴムひものような弾性振動 | | `BounceIn/Out/InOutCurve` | ボールが跳ねるようなバウンス | ### 標準インスタンス(`Curves`) Flutter の `Curves` と同じ係数で、命名は C# 規約(PascalCase)です。 | グループ | メンバ | |---|---| | 基本 | `Linear`, `Decelerate`, `FastOutSlowIn`, `SlowMiddle` | | Ease | `Ease`, `EaseIn`, `EaseOut`, `EaseInOut`, `EaseInToLinear`, `LinearToEaseOut` | | Ease バリエーション | `EaseIn/Out/InOut` × `Sine` / `Quad` / `Cubic` / `Quart` / `Quint` / `Expo` / `Circ` / `Back`(例: `EaseInOutCubic`) | | 強調・複合 | `EaseInOutCubicEmphasized`, `FastLinearToSlowEaseIn`, `FastEaseInToSlowEaseOut` | | バウンス | `BounceIn`, `BounceOut`, `BounceInOut` | | 弾性 | `ElasticIn`, `ElasticOut`, `ElasticInOut` | `Back` 系と `Elastic` 系は 0〜1 の範囲を行き過ぎる(オーバーシュートする)値を返します。`AnimationController` は `LowerBound`〜`UpperBound` で値をクランプするため、オーバーシュートをそのまま使いたい場合は注意してください。 ## FadeTransition と RenderAnimatedOpacity `FadeTransition` は `IAnimation` で子の不透明度を駆動するウィジェットです。 ```csharp new FadeTransition { Opacity = _controller, // IAnimation Child = ..., } ``` ポイントは更新経路です。`RenderAnimatedOpacity` が `Changed` を購読し、値が変わったフレームだけ `MarkNeedsPaint()` を呼びます([RenderObjects](/renderobjects/) の差分更新参照)。つまり: - **Widget のリビルドは発生しない** — `SetState()` は不要 - **レイアウトも走らない** — 再ペイントのみ(`OpacityLayer` の差し替え) - 不透明度が 0 のフレームは子のペイント自体をスキップ 毎フレーム値が変わるアニメーションで `SetState()` を使うとフレームごとに Widget ツリーの差分ビルドが走るため、アニメーション値は `*Transition` 系ウィジェット(現状は `FadeTransition`)で RenderObject に直結させるのが推奨パターンです。 ## テストでの駆動 `IFrameScheduler` を Fake に差し替えると、実時間なしでアニメーションを進められます(`tests/FloatSoda.Test/Animation/` の `FakeFrameScheduler` 参照): ```csharp var scheduler = new FakeFrameScheduler(); var provider = new TickerProvider { ResolveScheduler = () => scheduler }; var controller = new AnimationController { Vsync = provider, Duration = TimeSpan.FromSeconds(1) }; controller.Forward(); scheduler.Pump(TimeSpan.Zero); // 基準点 scheduler.Pump(TimeSpan.FromSeconds(0.5)); // Value == 0.5 ``` ## 関連ページ - [WidgetSystem](/widgetsystem/) — `StatefulWidget` / `State` のライフサイクル - [BuildPipeline](/buildpipeline/) — フレームパイプラインと `WidgetBinding` - [RenderObjects](/renderobjects/) — `MarkNeedsPaint` による差分ペイント - [APIDesign](/apidesign/) — `Changed` / `StatusChanged` などイベント通知の規約 # API Design Guidelines > API 設計規約(コンポーネント設計・命名・イミュータビリティ) このドキュメントは、本フレームワークのコンポーネントAPIを設計・実装する際の規約とベストプラクティスをまとめたものです。 ## 1. 基本設計哲学 本フレームワークはFlutterのウィジェットツリーモデルを参考にしており、**宣言的UI**・**イミュータブルな構成**・**ツリー構造による合成**を設計の中心に置きます。 C# のオブジェクト初期化子構文を活用することで、マークアップに近い読みやすいUIコードを実現します。 ### 判断原則: 概念とツリーの語彙は Flutter に、その表現手段は C# に従う Flutter を参考にする範囲と、C# の慣習を優先する範囲を次のように使い分けます。 - **UIドメインの語彙の層** — Flutter に準拠します。ウィジェット名・レイアウト用語(`MainAxisAlignment`, `CrossAxisAlignment`)・`Child` / `Children` などの木構造の語彙は Flutter の名前をそのまま使います。Flutter を参照する利用者・コード生成AIが自然に書く形をそのまま正解にするためです。 - **言語機構・実装パターンの層** — C# として自然な API や慣習があればそちらを優先します。Dart の言語制約に由来する実装パターン(例: `addListener` / `removeListener`)は直訳せず、C# の言語機構(`event`, `init`, `required`, `record struct`, `extension` プロパティ等)で同じ意図を表現します。 本ドキュメントの個別ルールの多くはこの原則の適用例です: リスナーパターンではなく `event`(セクション3.6)、`is null` パターン(3.5)、ジオメトリの `record struct`(8)、単位リテラルの拡張プロパティ(7.5)、`init` / `required` によるイミュータビリティと必須値の表現(4・5)。 ただし C# の一般慣習が常に勝つわけではありません。フレームワークの読みやすさのために意図的に逸脱する場合は、逸脱の理由を明記します(例: 兄弟ウィジェットの同一ファイル配置は StyleCop SA1402 から意図的に外れる — セクション2.5)。 ### 判断原則: .NET が標準で提供する機構を再実装しない 一つ前の原則の後半(表現手段は C# に従う)を、判定可能な手順にしたものです。「C# として自然か」は主観に流れやすいため、次の順で判断します。 1. その機構と **同等のもの** を .NET が標準で提供していないか確認する 2. あれば、それを使う。Flutter 側に対応する独自実装があっても移植しない 3. なければ自前で実装し、**なぜ同等でないのか** をドキュメントコメントに残す ここでの「標準」は厳密な BCL に限らず、.NET が標準として提供する機構(`Microsoft.Extensions.*` を含む)を指します。 適用例: | Flutter / 独自実装に流れがちな機構 | .NET の同等機構 | 判断 | |---|---|---| | `addListener` / `removeListener` | `event` | 移植しない(セクション3.6) | | `operator==` / `hashCode` の手書き | `record` / `record struct` の値等価性 | 実装しない(セクション8・9) | | HTTP のリトライ・レート制限 | `Microsoft.Extensions.Http.Resilience`(`DelegatingHandler` として組み込む) | 実装しない。**設定済みの `HttpClient` を呼び出し元から受け取り、ポリシーは呼び出し元の責任とする** | | HTTP の同時接続数制御 | `SocketsHttpHandler.MaxConnectionsPerServer` | 実装しない | | 処理を直列化するための専用スレッド | `Channel` と単一の reader ループ | 立てない。専用スレッドはスレッド親和性が要求される場合(GL コンテキスト等)に限る | 最後の行で `Channel` と reader ループの組み合わせだけを挙げているのは、**「投入順に1件ずつ」まで満たせる標準機構が他にないため**です。似て見えるものは、保証している契約が違います。 | 機構 | 保証すること | 保証しないこと | |---|---|---| | `Channel` と単一の reader ループ | 投入順での取り出し。ループが各要素の処理を `await` し切ってから次を読むことで1件ずつになる | — | | `Task` とスレッドプール | — | 排他実行も順序も保証しない | | `SemaphoreSlim(1, 1)` | 排他実行 | **待機の順序**。先に待った側が先に通るとは限らない | | `ConcurrentExclusiveSchedulerPair.ExclusiveScheduler` | 同期的なタスク本体どうしの排他 | **`await` をまたぐ排他**(継続は別タスクとして再スケジュールされる)と順序 | `Channel` が保証するのは**投入順での取り出し**までで、1件ずつの処理は reader 側の書き方で決まります。`SingleReader = true` は「呼び出し側が単一リーダーを守る」ことを前提にした最適化指示であり、複数リーダーや並行処理を禁止しません。**単一の reader ループが各要素の処理を `await` し切ってから次を読む**、という形にして初めて直列化が成立します。producer が複数ある場合、ここでの「投入順」は enqueue が成立した順を指します。 `IOTaskRunner` のように「投入順に1件ずつ」を契約しているものを、排他だけを保証する機構へ置き換えると順序逆転で壊れます。**要件が排他だけなのか、順序まで含むのかを先に確定させてください。** #### 「同等」の判定 この原則の負荷は「同等」の一語にかかります。**形が似ているだけで契約が違うものを同等と見なすと、逆向きに壊れます。** 同等かどうかは API の形ではなく、**満たすべき契約が一致するか** で判定します。 非同等と判定した例: 画像キャッシュに `Microsoft.Extensions.Caching.Memory` を使えるか。LRU と容量上限は賄えますが、**「参照中のエントリは容量に関係なく退避しない」という動的な契約**を表現できません。 近いものに `CacheItemPriority.NeverRemove` がありますが、これは**エントリ登録時に決まる静的な優先度**です。必要なのは「貸出が1つ以上ある間だけ退避を禁止し、最後の貸出が返された時点で通常の退避対象へ戻す」という、参照カウントに追従して切り替わる契約です。`NeverRemove` と既定値を出し入れして模倣することは理屈のうえでは可能ですが、切り替えの間に退避が走る隙間が残るため、契約を満たしません。 したがって同等ではなく、自前で実装したうえで理由を残します。 #### 依存関係の増減とは独立 この原則は「依存を増やさない」という意味ではありません。標準機構を使うために依存が一つ増えることも、標準機構で足りるために既存の依存を落とせることも、どちらも正しい結果です。判断の対象は依存の数ではなく、**同じものを二度実装していないか** です。 ### 判断原則: Flutter 由来の observable behavior に差異を作らない 最初の原則の前半(語彙は Flutter に従う)を、**挙動**まで広げたものです。 **Flutter 由来の Widget / RenderObject は、明示された FloatSoda 固有の理由がない限り、Flutter との observable behavior の差異を作りません。** FloatSoda の docs は Flutter の語彙で概念を教えます。読者(とコード生成 AI)は Flutter の挙動を期待して来るため、**差異がバグでも意図的な設計判断でも、利用者が払うコストは同じ**です。 少なくとも次は parity の対象です。 - property semantics - default values - layout - paint / clipping - hit testing - child handling - Widget update behavior - invalid / degenerate input handling - dirty layout / paint conditions - Element / state lifecycle semantics #### Flutter-derived の判定 この原則の適用開始点、つまり「何が Flutter 由来か」の判定基準です。 - Flutter に対応する public concept / Widget / RenderObject / lifecycle semantics が存在し、それを FloatSoda へ持ち込むものは **Flutter-derived** として扱い、この原則を適用します。 - Flutter の型やアルゴリズムを内部実装として利用していても、FloatSoda 独自の利用者向け概念・API であれば、その API 自体は **FloatSoda 固有**として扱います(例: オーバーレイ種別)。 - Flutter-derived かどうか判断が割れる場合は、実装開始前に Issue 上で正典(対応する Flutter API か、FloatSoda 固有か)を明示します。 #### 理由にならないもの **「実装しやすい」「こちらの方が安全」「こちらの方が自然」といった理由だけで独自仕様にしないでください。** これらは差異を正当化しません。Flutter がその挙動を選んだ背景(多くは実際のアプリで踏まれた事故)を、FloatSoda が再発見する必要はありません。 #### behavioral difference とみなさないもの C#/.NET として自然な表現への置換は、差異ではありません。 - `event` - `init` / `required` - `record` / `record struct` - .NET 標準機構の利用 これは一つ前の2つの原則(表現手段は C# に従う/標準機構を再実装しない)の裏返しです。判断に迷ったら、**利用者から見た挙動が変わるか**で切り分けてください。`addListener` を `event` に置き換えても、購読と解除という観測可能な挙動は同じです。 #### 既に決めた非移植方針を壊さない FloatSoda のランタイムや対象ユーザーに存在しない機能について、**既に明示された非移植方針を parity を理由に持ち込まないでください。** 特に Semantics(次節)との整合性を確認してください。「Flutter にあるから」は、次節の判断を覆す理由になりません。 #### 差異が必要な場合の記録義務 差異が必要と判断した場合は、**次の5点を恒久的に記録します**。 1. **Flutter の挙動** 2. **FloatSoda の挙動** 3. **差異が必要な理由** 4. **差異を固定するテスト**(ファイルとテストメソッド名) 5. **利用者に影響する場合のドキュメント** 記録先は [`known-divergences.md`](https://github.com/sumx21t-3310/FloatSoda/blob/main/.agents/skills/floatsoda-device-test/references/known-divergences.md) です。ここが **FloatSoda ↔ Flutter の確認済み差異の台帳の正典**で、本ドキュメントは判断原則だけを持ちます。 5 について、利用者から見える差異は台帳だけでは足りません。該当する `docs/` のページと、対応するサンプルの `## Flutterとの違い` 節(→ [CONTRIBUTING.md](https://github.com/sumx21t-3310/FloatSoda/blob/main/CONTRIBUTING.md))にも書いてください。台帳はコントリビュータ向け、docs とサンプルは利用者向けです。 4 が無い差異は、次の移植で気づかずに戻されます。**テストで固定されていない差異は、記録されていないのと同じ**と考えてください。 #### 参照の追跡可能性 Flutter を参照して移植・修正する場合、可能な範囲で次を **PR から追跡可能にしてください**(推奨)。PR テンプレートの `## Flutter reference` 欄がこれにあたります。 - Flutter version または commit - 参照した Flutter source - 参照した Flutter tests - 関連する Flutter API documentation ローカル参照クローンは `~/code_reading/flutter_reference` です。対応する Widget / Element / RenderObject の特定には `flutter-widget-source` skill が使えます。 #### 仕様が食い違ったときの優先順位 既存実装を無条件に正しいものとして扱わないでください。**上から順に**確認します。 1. **FloatSoda で明示的に定義された差異・設計判断** — 本ドキュメント、台帳、`docs/` の明記 2. **Flutter の仕様・実装・公式テスト** — 1 に該当する記述が無ければ、Flutter が正典 3. **既存の FloatSoda 実装は根拠になりません** — そう実装されていることは、それが正しいことを意味しません **古い Issue や既存実装だけを根拠に、新しい挙動を決めないでください。** Issue が書かれた時点の前提が今も成り立つかを確認します。 ### 判断原則: 対象ユーザー・ランタイムに不要な Flutter API は移植しない Flutter の API がすべて FloatSoda に必要なわけではありません。対象ユーザー(→ [TargetUsers](/targetusers/))の需要がなく、かつ FloatSoda のランタイム(Skia → OpenGL → OpenVR オーバーレイテクスチャ)に受け皿がない機能は、Flutter 由来のコードを移植する際にフックだけ先に置くことをせず、削除します。 #### 実装しない API — Semantics (アクセシビリティツリー) Flutter の `SemanticsNode` / `SemanticsConfiguration` / `PipelineOwner.semanticsOwner` に相当する **並行ツリー** は FloatSoda では実装しません。 理由: - **出力先に経路がない** — VR オーバーレイテクスチャは OS ネイティブのアクセシビリティ API (Windows Narrator / UI Automation / macOS VoiceOver 等) に接続する自然なフックを持たない。SteamVR / OpenVR にもスクリーンリーダー統合の仕組みは存在しない。 - **ターゲットユーザーに需要がない** — FloatSoda の 3 ペルソナ(バイブコーディング VRChatter / Booth 創作者 / uGUI 回避エンジニア)にアクセシビリティ需要は乗っていない。 - **部分導入は無意味** — `markNeedsSemanticsUpdate()` だけ実装しても `SemanticsNode` ツリーと `SemanticsOwner` が無ければ実質 no-op になる。逆に本気で入れるなら Semantics ツリー全体の設計が必要で、その時点で改めて設計し直す方が自然。 したがって Flutter 本家から移植するときは、以下のような API・プロパティは **持ち込まない**: - `RenderObject.markNeedsSemanticsUpdate()` - `describeSemanticsConfiguration(SemanticsConfiguration config)` - `SemanticsConfiguration` / `SemanticsNode` 系型 - ウィジェット側の `ignoringSemantics` / `excludeSemantics` / `semanticContainer` などのフラグ 将来 VR 空間内でアクセシビリティ情報を表現する自然な経路が見つかった時点で、個別に設計します。それまでは Flutter 側コードの semantics 関連呼び出しは移植せず削除する、を規約とします。 ```csharp // 推奨: オブジェクト初期化子によるツリー構造 var ui = new Column { Children = [ new Text("Hello, World!"), new FloatSoda.UI.Cream.Button { Child = new Text("Click me"), OnPressed = HandleClick, Style = new FloatSoda.UI.Cream.ButtonStyle { BackgroundColor = SKColors.CornflowerBlue } } ] }; ``` ## 2. コンポーネントAPI設計ガイドライン ### 2.1 オブジェクト初期化子ファーストの原則 すべてのコンポーネントは、**コンストラクタ引数を使わず**オブジェクト初期化子だけで完全に構成できるよう設計します。 ```csharp // ✅ 良い例: 初期化子のみで完結 var card = new Card { Title = "タイトル", Body = new Text("本文"), Elevation = 4 }; // ❌ 避ける: 複雑なコンストラクタ引数 var card = new Card("タイトル", new Text("本文"), 4); ``` **理由:** - コードがツリー構造として視覚的に読める - 引数の順序を覚える必要がない - 将来的なプロパティ追加時に後方互換性を保ちやすい #### 例外: 末端ウィジェットの単一値コンストラクタ **末端ウィジェット**に限り、主たる値1つを取るコンストラクタを容認します。末端ウィジェットとは、次の**両方**を満たすものです。 1. **ウィジェットを引数(`Child` / `Children`)に取らない** — 合成ではなく、それ自体が葉になる 2. **データそのものが表示対象** — 文字列・グリフ・画像・数値など「表示される値」を持つ(レイアウト指示の値は該当しない) ```csharp // ✅ 容認: Text はデータそのものが表示対象で、子を取らない new Text("Hello, World!") ``` **現在の該当例:** `Text(string)`。将来 `Icon` や `ProgressBar` などの末端ウィジェットを追加するときも、この基準を適用します。 **該当しない例:** `Padding` / `Container`(ウィジェットを取る)、`Spacer`(`size` はレイアウト指示でデータではない)。 **付帯ルール:** - コンストラクタ引数は**主たる値1つのみ**。スタイルやオプションは従来どおり `init` プロパティで受ける - コンストラクタを持つ末端ウィジェットは、その引数以外に `required` メンバーを**持たない**。追加のスタイルやオプションが必要なら `init` プロパティで受ける - ソースが曖昧なもの(`Image` のパスかプロバイダか等)はコンストラクタを増やさず、型付きのプロバイダで受ける(現行 API は `new Image { Provider = new FileImageProvider(path) }`。`FileImageProvider` は `FloatSoda.Core.Providers` 名前空間)。将来ショートカットを追加する場合は、[セクション7](#7-ファクトリメソッドの方針)に従って静的ファクトリにする **理由:** - `Text` の本体は文字列そのもの、`Icon` はグリフそのものであり、位置引数の意味が曖昧になりようがない。初期化子ファースト規約が守ろうとしている「引数の意味の自明性」を壊さない。 - 末端ウィジェットは `Children` の深部に大量に現れるため、`new Text("OK")` と書けることの視覚的ノイズ削減効果が最も大きい。 - Flutter が末端ウィジェットを `Text('hello')` / `Icon(Icons.add)` の形で提供しているため、Flutter を参照する利用者・コード生成AIが自然に書く形をそのまま正解にできる。 ### 2.2 子要素の表現 単一の子を持つコンポーネントは `Child` プロパティ、複数の子を持つ場合は `Children` プロパティ(`IList` 型)を使用します。 ```csharp // 単一の子(SizedBox) new SizedBox { Width = 240, Height = 80, Child = new Text("固定サイズ内のテキスト") } // 複数の子 new Row { MainAxisAlignment = MainAxisAlignment.SpaceBetween, Children = [ new Text("ホーム"), new Text("設定") ] } ``` ### 2.3 ネストの深さと可読性 ツリーが深くなる場合は、**ローカル変数への分割**を推奨します。 ```csharp // ✅ 推奨: 変数に切り出してフラット化 var avatarPlaceholder = new SizedBox { Width = 40, Height = 40 }; var nameLabel = new Text(user.Name); var emailLabel = new Text(user.Email); var userInfo = new Column { Children = [nameLabel, emailLabel] }; var tile = new Row { Children = [avatarPlaceholder, new SizedBox { Width = 12 }, userInfo] }; ``` ### 2.4 スタイルの分離 視覚的な属性はコンポーネント本体ではなく、専用の `*Style` クラスに分離します。`*Style` レコードや `Button` などのスタイル付きコンポーネントはデザインシステム層(`FloatSoda.UI.Cream` / `FloatSoda.UI.FizzyPop`)に属します(→ [UILayering](/uilayering/))。 ```csharp new FloatSoda.UI.Cream.Button { Child = new Text("送信"), OnPressed = OnSubmit, Style = new FloatSoda.UI.Cream.ButtonStyle { BackgroundColor = SKColors.CornflowerBlue, PressedBackgroundColor = SKColors.RoyalBlue, DisabledBackgroundColor = SKColors.LightGray } } ``` ### 2.5 ファイル構成 — 兄弟ウィジェットは同じファイルに置く 密接に関連するウィジェット群(**兄弟ウィジェット**)は、1型1ファイルに分割せず同一ファイルにまとめます。 ```csharp // Flex.cs — Flex とその薄い特殊化をまとめて定義 public sealed record Flex : MultiChildRenderObjectWidget { /* ... */ } public abstract record FlexWrapper(Axis Direction) : StatelessWidget { /* ... */ } public sealed record Column() : FlexWrapper(Axis.Vertical); public sealed record Row() : FlexWrapper(Axis.Horizontal); ``` **兄弟と見なす基準(いずれかを満たす場合):** 1. 同一ファイル内の共通基底の薄い特殊化である(`Column` / `Row` → `FlexWrapper`) 2. 単独では意味をなさず、必ず対で使う(`Stack` + `Positioned` のような関係) 3. 同じ実装詳細(private ヘルパーや共通の RenderObject)を共有する **兄弟と見なさない:** 単に同じカテゴリ・同じフォルダに属するというだけの関係。`Padding` と `SizedBox` はどちらも Layout 配下ですが、独立したファイルに分けます。 **ファイル名の規則:** | 状況 | ファイル名 | 例 | |---|---|---| | 基底・代表となるウィジェットがある | 代表のウィジェット名 | `Flex.cs`, `Text.cs`, `Align.cs` | | 対等なグループで代表が決めがたい | グループの概念名 | `Clip.cs`(`ClipOval` / `ClipRect` / `ClipRoundRect` / `ClipCustomPath`) | **理由:** - `Column` / `Row` のような数行のレコードを個別ファイルに分けるより、基底と並べて読めるほうが設計意図(薄いラッパーであること)が伝わる。Flutter の `basic.dart` が関連ウィジェットをまとめているのと同じ発想。 - C# の一般慣習「1型1ファイル」(StyleCop SA1402)からは意図的に逸脱する。本フレームワークのウィジェットは小さな `record` が多く、機械的な分割はファイル数だけを増やして見通しを悪化させるため。 ## 3. プロパティ命名規則 | 種別 | 規則 | 例 | |---|---|---| | コンテンツ系 | 意味のある名詞 | `Content`, `Label`, `Title`, `ImageUrl` | | 子要素 | `Child` / `Children` | `Child`, `Children` | | イベントハンドラ | `On` + 動詞 (PascalCase) | `OnPressed`, `OnChanged`, `OnSubmit` | | ブール型フラグ | `Is` / `Has` / `Can` プレフィックス | `IsEnabled`, `HasBorder`, `IsVisible` | | スタイル | `Style` サフィックス | `TextStyle`, `ButtonStyle` | | レイアウト | Flutterに準拠した名前 | `MainAxisAlignment`, `CrossAxisAlignment` | ### イベントハンドラの型 ```csharp // 引数なし public Action? OnPressed { get; init; } // 値を渡す場合 public Action? OnChanged { get; init; } // キャンセル可能な非同期処理 public Func? OnSubmitAsync { get; init; } ``` ### 入力語彙は「ポインター (Pointer)」で統一する FloatSoda が命名する型・メンバー・ドキュメントでは、入力デバイスの語彙として「マウス (Mouse)」を使わず「ポインター (Pointer)」に統一します。VR ではレーザーポインターが、デスクトップではマウスが同じ役割を担うため、デバイス非依存の語彙を採用します(Flutter の `PointerEvent` 系とも一致)。 ```csharp // ✅ 良い例: FloatSoda が命名するものはすべて Pointer public interface IRawPointerSource; public enum PointerButton { Left, Middle, Right } // ❌ 悪い例: FloatSoda の公開APIに Mouse を持ち込む public enum MouseButton { Left, Middle, Right } ``` **例外**: 外部 API(OpenVR / GLFW)の固有名詞をそのまま写す層は対象外です。`FloatSoda.OVR` の `SetMouseScale` のように下位 API(`SetOverlayMouseScale`)との対応が分かることに価値がある薄いラッパーは、元の語彙を維持します。 ドキュメントコメントの説明文でも「マウス」単独ではなく「ポインター(レーザーポインター/マウス)」のように書きます(→ [DocumentationComments](/documentationcomments/))。 ## 3.5 null チェックは `is null` / `is not null` を使う 参照の null 判定には `==` / `!=` 演算子ではなく、パターンマッチの `is null` / `is not null` を使用します。 ```csharp // ✅ 推奨 if (Child is null) return; if (Child is not null) context.PaintChild(Child, offset); // ❌ 避ける if (Child == null) return; if (Child != null) context.PaintChild(Child, offset); ``` **理由:** - `==` / `!=` はユーザー定義の演算子オーバーロードに解決される可能性があり、意図しない比較ロジックが走ることがある。`is null` は常に参照の同一性(厳密な null 判定)を見るため、型に依存せず安全。 - `is null` / `is not null` は意図が「null かどうか」であることを明確に表現する。 - 等値演算子をオーバーロードする `record` / `record struct`(セクション8・9)が多い本フレームワークでは、特にこの差が問題になりやすい。 > **補足:** 値型(`record struct` 等)の比較や、null 以外の値との比較は従来どおり `==` / `!=` を使います。本ルールは **参照の null 判定** に限定した規約です。 ## 3.6 リスナーパターンを実装せず `event` を使う マルチキャストデリゲート(`event`)で表現できる通知は、Flutter流のリスナーパターン(`AddListener` / `RemoveListener` メソッドやリスナー用インターフェース)を独自実装せず、C# の `event` で公開します。 ```csharp // ✅ 推奨: event による通知 public class AnimationController { public event Action? Changed; public event Action? StatusChanged; } // ❌ 避ける: リスナーパターンの独自実装 public interface IAnimationListener { void OnChanged(); } public class AnimationController { public void AddListener(IAnimationListener listener) { /* ... */ } public void RemoveListener(IAnimationListener listener) { /* ... */ } } ``` **理由:** - Dart の `addListener` / `removeListener` は言語にイベント機構がないための実装であり、C# ではマルチキャストデリゲートが同じ機能を言語レベルで提供する。リスナーリストの管理・通知中の購読解除の安全性(invocation list のスナップショット)も自前実装なしで手に入る。 - `+=` / `-=` による購読・解除は C# 開発者にとって最も予測可能な API になる。 - 購読側にインターフェース実装を強制せず、ラムダやメソッド参照をそのまま渡せる。 **適用範囲の整理:** | 対象 | 使うもの | |---|---| | Widget のコールバック(単一ハンドラを `init` で受ける) | `On` プレフィックスの `Action?` プロパティ(セクション3) | | 長寿命のミュータブルなオブジェクト(Controller 等)からの通知(購読者が複数・動的に増減) | `event` | **補足:** - 通知元の共通抽象が必要な場合も、リスナー側ではなく通知元側のインターフェースに `event` を宣言する(例: `interface IListenable { event Action? Changed; }`)。 - ラッパー型が親の通知をそのまま中継する場合は、カスタムイベントアクセサ(`add` / `remove`)で親の `event` に委譲すると購読の付け替えや解除漏れを防げる。 - `Dispose()` ではイベントフィールドに `null` を代入して購読を破棄する。 ## 4. イミュータビリティと `init` アクセサ すべてのプロパティは原則として `init` アクセサを使い、構築後の変更を不可にします。状態変化はフレームワークの状態管理レイヤーに委ねてください。 ```csharp public record Text : Widget { public string Content { get; init; } = string.Empty; public double FontSize { get; init; } = 14; public Color Color { get; init; } = Colors.Black; public FontWeight FontWeight { get; init; } = FontWeight.Normal; } ``` ## 5. デフォルト値の方針 - すべてのプロパティに**合理的なデフォルト値**を設定し、最小限の記述でコンポーネントを使えるようにする - 必須プロパティは `required` キーワードで明示する ```csharp public record AspectRatio : Widget { public required double Ratio { get; init; } // 必須(幅 ÷ 高さ。妥当なデフォルトが存在しない) public Widget? Child { get; init; } // null = 子なし public Alignment Alignment { get; init; } = Alignment.Center; // デフォルトあり } ``` > **末端ウィジェットの必須値は `required` にしない:** 上の `AspectRatio` はウィジェット(`Child`)を取る合成ウィジェットなので、初期化子+`required` で必須値を表現します。一方 `Text` / `Icon` / `Image` のような**末端ウィジェット**は、必須値をコンストラクタで受けるため `required` を使いません(→ [セクション2.1 の例外](#例外-末端ウィジェットの単一値コンストラクタ))。両者で必須値の受け方が異なる点に注意してください。 ## 6. バージョニングと後方互換性 ### 6.1 非破壊的変更(マイナーバージョン) - 新しいプロパティの追加(デフォルト値あり) - `required` でないプロパティのオプション化 ### 6.2 破壊的変更(メジャーバージョン) - プロパティの削除・リネーム - プロパティの型変更 - `required` の追加 - **observable behavior の変更** — 既存の正しい利用コードから観測できる挙動が変わるもの。layout、paint / clipping、hit testing、Widget update behavior、invalid / degenerate input handling、Element / state lifecycle semantics など、セクション1の parity 対象リストに挙げた挙動が判定の観点 observable behavior については、**互換性の基準は文書化された契約**(本ドキュメント、docs、Flutter parity)です。契約から逸脱していた挙動を契約へ戻す修正は、挙動が変わっても破壊的変更ではなくバグ修正として扱います。逆に、契約どおりに動いていた挙動を変える場合は破壊的変更です。 ### 6.3 廃止予定プロパティの扱い ```csharp /// テキストの色を指定します。 [Obsolete("TextStyle.Color を使用してください。v3.0 で削除予定です。")] public Color? TextColor { get; init; } ``` ### 6.4 Alpha 段階での判定と許容の分離 Alpha であることは、breaking change の**判定**を省略する理由にしません。判定と許容は分けて行います。 1. まず public API / observable behavior に対して、6.1 / 6.2 の基準で breaking change かを通常どおり判定する 2. そのうえで、Alpha 段階としていまその変更を受け入れるかを別に判断する 「Alpha だから breaking ではない」という扱いはしません。判定の結果は PR 本文に書き(→ [CONTRIBUTING.md](https://github.com/sumx21t-3310/FloatSoda/blob/main/CONTRIBUTING.md))、breaking change を伴う Issue には `breaking-change` ラベルを付けます。 ## 7. ファクトリメソッドの方針 プライマリコンストラクタ(`record struct` のポジショナル構文)以外でインスタンスを生成するパターンには、**静的ファクトリメソッド**を提供します。オブジェクト初期化子だけでは表現しにくい「よく使うプリセット」や「導出パターン」をファクトリメソッドとして定義することで、呼び出し側のコードを簡潔に保ちます。 ### 7.1 命名規則 | パターン | メソッド名の例 | 用途 | |---|---|---| | 全辺・全軸に同じ値 | `All(value)` | `EdgeInsets.All(16)` | | 軸ごとに指定 | `Symmetric(h, v)` | `EdgeInsets.Symmetric(horizontal: 8)` | | 一辺・一方向のみ | `Only(...)` | `EdgeInsets.Only(top: 4)` | | ゼロ・空・デフォルト | `Zero` / `Empty` / `Default` | `EdgeInsets.Zero`, `Size.Empty` | | 単位値 | `One` / `Unit` | `Size.One` | | 既存値からの変換・導出 | `From*(...)` | `Rect.FromPoints(a, b)` | | よく使うプリセット | 意味のある名詞 | `ThemeContext.Dark()`, `ThemeContext.Light()` | | 単位付きリテラル | 単位名の拡張プロパティ | `45.Deg`, `2000.Dpm`(→ 7.5) | ### 7.2 ジオメトリ型のファクトリ例 ```csharp public readonly record struct EdgeInsets(double Left, double Top, double Right, double Bottom) { public static readonly EdgeInsets Zero = new(0, 0, 0, 0); public static EdgeInsets All(double value) => new(value, value, value, value); public static EdgeInsets Symmetric(double horizontal = 0, double vertical = 0) => new(horizontal, vertical, horizontal, vertical); public static EdgeInsets Only(double left = 0, double top = 0, double right = 0, double bottom = 0) => new(left, top, right, bottom); } public readonly record struct Rect(double X, double Y, double Width, double Height) { public static readonly Rect Empty = new(0, 0, 0, 0); public static Rect FromLTWH(double left, double top, double width, double height) => new(left, top, width, height); public static Rect FromPoints(Offset topLeft, Offset bottomRight) => new(topLeft.X, topLeft.Y, bottomRight.X - topLeft.X, bottomRight.Y - topLeft.Y); public static Rect FromCenter(Offset center, double width, double height) => new(center.X - width / 2, center.Y - height / 2, width, height); } public readonly record struct Size(double Width, double Height) { public static readonly Size Zero = new(0, 0); public static readonly Size Infinite = new(double.PositiveInfinity, double.PositiveInfinity); public static Size Square(double side) => new(side, side); } ``` ### 7.3 Contextオブジェクトのファクトリ例 ```csharp public record ThemeContext { public Color PrimaryColor { get; init; } = Colors.Blue; public Color SurfaceColor { get; init; } = Colors.White; public Color OnSurfaceColor { get; init; } = Colors.Black; public TextStyle DefaultTextStyle { get; init; } = new(); public double BorderRadius { get; init; } = 4; // よく使うプリセットをファクトリメソッドで提供 public static ThemeContext Light() => new(); public static ThemeContext Dark() => new() { PrimaryColor = Colors.Teal300, SurfaceColor = Colors.Gray900, OnSurfaceColor = Colors.White }; } ``` ### 7.4 ファクトリメソッドを追加すべき判断基準 - 3つ以上のプロパティを毎回同じパターンで設定する組み合わせがある - ゼロ・空・単位といった「自明な定数」がある - 別の表現形式(座標2点→矩形など)から変換する必要がある - `with` 式と組み合わせて「ベースに少し手を加える」用途が想定される ### 7.5 単位系値オブジェクトの拡張ファクトリ 角度・密度・物理長など**単位を持つ値オブジェクト**には、`FromXxx` 静的ファクトリ(セクション7.1)に加えて、数値リテラルから直接生成できる**拡張プロパティ**を提供します。C# 14 の `extension` ブロックで定義し、引数なし・`()` なしで単位付きリテラルのように読める書き味を実現します。 ```csharp // ✅ 推奨: 拡張プロパティによる単位付きリテラル new RotatedBox { Angle = 45.Deg, Child = icon } // 従来の静的ファクトリも引き続き有効(こちらが正準) new RotatedBox { Angle = Angle.FromDegrees(45), Child = icon } ``` **定義例:** ```csharp namespace FloatSoda.Abstractions.Geometries.Units; public static class AngleUnits { extension(double value) { /// 度数から を生成します。 public Angle Deg => Angle.FromDegrees(value); /// ラジアンから を生成します。 public Angle Rad => Angle.FromRadians(value); } extension(int value) { /// 度数から を生成します。 public Angle Deg => Angle.FromDegrees(value); /// ラジアンから を生成します。 public Angle Rad => Angle.FromRadians(value); } } ``` **規約:** | 項目 | 規約 | |---|---| | 形式 | 拡張**プロパティ**(`()` なし)。引数なし・副作用なしの純粋変換のみ | | 命名 | `From` プレフィックスなしの単位名(`Deg`, `Rad`, `Dpm`, `Meters`) | | レシーバー型 | `double` と `int` の2つ(公開APIが `double` 基準〔セクション8.5〕のため `float` レシーバーは提供しない) | | 名前空間 | 専用名前空間(`FloatSoda.Abstractions.Geometries.Units` 等)に隔離し、オプトインにする | | 位置づけ | `FromXxx` 静的ファクトリが正準。拡張プロパティはその糖衣であり、必ず正準ファクトリへ委譲する | **理由:** - オブジェクト初期化子ベースのマークアップ(セクション1)の中で、`Angle.FromDegrees(45)` より `45.Deg` のほうが視覚的ノイズが少なく、単位付きリテラルとして自然に読める。 - 従来の拡張メソッドと異なり、C# 14 の `extension` ブロックは**プロパティ**を定義できるため、`45.Deg()` の `()` すら不要になる。値を返すだけの純粋変換であり、プロパティのセマンティクスにも合致する。 - 数値型への拡張は、名前空間をインポートしたすべてのコードで数値リテラルの補完候補に現れる。専用名前空間への隔離により、単位リテラルを使いたいファイルだけがオプトインでき、IntelliSense 汚染を防げる。 **注意点:** - 拡張ブロックのレシーバー解決には数値の暗黙変換(`int` → `double` 等)が**効かない**。`double` にだけ定義すると `45.Deg`(int リテラル)がコンパイルエラーになるため、レシーバー型は必ず規約どおり `double` と `int` の両方に定義すること。 - 単位の解釈が自明でない変換(例: `Dpm.FromMillimetersPerPixel` のような逆数系)は拡張プロパティにせず、正準の `FromXxx` のみとする。拡張プロパティは「数値がそのまま単位値になる」変換に限定する。 ## 8. ジオメトリオブジェクトには `record struct` を使う 座標・サイズ・余白などのジオメトリ型は `record struct` で定義します。値型であるため、ヒープ割り当てが不要でレイアウト計算時のパフォーマンスに優れ、かつ `record` の等値比較・分解・`with` 式の恩恵を受けられます。 ```csharp // ✅ 推奨 public readonly record struct Size(double Width, double Height); public readonly record struct Offset(double X, double Y); public readonly record struct Rect(double X, double Y, double Width, double Height); public readonly record struct EdgeInsets(double Left, double Top, double Right, double Bottom); ``` ファクトリメソッドの定義方針はセクション7を参照してください。`with` 式により既存の値から一部だけ変えた新しい値を簡潔に作れます。 ```csharp var insets = EdgeInsets.All(16); var wider = insets with { Left = 32, Right = 32 }; ``` **ジオメトリ型に `class` や通常の `struct` を使わない理由:** | | `record struct` | `class` | `struct` | |---|---|---|---| | ヒープ割り当て | なし | あり | なし | | 等値比較 | 値ベース(自動) | 参照ベース | 手動実装が必要 | | `with` 式 | ✅ | ✅ | ❌ | | 分解 (`Deconstruct`) | ✅ | 手動 | 手動 | ## 8.5 実数は `double` を基本とし、Skia型を公開APIに出さない ### 実数型の方針 公開APIに現れる実数(座標・サイズ・角度・比率など)は **`double`** を使用します。`float` はプラットフォーム境界(SkiaSharp / OpenVR / OpenGL への受け渡し)でのみ使用し、境界での変換はフレームワーク内部が担います。 ```csharp // ✅ 推奨: 公開APIは double public readonly record struct Angle(double Radians); public readonly record struct Size(double Width, double Height); // ❌ 避ける: 公開APIに float を露出 public readonly record struct Angle(float Radians); ``` **理由:** - C# の浮動小数リテラルはデフォルトで `double` であるため、`Rotation = 45.5.Deg` のように接尾辞 `f` なしで書ける。`float` 基準のAPIはマークアップ全体に `f` のノイズを強いる(セクション7.5の単位リテラルと相乗)。 - レイアウト計算や角度→行列変換のような合成計算は `double` で保持するほうが誤差が蓄積しにくい。 - Flutterも公開API(`dart:ui` / framework層)はすべて `double` であり、エンジン境界で `float` へ変換している。 **性能に関する補足:** 値オブジェクトのサイズは倍になるが、UIのプロパティ用途では実害はない。SIMD化されたホットパスなど `float` が正当化される箇所は、公開APIではなく内部実装に限定する。 ### Skia型を公開APIに出さない `SKCanvas` / `SKPicture` / `SKSize` / `SKColor` などの SkiaSharp 型は、公開API(Widget のプロパティ、RenderObject の公開メンバー、ジオメトリ型、デザインシステムの Style レコード)に露出させません。描画の語彙はフレームワーク自前の型(`Size`, `Color`, `Paint` 等)で定義し、Skia型への変換は `FloatSoda.Engine` 側の境界で行います。 ```csharp // ✅ 推奨: 自前の語彙型 public record ButtonStyle { public Color BackgroundColor { get; init; } = Colors.White; } // ❌ 避ける: Skia型の直接露出 public record ButtonStyle { public SKColor BackgroundColor { get; init; } = SKColors.White; } ``` **理由:** - レンダリングバックエンドを実装詳細に保つため。Flutter が framework 層と `dart:ui` の境界を維持していたからこそ Skia → Impeller の差し替えが可能だった。同じ境界を引くことで、将来のバックエンド変更を公開APIの破壊なしに行える。 - 利用者に SkiaSharp への直接依存を強制しない。 > **移行中の注記:** 既存コードにはこの規約に違反する箇所が残っている(`RenderBox.Size` の `SKSize`、`Angle` / `Dpm` / `Alignment` の `float` など)。段階的な解消は [Issue #131](https://github.com/sumx21t-3310/FloatSoda/issues/131) のロードマップに従う。新規APIはこの規約に従うこと。 ## 9. Contextオブジェクトには `record` を使う テーマ・ロケール・アクセシビリティ設定など、ツリーを通じて伝播するコンテキスト情報は `record`(参照型)で定義します。`with` 式によるコピー変形でスコープごとに一部を上書きでき、等値比較によって再描画の必要性を効率よく判定できます。 ```csharp public record ThemeContext { public Color PrimaryColor { get; init; } = Colors.Blue; public Color SurfaceColor { get; init; } = Colors.White; public Color OnSurfaceColor { get; init; } = Colors.Black; public TextStyle DefaultTextStyle { get; init; } = new(); public double BorderRadius { get; init; } = 4; public static ThemeContext Light() => new(); public static ThemeContext Dark() => new() { PrimaryColor = Colors.Teal300, SurfaceColor = Colors.Gray900, OnSurfaceColor = Colors.White }; } public record LocaleContext { public required CultureInfo Culture { get; init; } public FlowDirection FlowDirection { get; init; } = FlowDirection.LeftToRight; public static LocaleContext FromCulture(CultureInfo culture) => new() { Culture = culture }; } ``` サブツリーでテーマを部分的にオーバーライドする例: ```csharp // 親のコンテキストから一部だけ変えた新しいコンテキストを派生させる var darkSection = parentTheme with { PrimaryColor = Colors.White, SurfaceColor = Colors.Gray900 }; ``` **`record struct` ではなく `record`(参照型)を選ぶ理由:** コンテキストオブジェクトはツリー全体で共有参照されるため、値型のコピーが多発するとオーバーヘッドになります。参照型の `record` にしておくことで、`with` 式で変形したときだけ新しいインスタンスを生成し、変化のないサブツリーへは同じ参照を引き渡すことができます。 ## 10. ドキュメントコメント規約 ドキュメントコメント(XML ドキュメントコメント)の規約は独立したページに分離しました。適用範囲・契約の書き方・Dirty フラグなどの副作用の明記・関連型への参照・記述言語などは [DocumentationComments](/documentationcomments/) を参照してください。 要点は次の通りです。 - アクセス修飾子を問わず、原則としてすべての型およびメンバーに記述する(`public` だけでなく `private` も対象)。 - ドキュメントコメントは正式な API Reference の原稿として扱い、役割・契約・副作用を完結させる。使い方・チュートリアルはドキュメントサイト側へ分離する。 - `` は原則として使用しない。 ## 11. ネイティブAPIラップの方針 `FloatSoda.OVR` などでネイティブAPI(OpenVR等)をラップする際の規約です。 ### 11.1 enum の名前を返すだけの API は高レベルラッパーに追加しない OpenVR には `GetApplicationsErrorNameFromEnum` / `GetOverlayErrorNameFromEnum` / `GetSceneApplicationStateNameFromEnum` / `GetEventTypeNameFromEnum` など、enum 値を人間可読な名前文字列へ変換するだけの `*NameFromEnum` 系 API が各インターフェースに存在します。**列挙子名を返すだけで、説明文・ローカライズ・プロトコル上の意味を追加で持たないもの**は、`FloatSoda.OVR` の高レベルラッパーに同等メソッドを追加しません。 ```csharp // ✅ 推奨: 既知の値をログへ出す通常用途は C# の文字列補間で十分 EVRApplicationError err = ...; logger.Log($"OpenVR error: {err} ({(int)err})"); // "AppKeyAlreadyExists (100)" // ❌ 避ける: ネイティブ関数を経由した文字列化を高レベルラッパーとして公開する public string GetErrorName(EVRApplicationError err) => Marshal.PtrToStringAnsi(OpenVR.Applications.GetApplicationsErrorNameFromEnum(err)); ``` **理由:** - 既知の enum 値をログへ表示する通常用途には `enum.ToString()`(および文字列補間)で十分であり、`FloatSoda.OVR` の高レベル API として別の文字列化メソッドを追加する価値が小さい。 - FloatSoda.OVR のエラーモデルは `ThrowIfError()` による例外化であり、エラーは例外と型付きの `ErrorCode` で扱い、メッセージは診断情報として提供する。文字列化 API を高レベルラッパーとして公開すると「エラーは文字列で扱う」という誤ったシグナルになる。 - 高レベルラッパーの公開 API に存在するものは「使うべきもの」と解釈される(特にコード生成 AI)。不要な API を増やさないことが誤用防止になる。 **注意点:** - C# の `enum.ToString()` とネイティブ側 `*NameFromEnum` の返却文字列が完全一致することは**保証しません**(例: ネイティブ側は `VRApplicationError_AppKeyAlreadyExists`、C# バインディングは `AppKeyAlreadyExists` のように表現が既に異なる)。また未定義の将来値に対して `enum.ToString()` は数値文字列(例: `"117"`)を返しますが、ランタイム側の `*NameFromEnum` はその値の名前を認識できる場合があります。診断上どうしてもネイティブ側の名前が必要な場合は、低レベルバインディング(`OpenVR.Applications.GetApplicationsErrorNameFromEnum` 等)を直接呼び出してください。 - この規約は `FloatSoda.OVR` の**高レベルラッパー**に適用するものであり、`openvr_api.cs` に生成される低レベルバインディングから `*NameFromEnum` を削除する意味ではありません。 - 単なる列挙子名ではなく、説明文・対処方法・ローカライズ済み表示文を返す API(例: `VR_GetVRInitErrorAsEnglishDescription` のような記述系 API)はこの規約の対象外です。個別に検討してください。 ### 11.2 ライフサイクルを持つ型は単数形、ステートレスなユーティリティは複数形 ネイティブ API のラッパー型を命名する際、**そのインスタンスが `Init`/`Dispose` のようなライフサイクルを持つか**で単数形・複数形を使い分けます。 ```csharp // ✅ ライフサイクルを持つ: 単数形 (OVRApplication) // - コンストラクタで OpenVR.Init()、Dispose() で OpenVR.Shutdown() // - Info.Key(自分自身のアプリ識別子)を暗黙に使う自己参照系の操作を持つ public class OVRApplication : IDisposable { public OVRAppInfo Info { get; init; } public bool AutoLaunch { get => OpenVR.Applications.GetApplicationAutoLaunch(Info.Key); set => OpenVR.Applications.SetApplicationAutoLaunch(Info.Key, value); } } // ✅ ステートレスなユーティリティ: 複数形 (OVRApplications) // - ライフサイクルを持たない static クラス // - 「自分」ではなく SteamVR のアプリ登録全体を対象にした操作を持ち、対象は毎回引数で明示する public static class OVRApplications { public static void Launch(string appKey) => OpenVR.Applications.LaunchApplication(appKey).ThrowIfError(); } ``` **判断基準:** | 型の性質 | 命名 | 例 | |---|---|---| | `Init`/`Dispose` のようなライフサイクルを持ち、自分自身の識別子(`Info.Key` 等)を暗黙に使う操作を持つ | 単数形 | `OVRApplication` | | ライフサイクルを持たない `static` クラスで、操作対象を毎回引数で明示する | 複数形 | `OVRApplications` | **理由:** - OpenVR 自身の命名(`IVRSystem` = 単数、`IVRApplications` = 複数)と対応させることで、ラップ元の API との対応関係が名前から読み取れる。 - `OVRApplication.Launch(appKey)` のように単数形インスタンスから他アプリを起動する形にすると、「自分」と「他アプリ」の操作が同じ型に混在し、`this` を参照しない操作なのか自己参照系の操作なのかが型名だけでは区別できない。複数形の独立した静的クラスに分離することで、その曖昧さを型レベルで解消する。 - 将来 `IVROverlay` など他のネイティブインターフェースをラップする際にも、同じ基準(ライフサイクル所有の有無)で単数形/複数形を機械的に判断できる。 ## 関連ページ - [WidgetSystem](/widgetsystem/) — この規約で実装された組み込みウィジェット - [Home](/home/) — ドキュメント一覧 - [CONTRIBUTING.md](https://github.com/sumx21t-3310/FloatSoda/blob/main/CONTRIBUTING.md) — 開発・コントリビューション規約(ブランチ命名、namespace、テスト観点、PR運用) - [REVIEW.md](https://github.com/sumx21t-3310/FloatSoda/blob/main/REVIEW.md) — コードレビューの判断基準。本ドキュメントの原則をレビュー基準として参照する # Architecture > アセンブリ構成・ツリー構造・スレッドモデル FloatSoda は Flutter のアーキテクチャを参考に設計された VR オーバーレイ UI フレームワークです。SkiaSharp で描画コマンドを記録し、OpenGL テクスチャに焼き付けて OpenVR Compositor に提出することで SteamVR オーバーレイを表示します。 ## アセンブリ構成 ```mermaid graph TD Abstractions["FloatSoda.Abstractions\n共有契約・プリミティブ"] Rendering["FloatSoda.Rendering\nLayerツリー・Skia描画"] Engine["FloatSoda.Engine\nOpenGL・レンダースレッド"] OVR["FloatSoda.OVR\nOpenVR ラッパー"] Core["FloatSoda\nウィジェット・RenderObject・パイプライン"] Testing["FloatSoda.Testing\nヘッドレス画像レンダリング"] UI["FloatSoda.UI\nヘッドレスUI(振る舞いのみ)"] Cream["FloatSoda.UI.Cream\nデザインシステム①"] FizzyPop["FloatSoda.UI.FizzyPop\nデザインシステム②"] Rendering --> Abstractions Rendering --> Engine Rendering --> Core Abstractions --> Engine Abstractions --> Core OVR --> Core Engine --> Core Core --> Testing Rendering --> Testing Core --> UI UI --> Cream UI --> FizzyPop ``` | アセンブリ | 役割 | |---|---| | `FloatSoda.Abstractions` | Engine境界契約、`Offset`などの共有値型、入力イベント、フレームペーシング | | `FloatSoda.Rendering` | `ILayer`と具象Layer群、共通Layer描画、Bitmap描画 | | `FloatSoda.Engine` | `IEngineWindow`などの具体実装、`GLView`、`Renderer`、`RenderPostTaskRunner`、`IOTaskRunner`、`FramePacer` | | `FloatSoda.OVR` | OpenVR 初期化(`Application`)、オーバーレイ型(`DashboardOverlay` / `WorldSpaceOverlay` / `DeviceTrackedOverlay`)、イベントディスパッチャ、例外体系 | | `FloatSoda` | ウィジェット/エレメントツリー、RenderObject ツリー、`RenderPipeline`、`FloatSodaApp`、Generic Host統合 | | `FloatSoda.Testing` | Widget・RenderObjectツリーをBitmapへ描画するヘッドレステスト支援 | | `FloatSoda.UI` | ヘッドレスUI層。振る舞い・状態機械のみ(`ButtonBase`, `InteractionState`)。見た目は builder に委譲。**Phase 5 の予定で未提供**(→ [UILayering](/uilayering/#実装状況)) | | `FloatSoda.UI.Cream` | デザインシステム①: レトロでクリーミーな色使いのフラットデザイン(`Button`, `ButtonStyle`, `CreamTheme`)。**Phase 5 の予定で未提供** | | `FloatSoda.UI.FizzyPop` | デザインシステム②: 透明感・グラスモーフィズム(`Button`, `ButtonStyle`, `FizzyPopTheme`)。**Phase 5 の予定で未提供** | | `FloatSoda.Hooks` | R3 ベースの `HookWidget` / `HookElement`。フレームワークのビルドループとは未統合(部分実装) | ## ツリー構造 FloatSoda は Flutter の三ツリーモデルをベースに、現在 **RenderObject ツリー** と **レイヤーツリー** が完全実装済みです。ウィジェット/エレメントツリーは `StatelessWidget` / `StatefulWidget` / `InheritedWidget` / `ParentDataWidget` と、`BuildOwner` による差分ビルド(`Key` 対応の子リスト差分を含む)が実装済みです。レイアウト・描画・入力系のウィジェットは一巡し、残る未実装はスクロール系(`ListView` / `GridView` / `SingleChildScrollView`)です(詳細は [WidgetSystem](/widgetsystem/) と [BuildPipeline](/buildpipeline/))。 ```mermaid graph LR subgraph "Widget / Element Tree" W["Widget\n(immutable record)"] E["Element\n(mutable)"] BO["BuildOwner\n(dirty list)"] W -->|CreateElement| E BO -->|"BuildScope() で\ndirty Element を再ビルド"| E end subgraph "RenderObject Tree" RV["RenderView"] RB["RenderBox\nサブクラス群"] RV --> RB end subgraph "Layer Tree" CL["ContainerLayer"] PL["PictureLayer\n(SKPicture)"] CL --> PL end E -->|"CreateRenderObject /\nUpdateRenderObject"| RV RB -->|"Paint → PaintingContext"| CL ``` 各ツリーの役割: - **Widget** — 宣言的な UI の設計図。`abstract record` で不変。 - **Element** — Widget と RenderObject を橋渡しするミュータブルなノード。`MarkNeedsBuild()` で dirty になり、`BuildOwner` が次フレームの `BuildScope()` でまとめて再ビルドする。`StatelessElement` / `StatefulElement` / `InheritedElement` はいずれも実装済み。 - **BuildOwner** — dirty な Element のリストを保持し、`Depth` 順(親が先)に再ビルドを実行するスケジューラ。`WidgetBinding` がウィンドウごとに 1 つ保持する。 - **RenderObject** — レイアウト計算(`PerformLayout`)と描画コマンド記録(`Paint`)を担う。`MarkNeedsLayout` / `MarkNeedsPaint` の dirty フラグにより、変更があった部分だけを再レイアウト・再ペイントする。 - **Layer** — `Paint` フェーズが生成する合成操作のツリー。クローンしてレンダースレッドに渡す。 ## レンダリングライフサイクル ```mermaid sequenceDiagram participant Host as Generic Host participant Main as FloatSodaメインスレッド (STA) participant WB as WidgetBinding participant BO as BuildOwner participant Pipeline as RenderPipeline participant RV as RenderView participant RO as RenderObject 群 participant PC as PaintingContext participant Layer as レイヤーツリー participant RT as レンダースレッド participant Renderer as Renderer participant GL as GLView (GLFW/OpenGL) participant VR as OpenVR Compositor Host->>Main: FloatSodaHostedService.StartAsync() loop メインループ (FloatSodaApp.MainLoop) Main->>Main: VREventDispatcher.PollEvents() Note over Main,Host: VREvent_Quit → Host全体へ停止通知 Main->>WB: DrawFrame() [ウィンドウごと] WB->>BO: BuildScope() Note over BO: dirty な Element を Depth 順に Rebuild
→ UpdateRenderObject でプロパティ反映 alt NeedsVisualUpdate == true WB->>Pipeline: FlushLayout() Pipeline->>RO: LayoutWithoutResize() [dirty ノードのみ・Depth 順] Note over RO: 制約を子へ伝播し
SKSize を親に返す WB->>Pipeline: FlushPaint() Pipeline->>PC: RepaintCompositedChild(node) [dirty ノードのみ] RV->>RO: Paint(context, offset) [再帰] Note over RO: context.Canvas に Skia ドローコール記録
クリップ等は PushLayer で子 ContainerLayer に分岐 PC->>Layer: PictureLayer を ContainerLayer に追加 Note over PC: SKPictureRecorder.EndRecording()
→ PictureLayer.Picture に保存 WB->>Layer: Layer.Clone() — スレッドセーフコピー WB-->>RT: PostRender(window, capturedLayer) end Main->>Main: FramePacer.WaitForNextFrame() end loop レンダースレッド (RenderPostTaskRunner) RT->>RT: ConcurrentQueue からタスクを取り出す RT->>Renderer: Render(layer) Renderer->>GL: Clear() Note over GL: GRContext.ResetContext()
SKSurface.Canvas.Clear(Transparent) Renderer->>Layer: layer.Layout(LayerContext) Note over Layer: PaintBounds を再帰的に計算 Renderer->>Layer: layer.Paint(LayerContext) Note over Layer: ContainerLayer → PictureLayer の順に
SKCanvas.DrawPicture() で描画
ClipLayer は SaveLayer/ClipXxx を挿入 Renderer->>GL: Flush() Note over GL: SKSurface.Flush() → GRContext.Flush()
→ GL.Flush() で GL テクスチャに書き込み完了 RT->>VR: Overlay.Texture.FromTexture_t(GL texture handle) Note over VR: ETextureType.OpenGL / EColorSpace.Auto end ``` ## スレッドモデル | スレッド | 所有物 | 通信方法 | |---|---|---| | **メインスレッド** | RenderPipeline, Widget/RenderObject ツリー, VREventDispatcher | `RenderPostTaskRunner.PostTask(Action)` でタスクをキューに積む | | **レンダースレッド** | OpenGL コンテキスト, `GLView`, `Renderer`, `OverlayWindow` | `ConcurrentQueue` を処理 | | **I/Oスレッド** | `ImageProvider` / `FontProvider` によるリソース読み込み | `IOTaskRunner.RunAsync(...)` に投入し、完了は `BuildOwner` の `TaskScheduler` 経由でメインスレッドへ戻る | OpenGL のコンテキストはレンダースレッドが独占します。ウィンドウ作成も `PostTask` 経由でレンダースレッド上で実行されます。 I/O スレッドは `IOTaskRunner` が持つ単一のバックグラウンドスレッドで、投入順に1件ずつ処理します。`Image` の画像読み込みはこのスレッドで行われ、完了後の再ビルドはメインスレッドの `BuildScope` で実行されます。なお、テキストレイアウト中のフォント解決は同期 API から呼ばれるため、このキューを経由せず呼び出しスレッド上で読み込みます。 ```text メインスレッド └─ RenderPostTaskRunner.PostTask(layer 更新ラムダ) │ ConcurrentQueue ▼ レンダースレッド └─ OverlayWindow.Update() └─ Renderer.Render(layer) └─ GLView.Clear() → layer.Paint() → GLView.Flush() └─ SetOverlayTexture(GL texture handle) ``` ## 関連ページ - [BuildPipeline](/buildpipeline/) — BuildOwner による差分ビルドの詳細 - [RenderObjects](/renderobjects/) — 差分レイアウト・差分ペイントの仕組み - [OVRIntegration](/ovrintegration/) — OpenVR オーバーレイとイベント処理 # ビルドパイプライン(Widget 差分更新) > BuildOwner による差分ビルドとフレームパイプラインの詳細 このページは Widget ツリーの差分ビルドの仕組み — `BuildOwner` / dirty list / `BuildScope` / `Element.UpdateChild` — を解説します。RenderObject 側の差分レイアウト・差分ペイントは [RenderObjects](/renderobjects/) を参照してください。 > **実装状況:** `BuildOwner` と `StatelessElement` / `StatefulElement` / `InheritedElement` / `SingleChildRenderObjectElement` / `MultiChildRenderObjectElement`(`Key` 対応の子リスト差分)/ `RenderObjectToWidgetElement` の再ビルドが動作します。`Key`(`ValueKey` / `UniqueKey`)は `Widget.CanUpdate` に組み込み済みです。 ## 全体の流れ 毎フレーム `FloatSodaApp.MainLoop()` から各ウィンドウの `WidgetBinding.DrawFrame()` が呼ばれ、次の 3 段階が実行されます。 ```mermaid sequenceDiagram participant App as FloatSodaApp participant WB as WidgetBinding participant BO as BuildOwner participant El as Element ツリー participant PL as RenderPipeline participant RT as レンダースレッド App->>WB: DrawFrame() WB->>BO: BuildScope() Note over BO: dirty な Element を Depth 順にソート loop dirty Elements BO->>El: element.Rebuild() El->>El: PerformRebuild()
→ Build() → UpdateChild() Note over El: RenderObject のプロパティ更新
→ MarkNeedsLayout / MarkNeedsPaint end alt NeedsVisualUpdate == true WB->>PL: FlushLayout() WB->>PL: FlushPaint() WB->>RT: PostRender(layer.Clone()) end ``` 1. **ビルドフェーズ** — `BuildOwner.BuildScope()` が dirty な Element を再ビルドし、Widget ツリーの変更を RenderObject ツリーに反映します。 2. **レイアウト/ペイントフェーズ** — RenderObject 側の dirty フラグに基づき `RenderPipeline` が差分レイアウト・差分ペイントを実行します。 3. **合成フェーズ** — レイヤーツリーをクローンしてレンダースレッドへ送ります([Architecture](/architecture/) 参照)。 ## BuildOwner と dirty list `BuildOwner`(`src/FloatSoda/Elements/BuildOwner.cs`)は Element の再ビルドをスケジュールする中枢です。`WidgetBinding` が 1 つ保持し、ルート Element の `Mount` 時にツリー全体へ伝播します。 ### MarkNeedsBuild → ScheduledBuildFor Element を再ビルドしたいときは `Element.MarkNeedsBuild()` を呼びます。 ```csharp public void MarkNeedsBuild() { if (Dirty) return; Dirty = true; Owner?.ScheduledBuildFor(this); } ``` `BuildOwner.ScheduledBuildFor()` は Element を dirty list に追加し、初回であれば `onBuildScheduled` コールバック(`WidgetBinding.EnsureVisualUpdate`)を発火して「このフレームは描画が必要」というフラグを立てます。 ### BuildScope の再ビルドループ `BuildScope()` は dirty list を **`Depth` 昇順(親が先)** にソートしてから順に `Rebuild()` します。親を先にビルドするのは、親の再ビルドで子も更新される場合に子の個別ビルドを無駄にしないためです(Flutter と同じ戦略)。 ビルド中に新たな Element が dirty になった場合(ビルド中の `MarkNeedsBuild`)はリストを再ソートしてループを継続します。ループ終了後に `InDirtyList` フラグをクリアして dirty list を空にします。 `Element` は `IComparable` を実装しており、`Depth` → `Dirty` の順で比較されます。 ## Element の再ビルドと UpdateChild 再ビルドの実体は各 Element の `PerformRebuild()` です。 ### ComponentElement(StatelessElement など) ```csharp public override void PerformRebuild() { var built = Build(); // StatelessWidget.Build(this) を呼ぶ Dirty = false; Child = UpdateChild(Child, built); } ``` `UpdateChild(child, newWidget)` は Widget の差分を Element ツリーに適用する中心的メソッドです。 | 条件 | 動作 | |---|---| | `newWidget == null` | 子を `DeactivateChild`(RenderObject をツリーから切断)して破棄 | | 子がいない | `InflateWidget` で新しい Element を作成して `Mount` | | `child.Widget == newWidget`(完全一致) | 何もしない(Element を再利用) | | `Widget.CanUpdate(old, new)` | `child.Update(newWidget)` で既存 Element を更新 | | それ以外 | 子を破棄して `InflateWidget` で作り直し | > **`CanUpdate` の判定:** `Widget.CanUpdate(old, new)` は Flutter と同じく「同じ実行時型かつ `Key` が等しい」なら `true` を返し、既存 Element を再利用します。その手前にある `child.Widget == newWidget`(record の等値比較)は完全一致を素通しする高速パスで、ここで一致すれば `Update` すら呼びません。プロパティだけが変わった同型・同 Key の Widget は Element を再利用してプロパティ差分だけが適用されます。`Key`(`ValueKey` / `UniqueKey`)は `Widget.Key` として差分判定に組み込み済みです。 ### RenderObjectElement `RenderObjectElement` は `Mount` 時に `CreateRenderObject()` で RenderObject を生成し、`AttachRenderObject()` で最も近い祖先 RenderObjectElement の RenderObject に挿入します(Widget ツリー上では `StatelessWidget` などレンダリングを伴わない Element を挟めるため、探索が必要です)。 更新時は `PerformRebuild()` が `Widget.UpdateRenderObject(renderObject)` を呼び、**既存の RenderObject のプロパティだけを書き換えます**。プロパティのセッターが `MarkNeedsLayout()` / `MarkNeedsPaint()` を呼ぶことで、RenderObject 側の差分更新([RenderObjects](/renderobjects/))につながります。 ``` Widget が変わる → Element.Update → UpdateRenderObject → RenderObject のプロパティ変更 → MarkNeedsLayout / MarkNeedsPaint → RenderPipeline の dirty list へ → FlushLayout / FlushPaint(変わった部分だけ) ``` ## ルートの接続: RenderObjectToWidgetAdapter Widget ツリーのルートは `RenderObjectToWidgetAdapter`(Widget)と `RenderObjectToWidgetElement`(Element)のペアが `RenderView` に橋渡しします。 ```csharp RenderViewElement = new RenderObjectToWidgetAdapter { Child = rootWidget, Container = Pipeline.RenderView } .AttachToRenderTree(BuildOwner, RenderViewElement as RenderObjectToWidgetElement); ``` `AttachToRenderTree(owner, element)` の動作: - **初回(`element == null`)** — Element を生成し、`owner.BuildScope(() => result.Mount(null))` でビルドスコープ内にツリー全体を `Mount` します。 - **2 回目以降** — 既存 Element の `NewWidget` に新しいルート Widget をセットして `MarkNeedsBuild()` するだけです。実際の適用は次の `BuildScope()` 内の `PerformRebuild()` で行われます(ホットリロードやルート差し替えに対応)。 ## WidgetBinding.DrawFrame `WidgetBinding`(`src/FloatSoda/Core/WidgetBinding.cs`)はウィンドウ(オーバーレイ)ごとの調整役です。 ```csharp public void DrawFrame() { if (RenderViewElement != null) { BuildOwner.BuildScope(); // 1. dirty Element の再ビルド } if (!NeedsVisualUpdate || Window == null) return; // 変更がなければ何もしない NeedsVisualUpdate = false; Pipeline?.FlushLayout(); // 2. 差分レイアウト PostResizeIfSizeChanged(); // レイアウト結果にオーバーレイサイズを追従 Pipeline?.FlushPaint(); // 3. 差分ペイント if (Pipeline?.RenderView.Layer?.Clone() is not ContainerLayer layer) return; RenderThreadRunner?.PostRender(Window, layer); // 4. レンダースレッドへ (RenderPostTaskRunner) } ``` `NeedsVisualUpdate` は次のいずれかで立ちます。 - `BuildOwner` がビルドをスケジュールしたとき(`onBuildScheduled`) - RenderObject が `MarkNeedsLayout` / `MarkNeedsPaint` したとき(`RenderPipeline.OnNeedVisualUpdate`) つまり **Widget にも RenderObject にも変更がないフレームでは、レイアウト・ペイント・合成のすべてがスキップされます**。 ## 未実装の領域 | 対象 | 現状 | |---|---| | スクロール系ウィジェット | `ListView` / `GridView` / `SingleChildScrollView` は `internal` で公開 API から除外。viewport 基盤とあわせて Phase 3 で実装 | | 画像・アイコン | 描画系の `Paint.Image` / `Paint.Icon` を使用可能。フォントは `FontProvider` 経由で解決 | | ポインタ入力源 | ヒットテストとジェスチャ認識は実装済み。ただし座標の供給元がダッシュボードオーバーレイにしか接続されておらず、`WorldSpaceWindow` / `DeviceTrackedWindow` では入力が届かない | | UI3層構成(`FloatSoda.UI` / `Cream` / `FizzyPop`) | Phase 5 の予定。`ButtonBase` / `Button` / `ButtonStyle` の型はあるが `GestureDetector` へ未配線で、3プロジェクトとも NuGet 未配布 | | `FloatSoda.Hooks` | `HookWidget` / `HookElement`(R3 の `ReactiveProperty` による `UseState`)が部分実装。フレームワークのビルドループとは未統合で、`HookExtension` の各ヘルパーは `NotImplementedException` | ## 関連ページ - [Architecture](/architecture/) — フレーム全体の流れとスレッドモデル - [WidgetSystem](/widgetsystem/) — Widget / Element の使い方と組み込みウィジェット - [RenderObjects](/renderobjects/) — RenderObject 側の差分レイアウト・差分ペイント # ドキュメントコメント規約 > ドキュメントコメント規約(適用範囲・契約・副作用の明記) このドキュメントは、FloatSoda のソースコードに記述する XML ドキュメントコメントの規約をまとめたものです。 FloatSoda のドキュメントコメントは、ソースコードに埋め込まれた正式な API Reference の原稿として扱います。目的は、IDE および自動生成された API Reference 上で、API の役割・契約・副作用を正確に確認できるようにすることです。チュートリアル・設計背景・概念解説・実践的な使用例などは API Reference の責務に含めず、原則としてドキュメントサイトに記載します。 ## 1. 適用範囲 ドキュメントコメントは、アクセス修飾子にかかわらず、原則としてすべての型およびメンバーに記述します。対象には次の要素を含みます。 - クラス - 構造体 - レコード - インターフェース - 列挙型 - デリゲート - コンストラクター - メソッド - プロパティ - フィールド - イベント - 演算子 - 型パラメーター - 列挙値 `public` / `protected` / `internal` / `private` のいずれであるかを問わず、その要素の役割や契約が存在する場合はドキュメントコメントを記述します。内部 API および非公開メンバーについても、フレームワークの保守・コードレビュー・デバッグ・将来の API 変更に必要な内部向け API Reference として扱います。 ただし、次のような要素は例外として省略できます。 - コンパイラーまたはコード生成によって生成される要素 - 意味や契約を持たない単純なバッキングフィールド - 自明な定数の内部保持のみを目的とするフィールド - ドキュメントコメントを継承し、追加または変更された契約がないオーバーライド - 一時的なローカル実装に限定され、独立した役割を持たない要素 省略できるか判断に迷う場合は、ドキュメントコメントを記述します。 ## 2. 基本方針 ドキュメントコメントには、API Reference として必要な情報を記載します。 - `` には、その API が何を行うかを簡潔に記述する。 - メソッドおよびコンストラクターの引数は、`` を使用して省略せず説明する。 - 型パラメーターがある場合は、`` を使用して役割や制約を説明する。 - 戻り値がある場合は、`` を使用して戻り値の意味を説明する。 - プロパティの値に補足が必要な場合は、`` を使用して意味や制約を説明する。 - 例外を送出する場合は、`` を使用して発生条件を記述する。 - 単位・値の範囲・`null` の可否・既定値・失敗時の挙動など、API を正しく使用するために必要な契約を明記する。 - 状態変更・Dirty フラグの変更・再計算・再描画など、観測可能な副作用を明記する。 - `` は、要約・引数・戻り値だけでは不足する契約上の補足がある場合に使用する。 - 長い使用例・チュートリアル・設計意図・内部構造の解説はドキュメントサイトに記載する。 - `` は原則として使用しない。API Reference 上で確認する必要性が高い短い例に限り使用できる。 ## 3. API Reference とドキュメントサイトの責務 ドキュメントコメント(API Reference)には、次の内容を記載します。 - API の役割 - 引数および型パラメーターの意味 - 戻り値の意味 - プロパティ値の意味 - 発生する例外と条件 - 値の単位や有効範囲 - `null` の可否 - 既定値 - 副作用 - Dirty フラグの変更 - 変更が伝播する範囲 - 再実行される処理 - 失敗時の挙動 - 密接に関連する API ドキュメントサイトには、次の内容を記載します。 - チュートリアル - API の具体的な使い方 - 複数 API を組み合わせた例 - Widget・Element・RenderObject の概念解説 - Flutter との対応関係 - 設計意図 - 内部構造 - よくある間違い - 実践的なサンプル - パイプライン全体の解説 原則として、ドキュメントコメントは「何であるか」「どのような契約か」「どのような副作用があるか」を説明し、ドキュメントサイトは「どのように使うか」を説明します。 ## 4. 引数、戻り値および型パラメーター すべての引数に `` を記述します。引数名や型を単に言い換えるだけの説明は避け、意味・単位・有効範囲・`null` の可否・所有権・ライフタイム・副作用などを記載します。 ```csharp /// /// オーバーレイの幅。単位はメートルで、0より大きい値を指定します。 /// ``` 型パラメーターには、その型が担う役割と必要な制約を記載します。 ```csharp /// /// このElementが管理するWidgetの型。 /// ``` 戻り値がある場合は、`` に成功時・失敗時・対象が存在しない場合など、それぞれの値が表す意味を記載します。 ```csharp /// /// イベントが処理された場合は 。 /// 処理対象が存在しない場合は 。 /// ``` ## 5. Dirty フラグの変更 型またはメンバーが Dirty フラグを変更する場合は、変更される Dirty フラグをドキュメントコメント内で明示します。Dirty フラグの変更は、レイアウト・描画・合成などの後続処理に影響するため、API Reference 上の副作用として扱います。 必要に応じて、次の情報を記載します。 - 直接変更される Dirty フラグ - 間接的に変更される Dirty フラグ - 変更が伝播する関連オブジェクト - 伝播が停止する境界 - 再実行されるパイプライン処理 - 値が変化しなかった場合の挙動 特に次の Dirty 状態を変更する場合は明示します。 - Layout Dirty - Paint Dirty - Compositing Dirty - Widget または Element の再構築を要求する状態 - FloatSoda 固有のパイプライン Dirty 状態 ```csharp /// /// 子要素に適用する内側の余白を取得または設定します。 /// /// /// 子要素の周囲に適用する余白。 /// /// /// 値が変更された場合、このRenderObjectをLayout Dirtyとしてマークします。 /// これにより、次のパイプライン更新時にサイズと位置が再計算されます。 /// 値が変更されなかった場合、Dirty状態は変更されません。 /// public EdgeInsets Padding { get => _padding; set { if (_padding == value) { return; } _padding = value; MarkNeedsLayout(); } } ``` 実装メソッド名だけを記載するのではなく、その結果として何が起きるかを説明します。 ```csharp // 不十分 /// /// MarkNeedsLayout()を呼び出します。 /// ``` ```csharp // 推奨 /// /// このRenderObjectをLayout Dirtyとしてマークし、 /// 次のパイプライン更新時にサイズと位置を再計算します。 /// ``` 継承元の実装によって Dirty フラグが変更され、その挙動を派生型が変更または追加しない場合は、同じ説明を派生型へ重複して記載しません。派生型が Dirty フラグの種類・伝播範囲・または再実行される処理を変更する場合は、その差分を明示します。 ## 6. 関連型への参照 Widget・Element・RenderObject の間に対応関係がある場合は、API Reference から相互に参照できるようにします。参照対象には、次のような意味上の関係を含みます。 - 対応する Widget - 対応する Element - 対応する RenderObject - 密接に関連する API - 代替となる API - 対になる API 参照には、必要に応じて `` または `` を使用します。 ```csharp /// /// 子要素を利用可能な領域の中央に配置します。 /// /// /// の構成を宣言します。 /// /// public sealed record Center : SingleChildRenderObjectWidget; ``` 専用の Element 型が存在する場合は、対応する Widget と RenderObject を参照します。共通 Element を使用しており、型固有の Element が存在しない場合は、対応関係を説明するためだけに専用 Element を作成しません。 ## 7. 参照しないもの 次の要素には、原則として明示的なリンクを追加しません。 - 継承元の型 - 実装しているインターフェース - 型階層から自動的に判別できる型 - 同じ名前空間に属するだけの型 - 関係が薄い型 継承関係やインターフェースの実装関係は、IDE および API Reference 生成ツールに委ねます。ドキュメントコメントでは、型階層だけでは表現できない意味上の関係を補足します。 ## 8. 継承およびオーバーライド 基底メンバーと契約が完全に同一であり、追加の説明がない場合は、`` を使用できます。 ```csharp /// public override void PerformLayout() { // ... } ``` ただし、次のいずれかに該当する場合は、`` だけで済ませず、変更された契約を明示します。 - 振る舞いを追加または変更する - 例外条件を追加する - Dirty フラグの種類を変更する - Dirty 状態の伝播範囲を変更する - 副作用を追加する - 戻り値や `null` の扱いを変更する - スレッド・所有権・ライフタイムに関する制約を追加する ## 9. 記述言語 XML ドキュメントコメント(`/// `)は**日本語のみ**で書きます。1つのソースに2言語を併記することはしません。これは FloatSoda が日本語をニュートラル(既定)言語とする方針の一部です。 英語版はビルドパイプラインでの機械翻訳(LLM)によるサテライト XML 生成を将来検討していますが、**未実装**です。それまでソースは日本語一本で書き進めて問題ありません。サテライト XML の配置と仕組みの詳細は [Localization](/localization/) を参照してください。 ## 10. 入力語彙 ドキュメントコメントの説明文では、入力デバイスの語彙として「マウス」単独ではなく「ポインター(レーザーポインター/マウス)」のように書きます。これは FloatSoda が型・メンバー名を含めて入力語彙を「ポインター (Pointer)」で統一する方針の一部です。詳細と例外(外部 API の固有名詞をそのまま写す薄いラッパー)は [APIDesign](/apidesign/) の「入力語彙は『ポインター (Pointer)』で統一する」を参照してください。 ## 11. 原則 FloatSoda のドキュメントコメントは、補助的な実装コメントではなく、正式な API Reference を構成する情報です。 - ドキュメントコメントは、公開 API だけでなく、原則としてすべての型およびメンバーに記述する。 - API の役割・引数・型パラメーター・戻り値・例外・制約・副作用・および Dirty 状態の変更は、ドキュメントコメント内で完結させる。 - 長い説明・学習用途の内容・設計背景・実践的な使用例は含めず、ドキュメントサイトへ分離する。 - 型階層はツールに任せ、意味上の関係と観測可能な副作用をコメントで補足する。 原則として、ドキュメントコメントは「何であるか」「どのような契約か」「どのような副作用があるか」を説明し、ドキュメントサイトは利用者に「どのように使うか」を教えます。 ## 関連ページ - [APIDesign](/apidesign/) — API 設計規約(命名・イミュータビリティ・入力語彙) - [Localization](/localization/) — 記述言語とサテライト XML の仕組み - [RenderObjects](/renderobjects/) — Dirty フラグと差分レイアウト・描画の実際 - [Home](/home/) — ドキュメント一覧 # Getting Started > 環境構築・サンプル実行・最初のアプリ作成 ## 前提条件 - .NET 10 SDK - SteamVR がインストール済みで、アプリ実行前に起動していること - OpenVR ランタイム(SteamVR に同梱) ## サンプルアプリを動かす(最速) リポジトリをクローンしたらまずサンプルアプリを起動してフレームワークの動作を確認できます。 ```bash # SteamVR を起動してから実行する dotnet run --project samples/FloatSoda.Samples.OverlayApp ``` 起動すると SteamVR ダッシュボードに、レイアウト、時計、アニメーション、カウンターのデモ用タブが追加されます。左コントローラー追従とワールド座標固定の時計オーバーレイも生成されます(`samples/FloatSoda.Samples.OverlayApp/Program.cs`)。 SteamVR を終了するか `VREvent_Quit` を受信するとアプリも自動終了します。 > サンプルには `StatefulWidget` を使った時計ウィジェット(`WatchWidget.cs`)が含まれており、`SetState()` による毎秒の再ビルドで時刻が更新されます。 ### 用途別のサンプル一覧 `samples/` には目的の異なる5つのプロジェクトがあります。 | プロジェクト | 内容 | SteamVR | |---|---|---| | `FloatSoda.Samples.OverlayApp` | レイアウト・時計・アニメーション・カウンター・ドラッグの総合デモ。3種のオーバーレイを同時に生成する | 必要 | | `FloatSoda.Samples.GettingStarted` | 下の「最小構成のコード」とほぼ同じ最小アプリ | 必要 | | `FloatSoda.Samples.PointerRegion` | ホバー・押下・取り消しの状態を画面に出す入力デモ。`PointerRegion` と `Listener` の挙動を目で確かめられる | 必要 | | `FloatSoda.Samples.PrimitiveOverlay` | ウィジェット層を使わず、`FloatSoda.OVR` の低レベル API だけでオーバーレイを出す | 必要 | | `FloatSoda.Samples.PaintingSample` | Widget / RenderObject / Layer の各ツリーを PNG へ書き出す | **不要** | **`PaintingSample` だけは SteamVR も HMD もいりません。** `FloatSoda.Testing` のヘッドレスレンダラーで ツリーを画像化し、デスクトップへ `widget_tree_output.png` などを保存します。 レイアウトの結果だけを確かめたいときは、HMD をかぶらずにこれで見られます。 ```bash dotnet run --project samples/FloatSoda.Samples.PaintingSample ``` ## 新しいアプリを作る ### 1. プロジェクトを作成する ```bash dotnet new console -n MyOverlayApp cd MyOverlayApp dotnet add reference ../path/to/FloatSoda/src/FloatSoda/FloatSoda.csproj ``` ### 2. 最小構成のコードを書く `Program.cs` を以下のように書き換えます。Widget ベースの書き方が推奨です。 ```csharp using FloatSoda; using FloatSoda.Widgets; using FloatSoda.Widgets.Layout; using FloatSoda.Widgets.Paint; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using SkiaSharp; var builder = Host.CreateApplicationBuilder(args); builder.Services.AddFloatSoda(); using var host = builder.Build(); var app = host.Services.GetRequiredService(); Widget root = new Center { Child = new ColoredBox { Color = SKColors.CornflowerBlue, Child = new SizedBox { Width = 400, Height = 200 } } }; // オーバーレイのサイズは root ウィジェットのレイアウト結果に自動追従します。 app.CreateWindow(new DashboardWindow { Title = "HelloWorld", Child = root }); await host.RunAsync(); ``` ```bash # SteamVR を起動してから実行 dotnet run ``` Window の作成は Host 側で行います。`host.RunAsync()` は SteamVR が終了するまで待機し、SteamVR の終了イベント、Ctrl+C、または Host の停止要求を受けると正常終了します。 > **Widget の実装状況:** レイアウト系(`Center`, `Align`, `Row`, `Column`, `Padding`, `Container`, `Stack`, `Wrap`, `Expanded`, `AspectRatio` など)、描画系(`ColoredBox`, `DecoratedBox`, `Opacity`, `Transform`, `Clip*`)、入力系(`GestureDetector`, `Listener`)は使用可能で、`StatefulWidget` / `InheritedWidget` も動作します。 > `internal` のため公開 API から使えないのは、スクロール系の `ListView` / `GridView` / `SingleChildScrollView` です。画像とアイコンには描画系の `Paint.Image` / `Paint.Icon` を使用できます。 > **`Button` などの UI コンポーネントはまだ提供していません。** 用意する予定の3層構成(`FloatSoda.UI` / `Cream` / `FizzyPop`)は Phase 5 で、現時点では NuGet 未配布・押下も未反応です(→ [UILayering](/uilayering/#実装状況))。ボタンは `GestureDetector` で組み立ててください(→ [WidgetSystem.md](/widgetsystem/#押せるボタンを作る))。
RenderObject レベルの直接操作(低レベル API) ```csharp using FloatSoda; using FloatSoda.Geometrics; using FloatSoda.RenderObjects.Layout; using FloatSoda.RenderObjects.Painting; using FloatSoda.Widgets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using SkiaSharp; var builder = Host.CreateApplicationBuilder(args); builder.Services.AddFloatSoda(); using var host = builder.Build(); var app = host.Services.GetRequiredService(); var root = new RenderPositionedBox { Child = new RenderConstrainedBox { AdditionalConstraints = BoxConstraints.Tight(400, 200), Child = new RenderColoredBox { Color = SKColors.CornflowerBlue } } }; // CreateWindow の Child は Widget を要求するため、RenderObjectWidget でラップします。 Widget widgetRoot = new RawRootWidget { Root = root }; app.CreateWindow(new DashboardWindow { Title = "LowLevel", Child = widgetRoot }); await host.RunAsync(); // 既存の RenderObject を Widget ツリーのルートへ接続する最小ラッパー。 public sealed record RawRootWidget : SingleChildRenderObjectWidget { public required RenderPositionedBox Root { get; init; } public override RenderPositionedBox CreateRenderObject() => Root; } ```
## オーバーレイ種別の選び方 `app.CreateWindow(...)` に渡すウィンドウ定義 `WindowWidget` の種類でオーバーレイ種別を選びます。 `Size` を指定しない場合、オーバーレイのサイズは `Child` ウィジェットのレイアウト結果に追従します (`Size` を指定するとそのサイズで固定されます)。 | ウィンドウ定義 | オーバーレイ種別 | 位置の管理 | ポインタ入力 | |---|---|---|---| | `DashboardWindow { Title, Child, Size? }` | `DashboardOverlay` | SteamVR ダッシュボードが管理(ユーザーが開くタブ) | ✓ 届く | | `WorldSpaceWindow { Title, Child, Size?, Position, Rotation }` | `WorldSpaceOverlay` | ワールド座標で固定(`Vector3 Position`、既定は前方1m・高さ1m) | ✗ 届かない | | `DeviceTrackedWindow { Title, Child, Size?, Target, Offset, Rotation }` | `DeviceTrackedOverlay` | トラッキングデバイスに追従(`TrackedDevice` 列挙体) | ✗ 届かない | **ポインタ入力が届くのはダッシュボードオーバーレイだけです。** SteamVR はダッシュボード上のレーザーポインターを マウスイベントとして送ってくるため、FloatSoda はそれをそのままヒットテストへ流せます。 ワールド座標固定とデバイス追従のオーバーレイには、コントローラーレイからポインタ座標を作る経路がまだありません。 これらのウィンドウに `GestureDetector` を置いてもコンパイルは通り、例外も出ませんが、コールバックは呼ばれません。 `Title` は SteamVR 上の表示名(ダッシュボードタブ名など)です。OpenVR のオーバーレイキーは 「エントリアセンブリ名 + `Title` のスネークケース」から自動生成されます (例: アセンブリ `MyOverlayApp` + `Title = "My Dashboard"` → `my_overlay_app.my_dashboard`)。 ```csharp // ダッシュボード app.CreateWindow(new DashboardWindow { Title = "MyDashboard", Child = root }); // ワールド座標固定。Position 省略時はプレイエリア中央から前方1m・高さ1m (0, 1, -1) app.CreateWindow(new WorldSpaceWindow { Title = "MyWorld", Child = root }); // 左コントローラーに追従 app.CreateWindow(new DeviceTrackedWindow { Title = "MyHand", Child = root, Target = TrackedDevice.LeftController }); ``` ## フレームレート設定 `FloatSodaOptions` でフレームレートを制御できます。 ```csharp // 固定 FPS var builder = Host.CreateApplicationBuilder(args); builder.Services.AddFloatSoda(new FloatSodaOptions { TargetFrameRate = 90 }); ``` `TargetFrameRate` を指定しない場合のデフォルトは 60fps です。オーバーレイアプリはシーンアプリではないため、`WaitGetPoses` によるフレーム同期は利用できません。 ## 関連ページ - [WidgetSystem](/widgetsystem/) — 使えるウィジェットの一覧と実装状況 - [OVRIntegration](/ovrintegration/) — オーバーレイ種別・プロパティ・イベント処理の詳細 - [Architecture](/architecture/) — フレームワーク内部の全体像 # Input — アクション入力(コントローラー) > アクション入力(コントローラーのボタン・トリガー・スティック) FloatSoda.OVR のアクション入力は、Unity Input System に似た語彙(ActionMap → Action → DefaultBinding)で VRコントローラーのボタン・トリガー・スティックを扱うAPIです。定義はすべてC#コードで完結し、 アクションマニフェストJSONは自動生成されます。**ユーザーがJSONファイルを書くことはありません。** > オーバーレイUI上のポインタ操作(ボタンのクリック等)はウィジェットのヒットテストが担当します。 > このページのアクション入力は「UIの外」のアプリレベル入力(ショートカット、掴む、スクロール等)用です。 ## 最小の例 ```csharp using FloatSoda.OVR.Input; var grab = new InputAction { Name = "grab", SuggestedPath = "/user/hand/right/input/trigger/click", }; builder.Services.AddFloatSoda(new FloatSodaOptions { AppKey = new AppKey("com.example.myoverlay"), InputActionMaps = [new InputActionMap { Name = "main", Actions = [grab] }], }); grab.OnPerformed += _ => Console.WriteLine("トリガーが引かれた"); grab.OnReleased += () => Console.WriteLine("トリガーが離された"); // 毎フレームの値参照は grab.Value ``` ## アクションの型 `InputAction` の `T` は次の3つだけです。それ以外の型は初期化時に例外になります。 | 型 | 用途 | イベント | |---|---|---| | `bool` | ボタン/クリック | `OnPerformed`(押下エッジ)/ `OnReleased` | | `float` | トリガー引き量など1軸 | `OnPerformed`(値が変化したフレーム) | | `System.Numerics.Vector2` | スティック/トラックパッド | `OnPerformed`(値が変化したフレーム) | ## Unity Input System との対応 | Unity Input System | FloatSoda | OpenVR (IVRInput) | |---|---|---| | InputActionAsset | `FloatSodaOptions.InputActionMaps` | アクションマニフェストJSON(自動生成) | | InputActionMap | `InputActionMap` | アクションセット `/actions/{name}` | | InputAction | `InputAction` | アクション `/actions/{map}/in/{name}` | | InputBinding | `DefaultBinding` / `SuggestedPath` | デフォルトバインディングJSON(自動生成) | | `action.performed` | `OnPerformed` | — | | `action.ReadValue()` | `Value` | `GetDigitalActionData` / `GetAnalogActionData` | | `map.Enable()` / `Disable()` | `InputActionMap.Enabled` | `UpdateActionState` の対象選択 | ## バインディングの決定権はSteamVRにある Unityと最も違う点です。`DefaultBinding` / `SuggestedPath` は **初期割り当ての「提案」** であり、 実際のバインディングはSteamVRが管理し、ユーザーがSteamVRのコントローラーバインディングUIで いつでも変更できます。このため次のAPIは **意図的に存在しません**。 - 実行時にバインディングを追加/変更する(`AddBinding` / `ApplyBindingOverride` 相当) - アクションから現在のバインディングを列挙する(`action.bindings` 相当) - composite binding(複数ボタンの合成)を定義する ## デフォルトバインディングの書き方 - `SuggestedPath` — 1本のOpenVR入力パスを全コントローラー種別(Index / Vive Wand / Oculus Touch)へ複製します。まずはこれで十分です。 - `DefaultBindings` — コントローラー種別ごとにパスを変えたいときに使います。指定した種別にだけ出力されます。 パスはOpenVR正規表記です(SteamVRのバインディングUIに表示される表記と一致します)。 ``` /user/hand/right/input/trigger/click … トリガーをボタンとして(bool) /user/hand/right/input/trigger/pull … トリガー引き量(float) /user/hand/right/input/thumbstick … スティック(Vector2) /user/hand/left/input/a/click … 左手Aボタン(bool) ``` ## 注意: オーバーレイアプリとアクションセットの競合 FloatSodaアプリはオーバーレイアプリなので、シーンアプリ(VRChat等のゲーム本体)と同時に動きます。 同じ物理入力をシーンアプリも使っている場合の優先はSteamVRのアクションセット優先度に従います。 ゲームの操作と衝突しにくい入力(使われていないボタン、非利き手側など)をデフォルトに選ぶことを推奨します。 ## 生成物の場所 アクションマニフェスト一式は `%TEMP%/FloatSoda/{AppKey}/input/` に毎起動時に生成され、 `IVRInput.SetActionManifestPath` で登録されます。デバッグ時はこのJSONを直接確認できます。 # Localization > ローカライゼーション方針(日本語デフォルト・resx・サテライトXML) このドキュメントは、FloatSoda のローカライゼーション方針(言語の優先順位・リソースの持ち方・翻訳の追加手順)をまとめたものです。 ## 1. 基本方針: 日本語がデフォルト FloatSoda は**日本語をニュートラル(既定)言語**とし、英語をサテライトリソースとして提供します。 これは意図的な選択です。[TargetUsers](/targetusers/) が定義する3ペルソナ(バイブコーディングする VRChatter・Booth 創作者・uGUI を避けたいエンジニア)はいずれも日本語話者を第一に想定しており、`docs/` も日本語で書かれています。例外メッセージ・ドキュメントコメント・ドキュメントの言語が日本語で揃うことで、バイブコーディング時に LLM がユーザーへ提示するエラー説明の言語も一致します。 対象ごとの方針は次の通りです。 | 対象 | 方針 | |---|---| | 例外メッセージ | resx でローカライズ。ニュートラル = 日本語、`en` サテライト = 英語(実装済み) | | XML ドキュメントコメント | ソースには**日本語のみ**を書く。英語版は将来サテライト XML で後付け(未実装、§4) | | `docs/` 配下のドキュメント | 日本語のみ | | テストメソッド名 | `対象メンバー名_条件_期待結果` の条件・期待結果を日本語で書く(規約は [CONTRIBUTING.md](https://github.com/sumx21t-3310/FloatSoda/blob/main/CONTRIBUTING.md)) | | API 識別子(型名・プロパティ名) | 英語(.NET の慣習通り。ローカライズ対象外) | > **コントリビュータへ**: この方針を知らずに「英語がデフォルトであるべき」と直したくなるかもしれませんが、ニュートラル = 日本語は意図的な設計判断です。変更する場合は必ず issue で議論してください。 ## 2. フォールバックの仕組み リソース解決は `CultureInfo.CurrentUICulture`(通常は OS の表示言語)に基づく .NET 標準のフォールバックに従います。 ```mermaid graph LR A["CurrentUICulture = ja-JP"] -->|"ja サテライトなし →
ニュートラルへ"| N["ニュートラル (日本語)"] B["CurrentUICulture = en-US"] -->|"en サテライトあり"| E["en サテライト (英語)"] C["CurrentUICulture = de-DE など
(未対応言語)"] -->|"de サテライトなし →
ニュートラルへ"| N ``` 重要な含意が1つあります: **日本語でも英語でもない環境(ドイツ語 Windows など)では日本語が表示されます**。国際展開を最優先するなら「ニュートラル = 英語、`ja` サテライト = 日本語」が定石ですが、FloatSoda は主客層と Booth 流通を優先して「迷ったら日本語」を選んでいます。 ## 3. 例外メッセージ (実装済み) `FloatSoda.OVR` の例外メッセージは resx でローカライズされています。 | ファイル | 役割 | |---|---| | `src/FloatSoda.OVR/Exceptions/Resources/ExceptionMessages.resx` | ニュートラルリソース(**日本語**)。メインアセンブリに埋め込まれる | | `src/FloatSoda.OVR/Exceptions/Resources/ExceptionMessages.en.resx` | 英語サテライト。`en/FloatSoda.OVR.resources.dll` として出力される | | `src/FloatSoda.OVR/Exceptions/Resources/ExceptionMessages.cs` | 強い型付けアクセサ。`ResourceManager.GetString(key, CurrentUICulture)` で解決し、見つからなければキー文字列そのものを返す | キーの命名は `{例外クラス名}_{OpenVRエラー列挙子}` です(例: `VRApplicationException_NoManifest`)。OpenVR のエラー列挙値と1対1対応させています。 ```xml アプリケーションマニフェストが見つかりません。 The application manifest was not found. ``` ### メッセージを追加する手順 1. `ExceptionMessages.resx` に日本語で `` エントリを追加する 2. `ExceptionMessages.en.resx` に**同じキー**で英語エントリを追加する(両ファイルのキー集合は常に一致させる) 3. `ExceptionMessages.cs` に対応する静的プロパティを追加する 4. 例外クラス側からそのプロパティを参照する ### メッセージの文体 - 日本語: 敬体(「〜です」「〜ます」「〜できません」)。句点で終える - 英語: 平叙文。ピリオドで終える - どちらも「何が起きたか」を1文で述べる。対処法は例外の XML ドキュメントコメント側に書く ### 既知の未対応事項 `[assembly: NeutralResourcesLanguage("ja")]` は現在未設定です。設定すると、日本語環境で存在しない `ja` サテライトを探しに行くコストが省け、「ニュートラル = 日本語」という設計判断がコード上にも明示されます。 ## 4. XML ドキュメントコメント ソースコードの XML ドキュメントコメントを**日本語のみ**で書く方針は [DocumentationComments](/documentationcomments/) に移動しました。ここではローカライズの仕組みだけを扱います。 resx と違い、XML ドキュメントコメントはビルド時に `FloatSoda.OVR.xml` のような単一の XML ファイルに書き出されるため、resx の仕組みではローカライズできません。ローカライズする場合は、NuGet パッケージの `lib/net10.0/{culture}/FloatSoda.OVR.xml` にカルチャ別の翻訳済み XML を同梱する方式(サテライト XML)になります。Visual Studio / Rider の IntelliSense はこの配置を認識します。 英語版サテライト XML の生成は**未実装**です。将来的にビルドパイプラインで機械翻訳(LLM)による生成を検討していますが、それまでソースは日本語一本で書き進めて問題ありません。 ## 5. 新しいアセンブリでローカライズが必要になったら 現在ローカライズ済みリソースを持つのは `FloatSoda.OVR` のみです。他のアセンブリ(`FloatSoda` 本体や `FloatSoda.UI.*`)でユーザーに露出する文字列が必要になった場合も、同じパターンを踏襲してください。 - `Exceptions/Resources/`(またはリソースの種類に応じたフォルダ)に `{用途}Messages.resx`(日本語)+ `{用途}Messages.en.resx`(英語)を置く - 強い型付けアクセサクラスを手書きし、`GetString(key, CurrentUICulture) ?? key` のフォールバックを付ける - キー欠落で例外を投げない(リソース解決の失敗でアプリを落とさない) ただし [APIDesign](/apidesign/) の設計哲学に注意: **UI に表示する文字列はフレームワークが持たない**のが原則です。`new Text(...)` に渡す文字列は利用者のアプリのものであり、フレームワークのローカライズ対象はエラーメッセージ・診断メッセージに限られます。 ## 関連ページ - [TargetUsers](/targetusers/) — 日本語デフォルトの根拠となる想定利用者 - [APIDesign](/apidesign/) — API 設計規約(識別子は英語) - [OVRIntegration](/ovrintegration/) — 例外型の全体像 # OpenVR インテグレーション > OpenVR ラッパー・オーバーレイ種別・イベント処理 `FloatSoda.OVR` アセンブリは OpenVR API をラップし、型安全なオーバーレイ操作を提供します。 ## OVRApplication — OpenVR 初期化 `OVRApplication` クラスはコンストラクタ呼び出し時に `OpenVR.Init()` を実行します。初期化には、検証済みのアプリケーションキーを含む `OVRAppInfo` を渡します。 ```csharp using var app = new OVRApplication( new OVRAppInfo(new AppKey("my_overlay_app"), ApplicationType.Overlay)); // app.OVRSystem → CVRSystem(低レベル OpenVR API) // app.Info.Type → ApplicationType.Overlay ``` `builder.Services.AddFloatSoda()` は `FloatSodaOptions.AppKey` から `OVRAppInfo` を登録します。`host.RunAsync()` でFloatSodaのHostedServiceが開始されると、内部で `OVRApplication` を生成するため、通常は直接インスタンス化する必要はありません。 ### ApplicationType | 値 | 説明 | |---|---| | `Overlay` | オーバーレイ専用アプリ(FloatSoda のデフォルト) | | `Scene` | 3D シーンを描画するアプリ | | `Background` | SteamVR を起動しないバックグラウンドアプリ | | `Utility` | ハードウェア不要のユーティリティ(インストーラーなど) | ## オーバーレイ種別 ```mermaid classDiagram IOverlay <|-- IDashboardOverlay IOverlay <|-- IMovableOverlay IMovableOverlay <|-- IMovableOverlay~TTransform~ IDashboardOverlay <|.. DashboardOverlay IMovableOverlay~TTransform~ <|.. MovableOverlay~TTransform~ MovableOverlay~TTransform~ <|-- WorldSpaceOverlay MovableOverlay~TTransform~ <|-- DeviceTrackedOverlay ``` | クラス | 位置管理 | `Visibility` | `Transform` | |---|---|---|---| | `DashboardOverlay` | SteamVR ダッシュボードが管理 | なし(ダッシュボードに出現) | なし | | `WorldSpaceOverlay` | ワールド座標で固定 | あり | `WorldOverlayTransform` | | `DeviceTrackedOverlay` | トラッキングデバイスに追従 | あり | `DeviceTrackedOverlayTransform` | ### DashboardOverlay ```csharp var identity = new DashboardOverlayIdentity("アプリ名", "ウィンドウ名"); var overlay = new DashboardOverlay(identity); // overlay.Opacity.Value = 0.9f; // overlay.WidthInMeters.Value = 1.5f; ``` ### WorldSpaceOverlay ```csharp var identity = new OverlayIdentity("MyApp", "WorldWindow"); var overlay = new WorldSpaceOverlay(identity); overlay.Visibility.Show(); overlay.Transform.Position = new Vector3(0, 1.5f, -2f); // メートル単位 overlay.Transform.Rotation = Quaternion.CreateFromYawPitchRoll(0, 0, 0); ``` ### DeviceTrackedOverlay ```csharp var identity = new OverlayIdentity("MyApp", "HandWindow"); var overlay = new DeviceTrackedOverlay(identity); overlay.Visibility.Show(); overlay.Transform.Target = TrackedDevice.LeftController; overlay.Transform.Position = new Vector3(0, 0.05f, 0); // コントローラー相対オフセット ``` `TrackedDevice` の値: `LeftController`, `RightController`, `HMD` ## オーバーレイプロパティ すべての `IOverlay` 実装は以下のプロパティ(ケーパビリティオブジェクト)を持ちます。 | プロパティ | 型 | 説明 | |---|---|---| | `Opacity` | `OverlayOpacity` | `Value` (0.0–1.0) でアルファを設定 | | `WidthInMeters` | `OverlayWidthInMeters` | `Value` でワールド幅(メートル)を設定 | | `Curvature` | `OverlayCurvature` | `Value` で曲率を設定 | | `Texture` | `OverlayTexture` | `FromTexture_t()` / `FromFile()` でテクスチャを更新 | | `State` | `OverlayState` | `[VROverlayFlags.X]` でフラグを読み書き | | `Vibration` | `OverlayVibration` | ハプティックフィードバックを発火 | | `Input` | `OverlayInput` | 入力方式とホバー状態を管理 | | `EventDispatcher` | `OverlayEventDispatcher` | そのオーバーレイ固有のイベントを配送 | 派生インターフェース固有のプロパティ: | 対象 | プロパティ | 説明 | |---|---|---| | `IDashboardOverlay` | `Thumbnail` | ダッシュボード用サムネイルテクスチャ | | `IMovableOverlay` | `Visibility` | `Show()` / `Hide()` で表示状態を管理 | | `IMovableOverlay` | `Intersection` | レイとオーバーレイ表面の交差判定 | | `IMovableOverlay` | `Transform` | `Position`, `Rotation` で位置・向きを設定 | ## VREventDispatcher `VREventDispatcher` は `PollEvents()` を呼ぶたびに OpenVR のイベントキューを消費し、登録されたハンドラを呼び出します。抽象クラスであり、用途に応じたサブクラスを使います。 | クラス | 用途 | |---|---| | `VRSystemEventDispatcher` | `CVRSystem` から取得するグローバルイベント(`VREvent_Quit` など) | | `OverlayEventDispatcher` | 特定のオーバーレイ固有のイベント(インタラクション・入力) | ```csharp var dispatcher = new VRSystemEventDispatcher(); dispatcher.Register(EVREventType.VREvent_Quit, (in VREvent_t _) => { application.OVRSystem.AcknowledgeQuit_Exiting(); // 終了処理... }); // メインループ内で毎フレーム呼ぶ dispatcher.PollEvents(); ``` FloatSodaのHostedServiceは `VREvent_Quit` / `VREvent_ProcessQuit` を自動登録し、受信時にGeneric Host全体へ停止を通知します。 ## 例外体系 OpenVR API のエラーはすべて型付き例外に変換されます。 | 例外クラス | 発生タイミング | |---|---| | `VRInitializeException` | `OVRApplication` 初期化時(SteamVR が起動していないなど) | | `VROverlayException` | オーバーレイ作成・操作エラー | | `VRCompositorException` | Compositor 操作エラー | | `VRInputException` | 入力システムエラー | | `VRApplicationException` | アプリケーション登録エラー | | `TrackedPropertyException` | トラッキングプロパティ取得エラー | `OpenVRExceptionHelper.ThrowIfError()` が各 OpenVR API の戻り値を検査して例外を投げます。ケーパビリティオブジェクト内部で自動的に呼ばれるため、通常は手動で呼ぶ必要はありません。 ```csharp try { using var app = new OVRApplication( new OVRAppInfo(new AppKey("my_overlay_app"), ApplicationType.Overlay)); // ... } catch (VRInitializeException ex) { Console.Error.WriteLine($"SteamVR 初期化失敗: {ex.Message}"); } ``` ## Math — Matrix ヘルパー `FloatSoda.OVR.Math.Matrix` は `System.Numerics.Matrix4x4` と OpenVR の `HmdMatrix34_t` を相互変換するヘルパーです。 ```csharp // Matrix4x4 → HmdMatrix34_t(OverlayTransform.Apply() 内部で使用) var hmd = matrix4x4.ToHmdMatrix34_t(); OpenVR.Overlay.SetOverlayTransformAbsolute(handle, origin, ref hmd); ``` `OverlayTransform` サブクラスを実装する場合は `GetMatrix()` が `Position` + `Rotation` から `Matrix4x4` を生成するので、`Apply()` で `ToHmdMatrix34_t()` を呼ぶだけで済みます。 ## 関連ページ - [GettingStarted](/gettingstarted/) — オーバーレイ作成の高レベル API(`CreateWindow` と `DashboardWindow` など) - [Architecture](/architecture/) — オーバーレイテクスチャへのレンダリング経路 # RenderObject ツリー > RenderObject ツリーのリファレンス(レイアウト・描画) RenderObject ツリーはレイアウト計算と描画コマンド記録を担う低レベル API です。`FloatSoda` の描画は最終的にすべてこのツリーを通過します。 ## 基本契約 ```csharp // 最小限のカスタム RenderBox public class MyRenderBox : RenderBox { public override void PerformLayout() { Size = Constraints.Constrain(new SKSize(200, 100)); // 制約内に収める } public override void Paint(PaintingContext context, Offset offset) { var rect = SKRect.Create(offset.X, offset.Y, Size.Width, Size.Height); context.Canvas.DrawRect(rect, new SKPaint { Color = SKColors.Coral }); } } ``` | メソッド / プロパティ | 役割 | |---|---| | `Layout(BoxConstraints)` | フレームワークが呼ぶエントリポイント。制約と RelayoutBoundary を判定し、必要なときだけ `PerformLayout()` を呼ぶ(オーバーライド不可) | | `PerformLayout()` | サブクラスが実装する。`Constraints` を参照して自身の `Size` を決定し、子の `Layout` を呼ぶ | | `Paint(PaintingContext, Offset)` | `context.Canvas` に Skia 描画コマンドを記録する | | `Size` | `PerformLayout` で確定したサイズ(`SKSize`) | | `Constraints` | 直近の `Layout` で親から渡された `BoxConstraints` | ## 制約フロー レイアウトは **制約は下へ・サイズは上へ** の原則で動きます。 ``` RenderView (tight 制約: ビューポートサイズ) └─ RenderFlex ├─ RenderConstrainedBox (追加制約を合成) │ └─ RenderColoredBox → size を親に返す └─ RenderColoredBox → size を親に返す ``` `BoxConstraints` の主なファクトリ: | ファクトリ | 意味 | |---|---| | `BoxConstraints.Tight(w, h)` | 幅・高さを固定 | | `BoxConstraints.TightFor(width: w)` | 幅だけ固定、高さはフリー | | `constraints.Loosen()` | min を 0 に緩める(子が自由にサイズを決められる) | | `constraints.Enforce(other)` | 別の制約で上書き | ## 差分更新(dirty フラグ) RenderObject は Flutter と同様に **変更があった部分だけを再レイアウト・再ペイント** します。プロパティを変更したら `MarkNeedsLayout()` / `MarkNeedsPaint()` を呼ぶのが契約です([BuildPipeline](/buildpipeline/) の `UpdateRenderObject` から呼ばれるのが典型)。 ### MarkNeedsLayout と RelayoutBoundary `MarkNeedsLayout()` は自身の `NeedsLayout` を立て、**RelayoutBoundary**(自分のサイズ変更が親に影響しない境界。tight 制約を受けたノードなどが該当)まで親方向に伝播します。境界ノードが `RenderPipeline.NodesNeedingLayout` に登録され、`FlushLayout()` が `Depth` 順に `LayoutWithoutResize()` を呼びます。 ### MarkNeedsPaint と RepaintBoundary `MarkNeedsPaint()` は `IsRepaintBoundary == true` のノードまで親方向に伝播し、そのノードが `RenderPipeline.NodesNeedingPaint` に登録されます。`FlushPaint()` が `PaintingContext.RepaintCompositedChild()` で再記録します。 境界になるのは `RenderView`(ツリーのルート。常に境界)と `RenderRepaintBoundary`(`RepaintBoundary` ウィジェットの実体)です。毎フレーム変化する部分を `RepaintBoundary` で囲むと、その内側の `MarkNeedsPaint()` が祖先へ伝播しなくなり、変化していない周囲を再描画せずに済みます。 いずれの場合も `RenderPipeline.RequestVisualUpdate()` が呼ばれ、`WidgetBinding` に「このフレームは描画が必要」と通知されます。変更がないフレームではレイアウトもペイントも実行されません。 > **Semantics 系の dirty フラグは持ちません:** Flutter の `markNeedsSemanticsUpdate()` に相当する API は FloatSoda では実装していません。理由は [APIDesign § 実装しない API — Semantics](/apidesign/#実装しない-api--semantics-アクセシビリティツリー) を参照してください。Flutter 本家から RenderObject を移植する際も、semantics 関連のフックは削除します。 ## intrinsic 測定 通常のレイアウトは「制約を渡してサイズを受け取る」一方通行です。これに対し intrinsic 測定は、 **制約を渡す前に「子が本来ほしがっているサイズ」を問い合わせる**仕組みです。 `IntrinsicWidth` / `IntrinsicHeight` や `RenderFlex` の一部の配置計算がこれを使います。 `RenderBox` は4つの問い合わせ口を持ちます。 | メソッド | 意味 | |---|---| | `GetMinIntrinsicWidth(height)` | この高さで、内容を切り詰めずに描ける最小の幅 | | `GetMaxIntrinsicWidth(height)` | この高さで、これ以上広げても見た目が変わらない幅 | | `GetMinIntrinsicHeight(width)` | この幅で、内容を切り詰めずに描ける最小の高さ | | `GetMaxIntrinsicHeight(width)` | この幅で、これ以上高くしても見た目が変わらない高さ | サブクラスは対応する `ComputeMinIntrinsicWidth(double)` などを `protected override` で実装します。 `RenderProxyBox` は既定で子へそのまま委譲します。実装しないまま問い合わせを受けると `NotSupportedException` になります。 > **コストに注意:** intrinsic 測定は通常のレイアウトとは別にツリーを走査します。 > 入れ子にすると走査が掛け算で増え、最悪 O(N²) になります。 > 寸法があらかじめ分かっている場合は `SizedBox` や `ConstrainedBox` を使ってください。 ## PaintingContext とレイヤーツリー `Paint` の引数 `PaintingContext` は Skia のキャンバスを抽象化したものです。ドローコールを記録し、`PictureLayer`(`SKPicture`)としてレイヤーツリーに蓄積します。 クリッピングやオパシティを挟む場合は `PushClip*` / `PushOpacity` を使います。 ```csharp // クリップレイヤーを挿入してから子を描画 context.PushClipRect(childOffset, clipRect, Clip.Antialias, (ctx, off) => { child.Paint(ctx, off); }); ``` レイヤーツリーは `ILayer` の階層で構成されます: | レイヤー | 役割 | |---|---| | `ContainerLayer` | 子レイヤーをまとめるノード | | `PictureLayer` | `SKPicture`(Skia の記録済み描画コマンド)を保持するリーフ | | `ClipRectLayer` / `ClipRoundRectLayer` / `ClipPathLayer` | 矩形・角丸・パスのクリッピング | | `OpacityLayer` | アルファ合成 | | `TransformLayer` | 変換行列を適用 | ## 組み込み RenderObject 一覧 ### Layout | クラス | 説明 | 主なプロパティ | |---|---|---| | `RenderView` | ルート。ビューポートサイズの tight 制約を子に渡す | `Child`, `Layer` | | `RenderFlex` | Flex レイアウト(`Row` / `Column` の実体) | `Direction`, `MainAxisAlignment`, `CrossAxisAlignment`, `MainAxisSize`, `Children` | | `RenderWrap` | 主軸が尽きたら `run` へ折り返す Flex | `Direction`, `Spacing`, `RunSpacing`, `Alignment`, `RunAlignment`, `CrossAxisAlignment`, `Children` | | `RenderStack` | 子を重ね、非 `Positioned` 子を `Alignment` で配置 | `Alignment`, `Fit`, `Children` | | `RenderIndexedStack` | `RenderStack` を継承し、`Index` の子だけを描画・ヒットテスト | `Index`, `Alignment`, `Fit`, `Children` | | `RenderPositionedBox` | 子をアライメントで配置 | `Alignment`, `WidthFactor`, `HeightFactor` | | `RenderPadding` | 制約を余白分だけ縮小し、子を余白の内側へ配置 | `Padding` (`EdgeInsets`) | | `RenderConstrainedBox` | 追加の `BoxConstraints` を子に強制 | `AdditionalConstraints` | | `RenderConstraintsTransformBox` | 親制約を `BoxConstraintsTransform` で変換して子へ渡す | `ConstraintsTransform` (required), `Alignment`, `ClipBehavior` | | `RenderAspectRatio` | 幅対高さの比率を保った固定寸法を子へ適用 | `AspectRatio` (required) | | `RenderFittedBox` | 子を自然サイズでレイアウトし、`BoxFit` に従って変換 | `Fit`, `Alignment`, `ClipBehavior` | | `RenderLimitedBox` | 親の上限が無限の軸だけに最大寸法を適用 | `MaxWidth`, `MaxHeight` | | `RenderConstrainedOverflowBox` | 親と異なる制約を子へ渡し、領域外への描画を許す(`OverflowBox` の実体) | `MinWidth`, `MaxWidth`, `MinHeight`, `MaxHeight`, `Fit`, `Alignment` | | `RenderFractionallySizedOverflowBox` | 親の最大寸法に割合を掛けた tight 制約を子へ渡す | `WidthFactor`, `HeightFactor`, `Alignment` | | `RenderSizedOverflowBox` | 自身は指定サイズを採り、子へは親の元の制約を渡す | `RequestedSize`, `Alignment` | | `RenderIntrinsicWidth` | 子の最大 intrinsic 幅へ収縮する | `StepWidth` | | `RenderIntrinsicHeight` | 子の最大 intrinsic 高さへ収縮する | `StepHeight` | | `RenderRotatedBox` | 90度単位でレイアウト寸法ごと回転する | `QuarterTurns` | | `RenderOffstage` | 子をレイアウトしたまま描画・ヒットテストから外す | `Offstage` | | `RenderProxyBox` | レイアウト・ペイントを子に委譲するパススルー基底 | `Child` | ### Painting | クラス | 説明 | 主なプロパティ | |---|---|---| | `RenderColoredBox` | 単色で塗りつぶし、子をその上に描画 | `Color` (`SKColor`) | | `RenderDecoratedBox` | `BoxDecoration` の背景色・角丸・ボーダーを子の前面または背面へ描画 | `Decoration`, `Position` | | `RenderOpacity` | `OpacityLayer` を挿入して固定の不透明度を適用 | `Opacity` | | `RenderTransform` | レイアウト後に `Matrix3x2` の2次元変換を適用 | `Transform`, `Origin`, `Alignment`, `TransformHitTests` | | `RenderRepaintBoundary` | `IsRepaintBoundary` を `true` にし、再ペイントの伝播をここで止める | `Child` | | `RenderCustomClip` | クリップ系の抽象基底 | `Clipper`, `ClipBehavior` | | `RenderClipRect` | 矩形でクリップ | `ClipBehavior` | | `RenderClipRoundRect` | 角丸矩形でクリップ | `BorderRadius`, `ClipBehavior` | | `RenderClipOval` | 楕円でクリップ | `ClipBehavior` | | `RenderClipPath` | カスタム `SKPath` でクリップ | `Clipper` (`CustomClipper`), `ClipBehavior` | ### Gesture すべて `RenderProxyBox` を継承し、レイアウトと描画には手を加えず、ヒットテストの結果だけを変えます。 対応する Widget 側の説明は [WidgetSystem § ジェスチャとヒットテスト](/widgetsystem/#ジェスチャとヒットテスト) を参照してください。 | クラス | 説明 | 主なプロパティ | |---|---|---| | `RenderPointerListener` | ヒットしたポインターイベントをコールバックへ流す | `OnPointerDown`, `OnPointerUp`, `OnPointerMove`, `OnPointerEnter`, `OnPointerExit`, `OnPointerCancel`, `Behaviour` | | `RenderPointerRegion` | `RenderPointerListener` の派生。ホバー(Enter / Exit)だけを扱う | `OnPointerEnter`, `OnPointerExit`, `Behaviour` | | `RenderAbsorbPointer` | 自身をヒットさせたうえで、子へのヒットテストを止める | `Absorbing` | | `RenderIgnorePointer` | 自身と子をヒットテストから外す | `Ignoring` | ### Animation | クラス | 説明 | 主なプロパティ | |---|---|---| | `RenderAnimatedOpacity` | `IAnimation` を購読し、値変化フレームのみ再ペイントして不透明度を適用(→ [Animation](/animation/)) | `Opacity` (`IAnimation`) | ### Content | クラス | 説明 | 主なプロパティ | |---|---|---| | `RenderParagraph` | `RichText` のテキストレイアウト・描画エンジン(Topten.RichTextKit 使用) | `Text` (`TextSpan`) | | `RenderImage` | `SKImage` を `BoxFit` に従って描画 | `Image` (required), `Fit`, `Alignment` | ## カスタムクリッパーの実装 `CustomClipper` を継承して `GetClip(SKSize)` でパスを返します。 ```csharp // 下端が波打つ形状のクリッパー(ArcClipper を参照) public class ArcClipper : CustomClipper { public override SKPath GetClip(SKSize size) { var path = new SKPath(); path.LineTo(0f, size.Height - 30); path.QuadTo( new SKPoint(size.Width / 4, size.Height), new SKPoint(size.Width / 2, size.Height)); path.QuadTo( new SKPoint(size.Width * 3 / 4, size.Height), new SKPoint(size.Width, size.Height - 30)); path.LineTo(size.Width, 0); path.Close(); return path; } public override bool ShouldReclip(CustomClipper oldClipper) => false; } // 使用 var clipped = new RenderClipPath { Clipper = new ArcClipper(), Child = new RenderConstrainedBox { AdditionalConstraints = BoxConstraints.Tight(300, 300), Child = new RenderColoredBox { Color = SKColors.Tomato } } }; ``` ## RenderPipeline `RenderPipeline` は `WidgetBinding.DrawFrame()` から毎フレーム呼ばれます。dirty なノードのリスト(`NodesNeedingLayout` / `NodesNeedingPaint`)を保持し、`Flush*` で消化します。 ```csharp pipeline.FlushLayout(); // NodesNeedingLayout を Depth 順に LayoutWithoutResize() pipeline.FlushPaint(); // NodesNeedingPaint を RepaintCompositedChild() で再記録 var layer = pipeline.RenderView.Layer?.Clone(); // スレッドセーフにコピー ``` dirty なノードがないフレームでは何も行われません。初回フレームは `RenderView.PrepareInitialFrame()` がルートを両リストに登録することで全体をレイアウト・ペイントします。 ## 関連ページ - [BuildPipeline](/buildpipeline/) — Widget 側から RenderObject が更新される流れ - [Architecture](/architecture/) — レイヤーツリーとレンダースレッドへの受け渡し - [WidgetSystem](/widgetsystem/) — 各 RenderObject に対応する Widget # Target Users > FloatSoda が想定する3タイプの作り手と読み進め方 FloatSoda は、次の3タイプの作り手を想定して設計されています。自分がどれに近いかで、ドキュメントの読み方とスタート地点が変わります。 ## 1. AI と一緒に「自分用のツール」を作りたい VRChatter VR で遊んでいて「こういうのがあれば便利なのに」と思ったことがあれば、あなたは対象読者です。**コードが書けなくても構いません。** FloatSoda は、Claude や ChatGPT などの AI にコードを書いてもらう「バイブコーディング」で完結できることを設計目標にしています。 - UI がシーンファイルやプレハブを持たず **すべて C# コード** なので、AI がそのまま生成・修正できます - このドキュメント群(docs/)自体が、AI に読ませて正しいコードを書かせるための一次情報です 作れるものの例(FloatSoda は現在 Phase 1(入力基盤)と Phase 2(表示系ウィジェット)が並行して進行中です。各 Phase の詳細は [GitHub のマイルストーン](https://github.com/sumx21t-3310/FloatSoda/milestones) を参照): | 作りたいもの | 使う機能 | 作れるようになる Phase | |---|---|---| | 時刻・FPS・配信コメントを流す表示専用 HUD | テキスト表示 + テーマ + フェードアニメーション | 現時点で可能 | | FaceEmo の表情セットを OSC で切り替えるパネル | ボタングリッド + OSC 送信 | 現時点で可能(ダッシュボードオーバーレイに限る。ボタンは `GestureDetector` で自作する) | | VRChat の写真フォルダを VR 内で眺めるアルバム | 画像グリッド + スクロール | Phase 3(スクロール。画像ウィジェットは Phase 2) | | お気に入りフレンドがログインしたら出るトースト通知 | 通知オーバーレイ + バックグラウンド監視 | Phase 4(通知・テキスト入力) | **→ まずは [GettingStarted](/gettingstarted/) のサンプルを動かし、あとは AI に「これを改造して◯◯を作って」と頼んでください。** ## 2. Booth でオーバーレイ作品を売りたいクリエイター Unity でワールドやギミックを作れるなら、FloatSoda のオーバーレイ開発に必要な力は十分あります。新しく覚えるのは「宣言的 UI」という考え方だけです。 - **シーンも プレハブも `.meta` もありません。** Hierarchy に相当するものは、コード上の Widget ツリーです - uGUI の「オブジェクトを置いて、スクリプトから `text.text = ...` で書き換える」方式と違い、FloatSoda では「状態を変えると、UI がそれに合わせて再構築される」方式(`SetState`)を取ります - UI が全部コードなので、ドキュメントのコード片をコピペすればそのまま動きます。商品のサポートや説明にもコードを貼れます - exe としてのビルド・配布は通常の .NET アプリと同じ手順です **→ [GettingStarted](/gettingstarted/) → [WidgetSystem](/widgetsystem/) の順に読んでください。** ## 3. uGUI を使いたくないエンジニア シーンとプレハブの YAML、GUID 参照、読めない diff、レビューできない UI 変更にうんざりしているなら、FloatSoda の存在理由はまさにそれです。 - **UI が 100% C# コード**です。diff が読め、PR レビューができ、grep が効き、生成 AI も扱えます - Flutter の三ツリーモデル(Widget / Element / RenderObject)を .NET 上に実装しています。`StatelessWidget` / `StatefulWidget` / `InheritedWidget` による状態管理がそのまま使えます - Unity ランタイムに依存しません。素の .NET + SkiaSharp + OpenVR で完結します **→ [Architecture](/architecture/) と [APIDesign](/apidesign/) を読むと設計思想が掴めます。** ## この想定が設計に与えている影響 - **「コードに書けない状態」を作らない**: UI・テーマ・レイアウトはすべて C# コードで表現でき、外部のアセットファイルや GUI エディタを要求しません。これは 1(AI が生成できる)と 3(diff・レビューが効く)の両方の前提です - **API は誤用しにくさを優先**: object-initializer 中心・`required` プロパティ・イミュータブルな設計([APIDesign](/apidesign/))は、人間の初学者と AI の両方がコンパイルエラーの段階で間違いに気づけるようにするためです # UIレイヤリング(3層パッケージ構成) > UI層の3層パッケージ構成(ヘッドレス / デザインシステム)。設計方針であり未提供 > **このページは実装ではなく設計方針です。** `FloatSoda.UI` / `FloatSoda.UI.Cream` / `FloatSoda.UI.FizzyPop` の > 3プロジェクトは、いずれも `IsPackable=false` で **NuGet に配布していません**。 > リポジトリには `ButtonBase` / `InteractionState` / `Button` / `ButtonStyle` / 各テーマの型が置いてありますが、 > 骨組みだけで押下にもホバーにも反応しません([実装状況](#実装状況)を参照)。 > > **いま UI を組む場合は、`FloatSoda` 本体のウィジェットを直接使ってください。** > 押せるボタンは `GestureDetector` で組み立てられます > (→ [WidgetSystem § 押せるボタンを作る](/widgetsystem/#押せるボタンを作る))。 > 3層構成の提供は Phase 5 の予定です。 FloatSoda の UI 層は、Flutter で起きた「Material ロックイン」(振る舞い層が独立して存在せず、見た目と振る舞いが `material` パッケージに一体化した問題)を避けるため、3層のパッケージに分割する計画です。 ```mermaid graph TD Core["FloatSoda
コア + プリミティブウィジェット"] UI["FloatSoda.UI
ヘッドレス(振る舞いのみ)"] Cream["FloatSoda.UI.Cream
DS①: レトロ・クリーミー・フラット"] FizzyPop["FloatSoda.UI.FizzyPop
DS②: 透明感・グラスモーフィズム"] Core --> UI UI --> Cream UI --> FizzyPop ``` | 層 | パッケージ | 中身 | 提供状況 | |---|---|---|---| | プリミティブ | `FloatSoda` | RenderObject を持つウィジェットと、見た目の方針を持たない合成ウィジェット(`SizedBox`, `Flex`, `ColoredBox`, `Text` など) | ✓ NuGet で配布中 | | ヘッドレス | `FloatSoda.UI` | 振る舞い・状態機械のみ(`ButtonBase`, `InteractionState`)。見た目は builder デリゲートに完全委譲 | 予定(Phase 5) | | デザインシステム | `FloatSoda.UI.Cream` / `FloatSoda.UI.FizzyPop` | ヘッドレスの状態から見た目へのマッピングと `*Style` レコード・テーマ | 予定(Phase 5) | デザインシステム同士は互いに参照しません。下位層はすべて見える「緩いレイヤリング」にします(デザインシステム層はプリミティブを直接使ってよい)。 ## 実装状況 `FloatSoda` のプリミティブ層だけが利用できます。上2層は設計を確定させた段階で、実装はこれからです。 | 対象 | 状況 | |---|---| | `FloatSoda`(プリミティブ) | ✓ 使える。NuGet で配布中 | | `FloatSoda.UI`(`ButtonBase` / `InteractionState`) | 予定。型は存在するが `ButtonBase` が `GestureDetector` へ未配線で、`InteractionState` の `IsPressed` / `IsHovered` / `IsFocused` が常に `false` | | `FloatSoda.UI.Cream` / `FloatSoda.UI.FizzyPop`(`Button` / `ButtonStyle` / 各テーマ) | 予定。`ButtonBase` に依存しているため同様に反応しない | 3プロジェクトとも `IsPackable=false` のため、NuGet パッケージとしては存在しません。 使うにはリポジトリをクローンしてプロジェクト参照を張る必要がありますが、 上記のとおり押下もホバーも動かないため、現時点では実用になりません。 `FloatSoda.UI` の残作業は Phase 5 のマイルストーンにあります(`ButtonBase` への `GestureDetector` 配線が #102、 `Cream` / `FizzyPop` の `Button` 完成が #78 / #100、背景ブラーが #38)。 ## 境界基準 - **Skia / レンダーツリーの型に依存するウィジェットはコア**(`FloatSoda`)に置く。`RenderObjectWidget` 系は必然的にコア。 - 見た目の方針(意見)を持たない合成ウィジェット(`Center`, `Container` など)もコア。Flutter の `widgets` 層に相当。 - **インタラクションの状態機械(pressed / hovered / focused / disabled など)は必ず `FloatSoda.UI`** に置く。 - 色・余白・角丸などの具体的な見た目はデザインシステム層。 ## 2つの規約 1. **振る舞いは必ず FloatSoda.UI に置く。** デザインシステム層の `State` には「ヘッドレスの状態 → 見た目のマッピング」以外のロジックを書かない。Flutter の `TextField` が `material` に振る舞いごと実装され、Cupertino が振る舞いを複製する羽目になった轍を踏まないため。 2. **FloatSoda.UI はデザインシステムの InheritedWidget なしで動作する。** ヘッドレスウィジェットは自前のデフォルトを持ち、`CreamTheme` / `FizzyPopTheme` の存在を前提にしない(Flutter の `Theme.of` / `Material` 祖先の暗黙要求のようなアンビエント依存を作らない)。 **Litmus test:** 「2つ目のデザインシステムが、1つ目のコードをコピーせずに同じコンポーネントを作れるか」。Cream と FizzyPop を最初から並走させているのは、この検証を常時行うためです。ヘッドレス層のAPIに片方のデザインシステム固有の都合が漏れたら、もう片方が壊れることで検知できます。 ## 見た目の注入方式 Avalonia のルックレスコントロール(疑似クラス + `PART_` テンプレートパーツ)の契約を、型付きにした形を採ります: - 状態の公開 — 文字列の疑似クラスではなく `readonly record struct InteractionState`(型付き) - 見た目の注入 — 名前ベースの `PART_` 検索ではなく `required Func Builder`(型付きスロット、コンパイル時保証) 次のコードは**目指す姿であり、いまは押下に反応しません。** ```csharp // ヘッドレス層(FloatSoda.UI): 振る舞いのみ new ButtonBase { OnPressed = () => ..., Builder = (ctx, state) => /* state から見た目を構築 */ }; // デザインシステム層(FloatSoda.UI.Cream): 状態→見た目のマッピングのみ new Button { Child = new Text("OK"), OnPressed = () => ... }; ``` ## デザインシステム 2つのデザインシステムを最初から並走させる計画です。どちらも Phase 5 で仕上げます。 | | Cream | FizzyPop | |---|---|---| | コンセプト | レトロでクリーミーな色使い、フラットデザイン | 透明感、グラスモーフィズム | | テーマ | `CreamTheme` | `FizzyPopTheme` | | 現状 | `Button` + `ButtonStyle` の骨組みのみ。押下は未反応 | 同構成。加えて背景ブラーが未実装(下記) | テーマ(`XxxTheme.Of(context)`)はテーマ不在時に null を返し、コンポーネント側が既定スタイルへフォールバックします。テーマが無くても動くことを規約にします。 ## ロードマップ 主要ヘッドレスUIライブラリ(Radix UI, Headless UI, React Aria, Ark UI, Base UI)の収録コンポーネントを横断調査すると、提供物は2層に分解できる: **Tier 1(分解不能な原始インタラクション)** と、**Tier 2(Tier 1 + Overlay の組み合わせでできる複合コンポーネント)**。この構造をそのままヘッドレス層の実装順に採用する。 ### 0. ジェスチャ・ヒットテスト(前提条件) — 充足済み すべての Tier 1 コンポーネントが依存する基盤。コア側では実装済みで、`GestureDetector` / `Listener` / `PointerRegion` によって press / hover を受け取れる(→ [WidgetSystem § ジェスチャとヒットテスト](/widgetsystem/#ジェスチャとヒットテスト))。 残る制約は2つある。 - ポインタ座標が届くのはダッシュボードオーバーレイだけ。他のオーバーレイ種別への接続は Phase 1 の残件 - フォーカスの概念はまだ存在しない。`InteractionState.IsFocused` を埋める仕組みは未設計 ### 1. Tier 1 — 原始インタラクション 分解不能なインタラクションモデルを1つずつ実装する。各モデルは既存コードとの重複がないことを確認済み: | 順序 | コンポーネント | インタラクションモデル | 備考 | |---|---|---|---| | 1 | `ButtonBase` | 単発アクション(press) | 型と `Builder` スロットは実装済み。`ButtonBaseState` から `GestureDetector` への配線が未了で、押下・ホバー状態がまだ更新されない | | 2 | `ToggleBase` | 二値切替(on/off) | Checkbox・Switch の共通基盤 | | 3 | `RadioGroupBase` | 排他選択(択一) | Tabs の選択状態管理とも共有可能 | | 4 | `SliderBase` | 連続値(ドラッグ) | | | 5 | `TextFieldBase` | 文字入力 | 「振る舞いの一部が見た目」(カーソル・選択ハンドル)になる最難関。builder / スロットで見た目を外注する設計をここでも貫く | | 6 | `CollapsibleBase` | 開閉(表示・非表示) | Accordion の共通基盤 | ### 2. Overlay / Positioning primitive(Tier 1 と並ぶ独立コンポーネント) Menu・Select・Combobox・DatePicker・Tooltip・ContextMenu など Tier 2 の大半が同じ Popover 実装を使い回している。HTML/CSS の世界には対応要素がなく、VRオーバーレイでは「アンカー要素に対して浮遊パネルを3D空間にどう配置するか」(画面外にはみ出ない、他ウィンドウと重ならない、視線方向を考慮する)が SteamVR 特有の難問になるため、Web版ヘッドレスUIの実装をそのまま輸入できない。Tier 2 全体をブロックする基盤なので、Tier 1 と並行して早期に着手する。 ### 3. Tier 2 — 複合コンポーネント Tier 1 + Overlay の組み合わせで実装し、状態機械を個別に再発明しない(Litmus test と同じ規律): | コンポーネント | 組み合わせ元 | |---|---| | `SelectBase` / `ComboboxBase` | `TextFieldBase`(検索) + リスト選択 + Overlay | | `MenuBase` | Overlay + キーボードナビゲーション + `ButtonBase` 群 | | `AccordionBase` | `CollapsibleBase` × 複数 + 排他制御(`RadioGroupBase` と同じ択一ロジック) | | `TabsBase` | `RadioGroupBase` の選択状態管理を流用、見た目のみ異なる | ### 4. FizzyPop の完成に必要なレンダー機能 グラスモーフィズムの背景ブラーには `BackdropFilter` 相当(SkiaSharp の `SKImageFilter.CreateBlur` を使うレイヤー / RenderObject)が必要。現状は半透明ベタ塗りまで。Tier 1/2 の実装とは独立して進行可能。 ## 関連ページ - [Architecture](/architecture/) — アセンブリ構成と3ツリーモデル - [WidgetSystem](/widgetsystem/) — 組み込みウィジェット一覧 - [APIDesign](/apidesign/) — `*Style` レコード分離などのAPI規約 # ウィジェット/エレメントシステム > Widget / Element システムと組み込みウィジェット一覧 > **実装状況** — `✓` 使用可能 / `△` 使えるが一部の機能が未完成 / `✗` `internal` で公開 API から使えない > - **✓** Widget / Element / State の基盤(`StatelessWidget` / `StatefulWidget` / `InheritedWidget` / `ParentDataWidget`、`BuildOwner` による差分ビルド、`Key` 対応の子リスト差分) > - **✓** ツリー補助の `Builder` / `KeyedSubtree` / `RepaintBoundary` / `ListenableBuilder` > - **✓** レイアウト系・描画系・入力系のウィジェット。**下の[一覧](#組み込みウィジェット一覧)で `✓` が付いているものが使えます** > - **△** `FloatSoda.Hooks`(ビルドループと未統合) > - **✗** スクロール系の `ListView` / `GridView` / `SingleChildScrollView` > - **予定** `Button` / `Icon` を担う UI3層構成(`FloatSoda.UI` / `Cream` / `FizzyPop`)は Phase 5 の予定で、まだ提供していません。ボタンは `GestureDetector` で組み立ててください(→ [押せるボタンを作る](#押せるボタンを作る)) ## 三ツリーの役割 ``` Widget (immutable record) │ CreateElement() ▼ Element (mutable) ← 状態・ライフサイクル管理、BuildOwner が差分ビルド │ CreateRenderObject() / UpdateRenderObject() ▼ RenderObject ← レイアウト・描画(dirty フラグで差分更新) ``` - **Widget** — UI の設計図。`abstract record` で不変。フレームごとに再生成されても `==` で差分検知できる。 - **Element** — Widget と RenderObject を橋渡しする永続ノード。ウィジェットが更新されても Element は再利用される。再ビルドの仕組みは [BuildPipeline](/buildpipeline/) を参照。 - **RenderObject** — `PerformLayout` と `Paint` を実装する描画エンジン。詳細は [RenderObjects](/renderobjects/) を参照。 ## Widget の階層 | 基底クラス | 役割 | 対応する Element | |---|---|---| | `Widget` | すべてのウィジェットの基底。`CreateElement()` を宣言 | — | | `StatelessWidget` | `Build(IBuildContext)` で子ツリーを返す純粋関数コンポーネント | `StatelessElement` ✓ | | `StatefulWidget` | `CreateState()` で `State` を分離 | `StatefulElement` ✓ | | `InheritedWidget` | ツリー下方へのコンテキスト伝播 | `InheritedElement` ✓ | | `ProxyWidget` | RenderObjectを作らず、単一の `Child` へ構成を委譲 | `ProxyElement` ✓ | | `ParentDataWidget` | 親RenderObjectが子ごとに持つレイアウト情報を設定 | `ParentDataElement` ✓ | | `RenderObjectWidget` | `CreateRenderObject()` / `UpdateRenderObject(T)` で RenderObject を所有 | `RenderObjectElement` ✓ | | `SingleChildRenderObjectWidget` | 単一の `Child` を持つ RenderObjectWidget | `SingleChildRenderObjectElement` ✓ | | `MultiChildRenderObjectWidget` | `Children`(`List`)を持つ RenderObjectWidget | `MultiChildRenderObjectElement` ✓(`Key` 対応の子リスト差分) | | `RenderObjectToWidgetAdapter` | Widget ツリーのルートを `RenderView` に接続 | `RenderObjectToWidgetElement` ✓ | ## ParentDataWidget `ParentDataWidget` は、自身ではRenderObjectを作らず、子RenderObjectの `ParentData` を更新します。 親RenderObjectは `SetupParentData` で `T` を用意し、派生Widgetは `ApplyParentData(T)` で値を比較・更新して、変更した場合だけ `true` を返します。 変更時の `MarkNeedsLayout()` は基底クラスが親RenderObjectへ伝播します。 `Flexible` や `Positioned` のように「親レイアウトだけが解釈する子ごとの情報」を宣言的なWidget APIとして表現するための基盤です。 対応するParentDataを用意しない親の下で使用すると `InvalidOperationException` になります。 ## StatelessWidget 状態を持たない純粋関数コンポーネント。`Build(IBuildContext)` でウィジェットツリーを返します。 ```csharp using FloatSoda.Elements; using FloatSoda.Widgets; using FloatSoda.Widgets.Layout; public record MyWidget : StatelessWidget { public required string Title { get; init; } public override Widget Build(IBuildContext context) { return new Center { Child = new Text(Title) }; } } ``` `Build()` はマウント時と、`MarkNeedsBuild()` でスケジュールされた再ビルド時に `BuildOwner` から呼ばれます。 ## StatefulWidget / State `StatefulWidget` は Widget から `State` を分離するパターンです。`State.SetState(Action)` は状態を書き換えたうえで `Element.MarkNeedsBuild()` を呼び、次フレームの `BuildScope()` で再ビルドされます。 ```csharp public record WatchWidget : StatefulWidget { public override State CreateState() => new WatchState(); } public class WatchState : State { private Timer? _timer; private string _time = "00:00:00"; public override void InitState() { _timer = new Timer(_ => SetState(() => _time = DateTime.Now.ToString("HH:mm:ss")), null, dueTime: 0, period: 1000); } public override Widget Build(IBuildContext context) => new Text(_time); } ``` (このサンプルの全体は `samples/FloatSoda.Samples.OverlayApp/WatchWidget.cs` にあります) `State` のライフサイクルメソッド: `InitState()` / `SetState(Action)` / `DidUpdateWidget(T oldWidget)` / `DidChangeDependencies()`。 ## InheritedWidget ツリーの下方にコンテキスト(テーマなど)を伝播させるためのウィジェットです。`InheritedElement` が依存する子孫を追跡し、`UpdateShouldNotify(InheritedWidget oldWidget)` が `true` を返したときに依存側を再ビルド対象にします。 現在位置から最も近いスコープを読み、その更新通知を購読するには、`IBuildContext.DependOnInheritedWidgetOfExactType()` を使います。テーマ側に `Of(IBuildContext)` を用意すると、利用側が照会方法を毎回書かずに済みます。 ### 組み込みの `InheritedWidget` 自分で `InheritedWidget` を定義しなくても、フレームワークが2つを用意しています。 どちらも `Of(IBuildContext)` で最も近い祖先を取得し、同時に依存として登録します。 #### ServiceProvider — DI コンテナへ到達する `ServiceProvider` は `IServiceProvider` をウィジェットツリーへ公開します。 `Widget` は `record` でコンストラクタ注入ができないため、**ビルド中にサービスを解決する経路はこれです。** ```csharp using FloatSoda.Elements; using FloatSoda.Widgets; using Microsoft.Extensions.DependencyInjection; public record StatusLabel : StatelessWidget { public override Widget Build(IBuildContext context) { var services = ServiceProvider.Of(context); // IOscClient は FloatSoda が提供する型ではなく、利用側が Host へ登録した自前のサービス。 var osc = services.GetRequiredService(); return new Text(osc.IsConnected ? "接続中" : "切断"); } } ``` 祖先に `ServiceProvider` が無い場合、`Of` は `InvalidOperationException` を投げます。 ツリーの上位へ次のように挿しておきます。 ```csharp Widget root = new ServiceProvider { Services = host.Services, Child = new StatusLabel() }; ``` #### WindowWidget — 自分が載っているウィンドウを知る `WindowWidget`(と派生の `DashboardWindow` / `WorldSpaceWindow` / `DeviceTrackedWindow`)も `InheritedWidget` です。`app.CreateWindow(...)` に渡した時点でウィジェットツリーのルートになるため、 どのウィジェットからでも `WindowWidget.Of(context)` で `Title` や `Size` を読めます。 ```csharp var window = WindowWidget.Of(context); Widget caption = new Text(window.Title); ``` `WindowWidget` は `ScopeType` を基底型に固定しているため、 派生型で `CreateWindow` していても `WindowWidget.Of` で引けます。 オーバーレイ種別で表示を変えたい場合は型で分岐してください。 ```csharp Widget hint = WindowWidget.Of(context) is DashboardWindow ? new Text("レーザーポインターで操作できます") : new Text("このオーバーレイは表示専用です"); ``` **種別の判定には、上のようにパターンマッチを使ってください。** `DashboardWindow.Of(context)` のような書き方は種別の検証になりません。 `Of` を独自に持つのは `WindowWidget` / `OverlayWindow` / `DesktopWindow` の3つだけで、 `DashboardWindow` / `WorldSpaceWindow` / `DeviceTrackedWindow` は自前の `Of` を持たないためです。 `DashboardWindow.Of(context)` と書いても、実際に呼ばれるのは継承した `OverlayWindow.Of` で、 戻り値の型も `OverlayWindow` になります。ルートが `WorldSpaceWindow` でも例外にはなりません。 `OverlayWindow.Of(context)` は、ルートがオーバーレイ以外(`DesktopWindow`)のときだけ `InvalidOperationException` を投げます。 ### Builder `Builder` は新しい `IBuildContext` を1段挟み、`ChildBuilder` で子を構築します。同じ `Build()` 内で作成した `InheritedWidget` を、その子側のコンテキストから解決したい場合に使います。 ```csharp using FloatSoda.Elements; using FloatSoda.Widgets; public sealed record AlbumTheme : InheritedWidget { public required string Title { get; init; } public static AlbumTheme? Of(IBuildContext context) => context.DependOnInheritedWidgetOfExactType(); public override bool UpdateShouldNotify(InheritedWidget oldWidget) => oldWidget is AlbumTheme oldTheme && oldTheme.Title != Title; } Widget album = new AlbumTheme { Title = "VRChat photos", Child = new Builder { ChildBuilder = context => new Text(AlbumTheme.Of(context)?.Title ?? "No title") } }; ``` Issue 記載時の `BuildContext` ではなく、FloatSoda の公開コンテキスト契約である `IBuildContext` を受け取ります。 ### KeyedSubtree `KeyedSubtree` は子の内容を変えず、ラッパーに指定した `Key` でサブツリーの同一性を制御します。同じ位置・同じキーなら子の Element / State を保持し、キーを変えるとサブツリーを差し替えます。 ```csharp new KeyedSubtree { Key = new ValueKey(albumId), Child = BuildAlbum(albumId) }; ``` ### RepaintBoundary `RepaintBoundary` は子を独立した合成レイヤーへ記録します。境界内の `MarkNeedsPaint()` は `RenderRepaintBoundary` で止まり、変更されていない祖先を再描画しません。 ```csharp using FloatSoda.Widgets.Paint; new RepaintBoundary { Child = BuildFrequentlyChangingWidget() }; ``` ### ListenableBuilder `ListenableBuilder` はBCLの `INotifyPropertyChanged` を購読し、通知が届いたときに `ChildBuilder` 配下だけを再構築します。ViewModel全体をStatefulWidgetへ写し替えず、変更される表示領域を局所化したい場合に使います。プロパティ名によるフィルタは行わないため、`PropertyChanged` のどの通知でも再構築します。 ```csharp using System.ComponentModel; using FloatSoda.Widgets; sealed class CounterState : INotifyPropertyChanged { private int _count; public int Count { get => _count; set { if (_count == value) return; _count = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); } } public event PropertyChangedEventHandler? PropertyChanged; } var counter = new CounterState(); Widget counterLabel = new ListenableBuilder { Listenable = counter, ChildBuilder = _ => new Text($"Count: {counter.Count}") }; ``` `Listenable` を同じ位置の新しい `ListenableBuilder` で差し替えると、古いオブジェクトの購読を解除して新しいオブジェクトへ付け替えます。ツリーから外れたときも購読を解除します。 > **スレッド契約:** `PropertyChanged` は `ListenableBuilder` がマウントされたスレッド(通常はFloatSodaのメインループ)から発火してください。OSC受信やネットワーク処理などのバックグラウンドスレッドから直接通知すると `InvalidOperationException` を投げます。現時点では任意スレッドの通知をメインループへ自動マーシャリングする公開APIはありません。状態の変更と通知を呼び出し側でメインループへ移してから発火してください。 ## Hooks(FloatSoda.Hooks) > **△ 部分実装:** `FloatSoda.Hooks` プロジェクトに R3 ベースの `HookWidget` / `HookElement` がありますが、フレームワークのビルドループとは未統合です。`HookExtension` の `UseState` / `UseEffect` / `Depends` / `UseMemo` / `UseAction` は `NotImplementedException` を投げます。Phase 4 で統合します。 `HookWidget.Build()` 内で `UseState(initialValue)` を呼ぶと `ReactiveProperty` が返り、値の変更が再ビルドをトリガーする、という React フック風の API を目指しています。 ```csharp // 構想中の API(未動作。Button は FloatSoda.UI.Cream などのデザインシステム層のもの) public override Widget Build(IBuildContext context) { var count = UseState(0); return new Button { Child = new Text($"Count: {count.Value}"), OnPressed = () => count.Value++, }; } ``` ## 組み込みウィジェット一覧 ### Layout | ウィジェット | 実装状況 | 説明 | 主なプロパティ | |---|---|---|---| | `Center` | ✓ | 子を中央に配置(`Align` に委譲) | `Child` | | `Align` | ✓ | 子を指定の `Alignment` で配置 | `Alignment`, `WidthFactor`, `HeightFactor`, `Child` | | `Column` | ✓ | 垂直方向に並べる(`Flex` に委譲) | `Children`, `MainAxisAlignment`, `CrossAxisAlignment`, `MainAxisSize` | | `Row` | ✓ | 水平方向に並べる(`Flex` に委譲) | `Children`, `MainAxisAlignment`, `CrossAxisAlignment`, `MainAxisSize` | | `Flex` | ✓ | 方向指定のフレックスレイアウト。`UpdateRenderObject` と `Key` 対応の子リスト差分に対応 | `Direction`, `Children`, `MainAxisAlignment`, `CrossAxisAlignment`, `VerticalDirection` | | `Wrap` | ✓ | 主軸の利用可能領域で子を `run` へ折り返して配置 | `Direction`, `Children`, `Spacing`, `RunSpacing`, `Alignment`, `RunAlignment`, `CrossAxisAlignment`, `VerticalDirection` | | `Flexible` | ✓ | `Flex`系の余剰主軸領域を比率で受け取り、子が割当量以下の大きさを選択可能 | `Flex`, `Fit`, `Child` (必須) | | `Expanded` | ✓ | `Flex`系の余剰主軸領域を比率で受け取り、子を割当量いっぱいに拡張 | `Flex`, `Child` (必須) | | `Spacer` | ✓ | `Flex`系へ比率指定できる空白を挿入 | `Flex` | | `SizedBox` | ✓ | 固定サイズのボックス | `Width`, `Height`, `Child` | | `ConstrainedBox` | ✓ | 親の制約と交差する追加制約を子へ適用 | `AdditionalConstraints` (`BoxConstraints`, 必須), `Child` | | `AspectRatio` | ✓ | 親制約内で幅対高さの比率を維持して子へ固定寸法を適用 | `Ratio` (正の有限値、必須), `Child` | | `FittedBox` | ✓ | 子を自然サイズでレイアウトし、`BoxFit`と`Alignment`に従って拡大縮小・配置 | `Fit`, `Alignment`, `ClipBehavior`, `Child` | | `LimitedBox` | ✓ | 親の上限が無限の軸だけ、子へ最大寸法を適用 | `MaxWidth`, `MaxHeight`, `Child` | | `ConstraintsTransformBox` | ✓ | 親制約を任意の `BoxConstraintsTransform` で変換し、子を配置 | `ConstraintsTransform` (必須), `Alignment`, `ClipBehavior`, `Child` | | `UnconstrainedBox` | ✓ | 両軸または指定軸以外の制約を外して子を自然サイズで配置 | `ConstrainedAxis`, `Alignment`, `ClipBehavior`, `Child` | | `IntrinsicWidth` | ✓ | 子の最大intrinsic幅へ収縮し、任意のstep単位で切り上げ | `StepWidth`, `Child` | | `IntrinsicHeight` | ✓ | 子の最大intrinsic高さへ収縮し、任意のstep単位で切り上げ | `StepHeight`, `Child` | | `Padding` | ✓ | 子の制約を余白分だけ縮小し、子を余白の左上位置へ配置 | `Spacing` (`EdgeInsets`, 必須), `Child` | | `Stack` | ✓ | 複数の子を重ね、非Positioned子を`Alignment`と`Fit`で配置 | `Children`, `Alignment`, `Fit` | | `Positioned` | ✓ | `Stack`の子を辺からの距離または固定寸法で絶対配置 | `Left`, `Top`, `Right`, `Bottom`, `Width`, `Height`, `Child` | | `IndexedStack` | ✓ | 全子をレイアウトし、`Index`で選んだ1子だけを描画・ヒットテスト。`null`なら全子を非表示 | `Children`, `Index`, `Alignment`, `Fit` | | `Offstage` | ✓ | 子をレイアウトしたまま描画・ヒットテストから除外 | `IsOffstage`, `Child` | | `Visibility` | ✓ | `Visible`に応じて必須の`Child`と`Replacement`を切り替え。非表示子の状態保持は行わない | `Visible`, `Child` (必須), `Replacement` | | `RotatedBox` | ✓ | 90度単位でレイアウト寸法ごと時計回りに回転。負値・4以上は4を法として正規化 | `QuarterTurns`, `Child` | | `FractionallySizedBox` | ✓ | 親の最大寸法に対する割合を子へtight制約として適用し、子を配置 | `WidthFactor`, `HeightFactor`, `Alignment`, `Child` | | `OverflowBox` | ✓ | 親とは異なる制約を子へ渡し、自身の領域外への描画を許可 | `MinWidth`, `MaxWidth`, `MinHeight`, `MaxHeight`, `Fit`, `Alignment`, `Child` | | `SizedOverflowBox` | ✓ | 自身は指定サイズを採り、子へ親の元の制約を渡して配置 | `Size` (`Size`, 必須), `Alignment`, `Child` | | `Container` | ✓ | 配置・余白・装飾・寸法・変換を1つのウィジェットで合成 | `Alignment`, `Padding`, `Color`, `Decoration`, `Width`, `Height`, `Transform`, `TransformAlignment`, `Child` | | `ListView` | ✗ 未実装(`internal`) | スクロール可能なリスト | — | | `GridView` | ✗ 未実装(`internal`) | グリッドレイアウト | — | | `SingleChildScrollView` | ✗ 未実装(`internal`) | 単一子をスクロール | — | `Container` は、`Align` / `Padding` / `DecoratedBox` / `SizedBox` / `Transform` の組み合わせを1つのウィジェットにまとめた合成ウィジェットです。 指定したプロパティに対応するウィジェットだけを、内側から配置・余白・装飾・寸法・変換の順で重ねます。 `Padding` は装飾の内側に入るため、余白の分だけ子が装飾より小さくなります。 `Color` と `Decoration` を同時に指定すると `InvalidOperationException` になります。背景色と角丸を両方使う場合は `BoxDecoration.Color` へまとめてください。 ```csharp using FloatSoda.Geometrics; using FloatSoda.Painting; using FloatSoda.Widgets; using FloatSoda.Widgets.Layout; Widget card = new Container { Width = 320, Padding = EdgeInsets.All(16), Decoration = new BoxDecoration { Color = new Color(32, 32, 40), BorderRadius = BorderRadius.Circular(12) }, Child = new Text("VRChat: Online") }; ``` `ConstrainedBox` は、親から渡される制約を無視せず、その範囲内で追加の最小・最大サイズを子へ適用します。 `AspectRatio.Ratio`は幅を高さで割った値です。両軸が可変なら幅の上限を優先し、収まらない場合は高さの上限から幅を再計算します。幅と高さの両方に上限がない場所ではサイズを決められないため、親の`SizedBox`や`ConstrainedBox`から少なくとも一方の上限を与えてください。 `FittedBox`は子を制約なしの自然サイズでレイアウトしてから描画時に変換します。`BoxFit`には`Fill`, `Contain`, `Cover`, `FitWidth`, `FitHeight`, `None`, `ScaleDown`があり、`Cover`などではみ出す部分を切り抜く場合は`ClipBehavior`を指定します。 `LimitedBox`は、親から受け取った最大幅または最大高さが正の無限大の場合だけ対応する上限を適用します。有限の親制約がある場合は`MaxWidth` / `MaxHeight`を適用しません。 ```csharp using FloatSoda.Geometrics; using FloatSoda.Rendering.Layers; using FloatSoda.Widgets; using FloatSoda.Widgets.Layout; Widget thumbnail = new AspectRatio { Ratio = 16.0 / 9.0, Child = new FittedBox { Fit = BoxFit.Cover, ClipBehavior = Clip.HardEdge, Child = new SizedBox { Width = 1920, Height = 1080 } } }; ``` `IndexedStack.Index` は0始まりです。`null`は全子をレイアウトしたまま全非表示にし、負値または`Children`の範囲外は`ArgumentOutOfRangeException`になります。 `RotatedBox`は回転後の幅と高さをレイアウトへ反映します。レイアウト寸法を変えず描画だけを任意角度で変形する`Transform`とは用途が異なります。 #### 表示・非表示の3つのウィジェットの使い分け `Visibility` / `Offstage` / `IndexedStack` はどれも「表示するものを切り替える」用途に見えますが、 **非表示にした子の状態(`State`)を保つかどうか**と、**非表示の間もレイアウトを計算するか**が違います。 | ウィジェット | 非表示の子の `State` | 非表示の子のレイアウト | 向いている用途 | |---|---|---|---| | `Visibility` | 通常は失われる(下記) | 計算しない | 状態を持たない表示切り替え | | `Offstage` | 保たれる | 再レイアウト時に計算する | 戻したときに元の状態でいてほしい単一の子 | | `IndexedStack` | 保たれる | 再レイアウト時に全子ぶん計算する | タブのように複数の候補から1つを選ぶ | `Visibility` は `Visible = false` のとき、`Child` の代わりに `Replacement`(省略時は空の `SizedBox`)を ツリーへ置きます。`Child` と `Replacement` の実行時型が違えば `Widget.CanUpdate` が `false` になり、 `Child` の Element と `State` は破棄されます。既定の `Replacement` を使う通常のケースはこれにあたります。 ただし**状態が必ず破棄されるわけではありません。** `Child` と `Replacement` が同じ実行時型で `Key` も等しい場合(どちらも `Key` を指定していない場合を含む)、`Element.UpdateChild` は 既存の Element を再利用するため状態が残ります。 非表示を状態のリセット手段として使うなら、型か `Key` を変えて破棄を確実にしてください。 `Offstage` と `IndexedStack` は非表示の子もツリーに残すため状態が保たれますが、 その代わり**再レイアウトが走るときには、表示していない子の分も計算します**。 候補が多い場合や、子のレイアウトが重い場合はコストが積み上がります。 このコストは毎フレーム発生するわけではありません。ウィジェットにも RenderObject にも 変更がないフレームはレイアウト自体がスキップされます(→ [BuildPipeline](/buildpipeline/))。 `IndexedStack` の `Index` を変えたときも `MarkNeedsPaint()` だけが走るため、 タブの切り替えでは再レイアウトされません。 ```csharp // 状態を保ちたい: 開閉してもスクロール位置を維持する new Offstage { IsOffstage = !isExpanded, Child = BuildDetails() } // 状態を保たなくてよい: 通知の有無で出し分けるだけ new Visibility { Visible = hasNotification, Child = new Text("新着あり") } // 複数候補から1つ: タブごとの入力内容を保つ new IndexedStack { Index = selectedTab, Children = [BuildHome(), BuildSettings()] } ``` `Expanded` / `Flexible` / `Spacer` は `Row`、`Column`、`Flex` の直接の子として使用します。 `Flex` は1以上の整数で、たとえば `Flex = 2` は `Flex = 1` の子の2倍の余剰領域を受け取ります。 `Expanded` は `FlexFit.Tight` 固定、`Flexible` は既定で `FlexFit.Loose` です。 主軸の最大制約が無限のときは余剰領域を決められないため、flex子を含む `Flex` は `InvalidOperationException` を投げます。親の `SizedBox` / `ConstrainedBox` などから有限の幅(`Row`)または高さ(`Column`)を与えてください。 ```csharp using FloatSoda.Geometrics; using FloatSoda.Widgets; using FloatSoda.Widgets.Layout; Widget toolbar = new SizedBox { Width = 600, Height = 64, Child = new Row { CrossAxisAlignment = CrossAxisAlignment.Stretch, Children = [ new Expanded { Flex = 2, Child = new Text("VRChat status") }, new Spacer { Flex = 1 }, new Flexible { Flex = 1, Fit = FlexFit.Loose, Child = new Text("Settings") } ] } }; ``` ```csharp Widget panel = new ConstrainedBox { AdditionalConstraints = new BoxConstraints( MinWidth: 240, MaxWidth: 400, MinHeight: 120, MaxHeight: 240), Child = new SizedBox { Width = 320, Height = 180 } }; ``` `ConstraintsTransformBox` は、親制約を `BoxConstraintsTransform` delegateで変換してから子へ渡します。 子の自然サイズが親制約を超えた場合、自身は親制約内のサイズを採用し、`Alignment` に従って子を配置します。 `ClipBehavior = Clip.None` ではoverflow部分も描画し、`Clip.HardEdge` または `Clip.Antialias` では自身の矩形で切り抜きます。 `Clip` は `FloatSoda.Rendering.Layers` 名前空間の型なので、使用ファイルへ同名前空間をimportしてください。 ```csharp using FloatSoda.Rendering.Layers; Widget wideLogRow = new ConstraintsTransformBox { // 最小幅は維持し、最大幅だけを外す。 ConstraintsTransform = ConstraintsTransformBox.MaxWidthUnconstrained, Alignment = Alignment.CenterRight, ClipBehavior = Clip.HardEdge, Child = new Text("VRChatの長いログメッセージ") }; ``` 定型的な変換には次のstaticメソッドをそのままdelegateとして指定できます。 - `Unmodified`: 制約を変更しない - `Unconstrained`: 両軸の最小・最大制約を外す - `WidthUnconstrained` / `HeightUnconstrained`: 指定軸の最小・最大制約を外す - `MaxWidthUnconstrained` / `MaxHeightUnconstrained`: 指定軸の最大制約だけを外す - `MaxUnconstrained`: 両軸の最大制約だけを外す 独自変換の戻り値は、最小値が0以上の有限値、最大値が対応する最小値以上の値または正の無限大である必要があります。 NaN、負値、負の無限大、最小値が最大値を超える制約は、子のレイアウト前に `ArgumentException` になります。 `UnconstrainedBox` は `ConstraintsTransformBox` を合成する簡易ウィジェットです。 `ConstrainedAxis = null`(既定値)では両軸の制約を外します。`Axis.Horizontal` では横軸の制約だけを維持し、`Axis.Vertical` では縦軸の制約だけを維持します。 ```csharp Widget naturalWidthRow = new UnconstrainedBox { ConstrainedAxis = Axis.Vertical, Alignment = Alignment.CenterLeft, Child = new Row { Children = [new Text("自然な幅で並べるログ行")] } }; ``` `FractionallySizedBox` は、`WidthFactor` / `HeightFactor` を指定した軸で親の最大寸法にfactorを乗算し、その寸法を子へtight制約として渡します。`null` の軸は親制約を変更しません。factorを使う軸には有限の最大制約が必要です。 `OverflowBox` は、`MinWidth` / `MaxWidth` / `MinHeight` / `MaxHeight` のうち指定した境界だけを親制約から上書きします。`Fit = OverflowBoxFit.Max` は有限の親領域を最大まで使用し、`OverflowBoxFit.DeferToChild` は親制約内で子のサイズに従います。overflow部分は切り抜かず、そのまま描画します。 `SizedOverflowBox` は、自身のサイズを `FloatSoda.Geometrics.Size` で指定する一方、子へは親から受け取った元の制約をそのまま渡します。自身と子を異なるサイズでレイアウトしたい場合に使用します。 ```csharp Widget overflowPreview = new SizedOverflowBox { Size = new Size(240, 120), Alignment = Alignment.Center, Child = new OverflowBox { MinWidth = 320, MaxWidth = 320, Fit = OverflowBoxFit.Max, Child = new FractionallySizedBox { HeightFactor = 0.5, Child = new Text("VRChat preview") } } }; ``` `IntrinsicWidth` / `IntrinsicHeight` は、通常レイアウトの前に子へ自然な寸法を問い合わせます。 `StepWidth` / `StepHeight` を指定すると、計測値をその正の有限値の倍数へ切り上げます。 たとえば内容量が異なるカードを一定のstep幅へ揃える場合に使えます。 ```csharp Widget statusCard = new IntrinsicWidth { StepWidth = 40, Child = new Text("VRChat: Online") }; ``` intrinsic測定は追加のツリー走査を必要とし、入れ子では最悪O(N²)になり得ます。 スクロール領域や大量の項目を持つツリーでは使用せず、寸法が分かる場合は`SizedBox`や`ConstrainedBox`を優先してください。 ### Painting | ウィジェット | 実装状況 | 説明 | 主なプロパティ | |---|---|---|---| | `ColoredBox` | ✓ | 単色背景 | `Color` (`Color`), `Child` | | `DecoratedBox` | ✓ | `BoxDecoration` の背景色・角丸・ボーダーを子の前面または背面へ描画 | `Decoration`, `Position`, `Child` | | `ClipRect` | ✓ | 矩形クリップ | `Clipper`, `ClipBehavior`, `Child` | | `ClipRoundRect` | ✓ | 角丸矩形クリップ | `BorderRadius`, `Clipper`, `ClipBehavior`, `Child` | | `ClipOval` | ✓ | 楕円クリップ | `CustomClipper`, `ClipBehavior`, `Child` | | `ClipCustomPath` | ✓ | カスタムパスクリップ | `Clipper`, `ClipBehavior`, `Child` | | `Opacity` | ✓ | 0から1までの固定不透明度を合成レイヤーで適用 | `Value`, `Child` | | `Transform` | ✓ | レイアウト後に `Matrix3x2` の2次元変換を適用 | `Matrix`, `Origin`, `Alignment`, `TransformHitTests`, `Child` | | `RepaintBoundary` | ✓ | 子の再描画を独立した合成レイヤー内に限定 | `Child` | ### Animation | ウィジェット | 実装状況 | 説明 | 主なプロパティ | |---|---|---|---| | `FadeTransition` | ✓ | `IAnimation` で子の不透明度を駆動(リビルド不要、ペイントのみ)→ [Animation](/animation/) | `Opacity` (`IAnimation`), `Child` | ### Text / Paint | ウィジェット | 実装状況 | 説明 | 主なプロパティ | |---|---|---|---| | `RichText` | ✓ | `TextSpan` でスタイル付きテキストを表示 | `Text` (`TextSpan`) | | `Text` | ✓ | 単一書式のテキスト表示(`RichText` / `TextSpan` に委譲) | `Data` (string), `Style` (`TextStyle?`) | | `DefaultTextStyle` | ✓ | 配下の `Text` へ既定のテキスト書式を伝播する `InheritedWidget` | `Style` (`TextStyle`), `Child` | | `Paint.Image` | ✓ | `ImageProvider`から読み込んだ画像を`Fit`に従って表示。読み込み中と失敗時は`Child`のみを描画し、失敗は`OnError`で通知 | `Provider`, `Fit`, `Alignment`, `Child`, `OnError` | | `Paint.Icon` | ✓ | `IconData`と`FontProvider`で指定したアイコンフォントのグリフを表示 | `Data`, `Size`, `Color` | `Paint.Image` の `Fit` は `FittedBox` と同じ `BoxFit` を使い、既定は `Contain`(縦横比を維持して領域内へ収める)です。収めた画像を領域内のどこへ置くかは `Alignment` で指定します(既定は `Alignment.Center`)。`Cover` のように画像の一部だけを使う場合は**描画元の矩形を切り取って**描画するため、**どの `Fit` でも領域外へはみ出しません**。画像のどの部分を残すかは `Alignment` が決めます。 `FittedBox` が `ClipBehavior` を持つのに `Paint.Image` が持たないのは、この違いによるものです。`FittedBox` は描画元を切り取れない子ウィジェットを拡大縮小するため切り抜きが要りますが、`Paint.Image` は描画元の矩形自体を狭められます。 `Text` は表示文字列を単一値コンストラクタで受け、書式は `init` プロパティで指定します。空文字列は有効です。 書式は Flutter と同じ優先順位で解決します: **明示指定 > 祖先の `DefaultTextStyle` > フレームワークの既定値**(フォントサイズ30、Arial、黒、ウェイト400)。`TextStyle` の各プロパティは `null` を「未指定」として扱い、未指定のプロパティだけが継承で埋まります。 ```csharp using FloatSoda.Geometrics; using FloatSoda.Painting; using FloatSoda.Core.Providers; using FloatSoda.Widgets; new Text("Hello, VR!") { Style = new TextStyle { FontSize = 36, Color = new Color(255, 255, 255), Font = new SystemFontProvider("Arial"), FontWeight = 700, IsItalic = false } } ``` 複数の `Text` へ同じ書式を適用するときは、`DefaultTextStyle` で祖先から伝播させます。配下の `Text` は明示したプロパティだけを上書きし、残りを継承します。`DefaultTextStyle` の `Style` を変更すると、依存する `Text` は自動で再ビルドされます。 ```csharp new DefaultTextStyle { Style = new TextStyle { FontSize = 36, Color = new Color(255, 255, 255) }, Child = new Column { Children = [ new Text("タイトル"), // 36px・白 new Text("強調") { Style = new TextStyle { Color = new Color(255, 200, 0) } } // 36px・黄 ] } } ``` 継承させたくない `Text` には、`Inherit = false` の `TextStyle` を指定します。この場合、未指定のプロパティにはフレームワークの既定値が適用されます。 システムにないフォントは `FileFontProvider` で指定します。同じ値のProviderは内部で共有され、複数の `Text` / `Icon` から使ってもフォントリソースは一度だけ読み込まれます。 ```csharp using FloatSoda.Core; using FloatSoda.Core.Providers; using FloatSoda.Geometrics; using FloatSoda.Widgets.Paint; var materialIcons = new FileFontProvider("Assets/MaterialIcons-Regular.otf"); new Icon(new IconData(0xe88a, materialIcons)) { Size = 24, Color = new Color(255, 255, 255) } ``` `Button` / `IconButton` は、コアではなくデザインシステム層(`FloatSoda.UI.Cream` / `FloatSoda.UI.FizzyPop`)が担う設計です。振る舞いを担うヘッドレスウィジェット(`ButtonBase` など)は `FloatSoda.UI` に置きます。**この3層はまだ提供していません**(→ [UILayering](/uilayering/#実装状況))。いま必要なボタンは [押せるボタンを作る](#押せるボタンを作る) の方法で組み立ててください。 ### Gesture | ウィジェット | 実装状況 | 説明 | 主なプロパティ | |---|---|---|---| | `GestureDetector` | ✓ | タップとパン(ドラッグ)を検知する | `OnTap`, `OnPanStart`, `OnPanUpdate`, `OnPanEnd`, `Behaviour`, `Child` (必須) | | `RawGestureDetector` | ✓ | 独自の `GestureRecognizer` を登録して認識器の組み合わせを自分で決める | `Gestures`, `Behaviour`, `Child` (必須) | | `Listener` | ✓ | 意味付けされていない生のポインターイベントを受け取る | `OnPointerDown`, `OnPointerUp`, `OnPointerMove`, `OnPointerEnter`, `OnPointerExit`, `OnPointerCancel`, `Behaviour`, `Child` | | `PointerRegion` | ✓ | 押下に依存しないホバー(領域への出入り)だけを受け取る | `OnPointerEnter`, `OnPointerExit`, `Behaviour`, `Child` | | `AbsorbPointer` | ✓ | 自身をヒットさせたうえで、子へのヒットテストを止める | `Absorbing` (既定 `true`), `Child` | | `IgnorePointer` | ✓ | 自身と子をヒットテストの対象から外し、背後の兄弟へ通す | `Ignoring` (既定 `false`), `Child` | ## ジェスチャとヒットテスト ヒットテストは「ポインタ座標から、そこにある RenderObject を特定する」仕組みで、 ジェスチャ認識は「特定した対象に届いたポインターイベント列を、タップやパンという意味へ解釈する」仕組みです。 FloatSoda では**どちらも実装済み**です。 ```csharp using FloatSoda.Geometrics; using FloatSoda.Widgets; using FloatSoda.Widgets.Gesture; using FloatSoda.Widgets.Layout; Widget tappable = new GestureDetector { OnTap = () => Console.WriteLine("押された"), Child = new Container { Width = 200, Height = 60, Color = new Color(80, 120, 200), Alignment = Alignment.Center, Child = new Text("Tap me") } }; ``` ### ポインタ入力が届く範囲 **現時点でポインタ座標が届くのは、ダッシュボードオーバーレイ(`DashboardWindow`)だけです。** SteamVR はダッシュボード上のレーザーポインターをマウスイベントとして送ってくるため、 FloatSoda はそれを `IRawPointerSource` として受け取っています。 `WorldSpaceWindow` と `DeviceTrackedWindow` にはコントローラーレイ経路がまだ接続されておらず、 ヒットテスト自体は動いても、そこへ渡す座標が供給されません。この接続は Phase 1 の残件です。 つまり、`GestureDetector` を書いたコードは `WorldSpaceWindow` でもコンパイルは通り、 例外も出ませんが、コールバックが呼ばれることはありません。 ### ヒットテストの振る舞い `Behaviour`(`HitTestBehaviour`)は、ウィジェット自身をヒット対象に含めるかどうかを決めます。 | 値 | 意味 | |---|---| | `DeferToChild` | 子がヒットしたときだけ自身もヒットする | | `Opaque` | 自身の領域全体をヒットとして扱い、背後の兄弟への探索を止める | | `Translucent` | 自身をヒットパスへ加えたうえで、背後の兄弟への探索も続ける | 子を持たない領域(`SizedBox` だけの余白など)をタップ可能にしたい場合は、`Behaviour = HitTestBehaviour.Opaque` を指定します。 `DeferToChild` では、描画内容を持たない子はヒットしません。 **既定値はウィジェットによって違います。** | ウィジェット | `Behaviour` の既定値 | 理由 | |---|---|---| | `GestureDetector` / `RawGestureDetector` / `Listener` | `DeferToChild` | 押下対象は子の描画領域と一致するのが普通で、余白まで拾うと背後のウィジェットを意図せず塞ぐ | | `PointerRegion` | `Opaque` | ホバー領域は子の隙間を含む矩形全体で扱いたい。隙間でホバーが切れると、状態が細かく点滅する | `PointerRegion` で子の描画領域だけをホバー対象にしたい場合は、`Behaviour = HitTestBehaviour.DeferToChild` を明示してください。 ### 押せるボタンを作る `GestureDetector` と `StatefulWidget` を組み合わせると、押すたびに表示が変わるボタンになります。 `OnTap` の中で `SetState` を呼ぶと、状態を書き換えたうえで再ビルドがスケジュールされます。 ```csharp using FloatSoda.Elements; using FloatSoda.Geometrics; using FloatSoda.Widgets; using FloatSoda.Widgets.Gesture; using FloatSoda.Widgets.Layout; public record CounterPanel : StatefulWidget { public override State CreateState() => new CounterPanelState(); } public class CounterPanelState : State { private int _count; public override Widget Build(IBuildContext context) => new GestureDetector { OnTap = () => SetState(() => _count++), Child = new Container { Width = 200, Height = 60, Color = new Color(80, 120, 200), Alignment = Alignment.Center, Child = new Text($"押した回数: {_count}") } }; } ``` `State` の派生は **`class` で宣言します**(`record` は `record` 以外のクラスを継承できません)。 **押した瞬間に色を変えたい場合、`GestureDetector` だけでは足りません。** `GestureDetector.OnTap` は指を離した後に一度だけ呼ばれ、押し下げの瞬間を知らせる口がないためです。 押下中の見た目を変えるには、次のどちらかを使います。 | やりたいこと | 使うもの | |---|---| | 押し下げ・離す・取り消しを個別に扱う | `RawGestureDetector` + `TapGestureRecognizer` の `OnTapDown` / `OnTapUp` / `OnTapCancel`(下の「認識器を自分で組む」) | | ホバー(領域への出入り)で見た目を変える | `PointerRegion`(`OnPointerEnter` / `OnPointerExit`) | 動くコードは次のサンプルにあります。 - `samples/FloatSoda.Samples.OverlayApp/CounterWidget.cs` — `GestureDetector` + `SetState` のカウンター - `samples/FloatSoda.Samples.PointerRegion/PointerRegionDemo.cs` — ホバー・押下・取り消しの状態をすべて表示するデモ ### 用意された `Button` はまだありません `Button` を提供するのは UI3層構成(`FloatSoda.UI` と `Cream` / `FizzyPop`)ですが、**これは Phase 5 の予定で、まだ使えません。** リポジトリには `ButtonBase` / `Button` / `ButtonStyle` の型が置いてあるものの、 `ButtonBase` が `GestureDetector` へ配線されていないため押下・ホバーの状態が更新されず、 3プロジェクトとも NuGet に配布していません(→ [UILayering](/uilayering/#実装状況))。 **足りないのはフレームワークのジェスチャ基盤ではなく、その上に乗せる層です。** ボタンは上の[押せるボタンを作る](#押せるボタンを作る)の方法で組み立ててください。 ### 認識器を自分で組む(`RawGestureDetector`) `GestureDetector` はタップとパンだけを扱う既製の組み合わせです。 それ以外の解釈が必要な場合は `RawGestureDetector` を使い、`GestureRecognizer` を自分で登録します。 組み込みの認識器は2つあります。 | 認識器 | コールバック | |---|---| | `TapGestureRecognizer` | `OnTap`, `OnTapDown`, `OnTapUp`, `OnTapCancel` | | `PanGestureRecognizer` | `OnPanStart`, `OnPanUpdate`, `OnPanEnd` | `Gestures` は `Dictionary` です。 キーは認識器の型、値は「生成するデリゲート」と「コールバックを設定するデリゲート」の組です。 ```csharp using FloatSoda.Gesture; using FloatSoda.Widgets; using FloatSoda.Widgets.Gesture; Widget tapOnly = new RawGestureDetector { Gestures = new Dictionary { [typeof(TapGestureRecognizer)] = new GestureRecognizerFactory( // 1つ目: 認識器を生成する。再構築のたびには呼ばれない。 () => new TapGestureRecognizer(), // 2つ目: 再構築のたびに呼ばれ、最新のコールバックを差し込む。 recognizer => { recognizer.OnTapDown = position => Console.WriteLine($"押下: {position}"); recognizer.OnTap = () => Console.WriteLine("確定"); recognizer.OnTapCancel = () => Console.WriteLine("取り消し"); }) }, Child = BuildSurface() }; ``` **生成と設定を2つのデリゲートに分けているのは、認識器のインスタンスを再構築をまたいで保つためです。** `Widget` は `record` なので毎回作り直されますが、認識器は押下の途中経過を持っています。 毎回作り直すと、押下中に再構築が起きた時点でジェスチャが途切れます。 複数の認識器を登録すると、どれが勝つかは `GestureArenaManager` が決めます。決着のつき方は2通りです。 1. **どれかが勝利を宣言した時点で確定する。** たとえばパンは、指が一定距離を超えて動いた時点で 自分のジェスチャだと宣言します。このとき他の認識器は `RejectGesture` を受けて脱落します 2. **誰も宣言しないままポインタが上がったら、最初に登録された認識器が勝つ。** `Dictionary` の列挙順に依存するため、優先したい認識器を先に入れてください **組み込みの2つはどちらも自分で宣言するため、通常は1で決着します。** タップとパンを両方登録した場合、指をほとんど動かさずに離せばタップが、動かせばパンが勝ちます。 2のルートは、勝利も辞退も宣言しない認識器を自作したときの保険です。 ## Key `IKey` / `ValueKey` / `UniqueKey` が定義され、`Widget.Key` プロパティと差分判定に組み込まれています。`Widget.CanUpdate(old, new)` は「同じ実行時型かつ `Key` が等しい」なら既存 Element を再利用します(Flutter と同じ型 + Key 判定)。`Element.UpdateChild` は先に record 等値の高速パスで同一 Widget をスキップし、その後 `CanUpdate` で更新可否を判断します。`MultiChildRenderObjectElement` の子リスト差分でも `Key` を使って要素の同一性を追跡します(詳細は [BuildPipeline](/buildpipeline/))。 既存の子ウィジェット自体を変更せずにキーを付けたい場合は、`KeyedSubtree` の `Key` と `Child` を指定します。 ## 関連ページ - [BuildPipeline](/buildpipeline/) — BuildOwner / dirty list / UpdateChild の詳細 - [RenderObjects](/renderobjects/) — Widget が生成する RenderObject のリファレンス - [GettingStarted](/gettingstarted/) — Widget を使った最初のアプリ - [UILayering](/uilayering/) — ヘッドレスUI層とデザインシステム層の構成 - [APIDesign](/apidesign/) — ウィジェット API の設計規約