diff --git a/LunaBuild.csproj b/LunaBuild.csproj index 381ce8a6..fc2263d3 100644 --- a/LunaBuild.csproj +++ b/LunaBuild.csproj @@ -2,6 +2,7 @@ Exe net9.0 + Major enable enable lunabuild diff --git a/LunaSDK-Docs/ADRs/ADR-0003 GUI Visual Editing.md b/LunaSDK-Docs/ADRs/ADR-0003 GUI Visual Editing.md index 59d13567..de7da5ac 100644 --- a/LunaSDK-Docs/ADRs/ADR-0003 GUI Visual Editing.md +++ b/LunaSDK-Docs/ADRs/ADR-0003 GUI Visual Editing.md @@ -1,13 +1,18 @@ ## Status -Proposed +Retired. ## Last updated -2026/6/9 +2026/8/29 ## Background Currently LunaSDK has its own GUI module, but all GUI interfaces are hard-coded in C++. Since now we need to offer our new GUI module to UX designers, we need to offer a new visual GUI design tool and its runtime representation so that UX designers can design GUI interface visually without knowing the real C++ code behind. ## Decision +This proposal was never approved and is now retired and superseded by [[ADR-0015 Introduce GameGUI and GameGUIEditor]]. The former +`GUIAsset`/`GUIEditor` naming and the assumption that asset generation should call `EditorGUI` APIs no longer match +the finalized `GUI`/`EditorGUI` boundary established by [[ADR-0004 Introduce GUI foundation layer]]. The text below +is retained as the historical proposal. + We introduce two new targets in LunaSDK: `GUIAsset` and `GUIEditor`. ### GUIAsset @@ -46,4 +51,6 @@ In CLI mode, the editor behaves like a server process, and MCP is the only way t 2. GUI design data and runtime data will not be 100% match at most cases, the runtime data structure version will change rapidly, but the design data is much slower. Use one schema to describe both design data and runtime data will be troublesome. ## Version history +* **2026/8/29** Updated the superseding ADR reference after GameGUI was renumbered to ADR-0015. +* **2026/8/25** Retired this proposal in favor of [[ADR-0015 Introduce GameGUI and GameGUIEditor]], which introduces `GameGUI` and `GameGUIEditor` on the finalized GUI foundation. * **2026/6/9** Proposed. diff --git a/LunaSDK-Docs/ADRs/ADR-0004 Introduce GUI foundation layer.md b/LunaSDK-Docs/ADRs/ADR-0004 Introduce GUI foundation layer.md index bd036609..d2c01111 100644 --- a/LunaSDK-Docs/ADRs/ADR-0004 Introduce GUI foundation layer.md +++ b/LunaSDK-Docs/ADRs/ADR-0004 Introduce GUI foundation layer.md @@ -2,10 +2,10 @@ Approved. ## Last updated -2026/8/7 +2026/8/29 ## Background -LunaSDK currently has an `EditorGUI` module that has already moved away from the old ImGui backend. The current implementation uses explicit GUI contexts, per-frame descriptions, layers, state objects, style entries, render proxies, VG-backed drawing, and GUI debugging support. This direction has proven useful for Studio, GUIEditor, in-game GUI rendering, and custom vector rendering. +LunaSDK currently has an `EditorGUI` module that has already moved away from the old ImGui backend. The current implementation uses explicit GUI contexts, per-frame descriptions, layers, state objects, style entries, render proxies, VG-backed drawing, and GUI debugging support. This direction has proven useful for Studio, in-game GUI rendering, and custom vector rendering. However, the current `EditorGUI` module still mixes several responsibilities in one layer: @@ -17,9 +17,9 @@ However, the current `EditorGUI` module still mixes several responsibilities in This makes the module powerful, but it also creates architectural pressure. Even after the node-kind enum was removed and concrete nodes became RTTI objects, the context and input code still contain widget-specific branches for cases such as scroll bars, table separators, tab headers, numeric controls, dock panels, and popup handling. As more widgets, visual effects, editor features, and in-game GUI use cases are added, this coupling will keep increasing unless the lower-level GUI primitives are separated from the widget layer. -At the same time, GUIEditor and GUIAsset need stable design-time and runtime metadata. Visual editing should not depend on private widget implementation details, and custom user widgets should not require modifying the EditorGUI component package. A lower-level GUI foundation can provide a small set of orthogonal primitives that are easier to inspect, test, record, replay, and compose. +At the same time, retained GUI packages such as GameGUI and visual tools such as GameGUIEditor need stable design-time metadata without depending on private execution details. Custom user widgets should not require modifying the EditorGUI component package. A lower-level GUI foundation can provide a small set of orthogonal primitives that are easier to inspect, test, record, replay, and compose. -Since common GUI concepts will be moved into `GUI`, the high-level immediate API does not need to remain a fully generic widget framework. Instead, LunaSDK should support multiple immediate API packages for different application domains. Each package can make stronger visual and interaction assumptions, keep its implementation smaller, and still interoperate with other packages because they all build the same `GUI` element tree. +Since common GUI concepts will be moved into `GUI`, high-level packages do not need to share one programming model or remain fully generic widget frameworks. LunaSDK may provide immediate API packages and retained semantic packages for different application domains. Each package can make stronger visual and interaction assumptions, keep its implementation smaller, and still interoperate because every package builds the same low-level, per-frame `GUI` element tree. The original GUI rendering boundary assumed that every generated draw command would be compiled into one VG shape draw list and rasterized exclusively by VG. This is sufficient for ordinary vector shapes, text, and images, but it is too restrictive for procedural shadows, temporary render targets, offscreen composition, and future multi-pass effects. Some of these operations do not consume a VG shape buffer and are more naturally implemented by dedicated RHI pipelines. Requiring VG to own every such pipeline would make VG absorb GUI-specific render orchestration that is unrelated to vector path rasterization. @@ -43,18 +43,18 @@ transform, preserve analytic antialiasing under perspective, optionally use scen host-independent two-dimensional events. ## Decision -Introduce a new `GUI` layer between `VG` and high-level immediate GUI packages. +Introduce a new `GUI` layer between `VG` and high-level GUI packages. `GUI` is a low-level, data-oriented GUI foundation. It provides the foundational functionality needed by GUI systems built with LunaSDK. It must not provide high-level widgets such as buttons, checkboxes, combo boxes, menu items, color editors, or dock panels. Instead, it provides orthogonal primitives that higher-level modules use to build widgets, views, tools, and editors. -The high-level package formerly named `GUI` is named `EditorGUI` and becomes one immediate API package built on top of `GUI`. `EditorGUI` is a GUI component package tailored for data-editing applications such as DCC software, editors, office software, and enterprise administration software. It should favor concise, efficient, editor-style controls over fully customizable game UI visuals. Other packages may be introduced later for game HUDs, console UI, mobile UI, or other visual styles. +The high-level package formerly named `GUI` is named `EditorGUI` and becomes one immediate API package built on top of `GUI`. `EditorGUI` is a GUI component package tailored for data-editing applications such as DCC software, editors, office software, and enterprise administration software. It should favor concise, efficient, editor-style controls over fully customizable game UI visuals. Retained semantic packages may be introduced for game HUDs, console UI, mobile UI, or other domains; [[ADR-0015 Introduce GameGUI and GameGUIEditor]] proposes `GameGUI` as the first such package. The new layering is: ```text -Applications, Studio, GUIEditor, game code +Applications, Studio, GameGUIEditor, game code | -Immediate API packages, GUIAsset generators, editor adapters +Immediate API packages, retained-tree compilers, editor adapters | GUI logical layer | @@ -66,7 +66,9 @@ VG, Font, RHI, Runtime This decision removes the following concepts from the long-term high-level `EditorGUI` architecture: 1. `EditorGUI::Node` - - The runtime should maintain only one tree: the typeless `GUI` element tree. + - GUI execution should maintain only one low-level, per-frame tree: the typeless `GUI` element tree. + - A higher-level package may own a persistent semantic or asset tree, but it must compile that tree into `GUI` + elements rather than introduce a competing core context, layout engine, input router or renderer. - High-level immediate APIs submit data into this tree directly. 2. `EditorGUI::RenderProxy` @@ -92,9 +94,9 @@ This decision removes the following concepts from the long-term high-level `Edit - Receives a caller-provided final color texture and an optional depth-stencil texture. It derives attachment formats from those resources, begins and ends every GUI pass, and leaves the attachments in documented final states. - Uses VG as the primary backend for vector paths, rounded rectangles, text, images, and other suitable vector primitives. - May use GUI-owned RHI pipelines directly for primitives or effects that do not naturally use VG shape buffers, such as analytic shadows, backdrop filtering and offscreen composition. - - Does not interpret widget semantics or high-level style policy. Immediate API packages still decide which primitives and effects to emit. + - Does not interpret widget semantics or high-level style policy. High-level packages still decide which primitives and effects to emit. -Direct RHI use is an internal rendering-layer capability, not a general escape hatch that lets immediate API packages inject arbitrary command-buffer callbacks. Public draw commands must remain inspectable data with deterministic ordering and declared resource usage. New RHI-backed primitives should be represented by explicit draw-command types and implemented by rendering-layer command compilers. +Direct RHI use is an internal rendering-layer capability, not a general escape hatch that lets high-level packages inject arbitrary command-buffer callbacks. Public draw commands must remain inspectable data with deterministic ordering and declared resource usage. New RHI-backed primitives should be represented by explicit draw-command types and implemented by rendering-layer command compilers. The renderer exposes one recording entry point. `IRenderer::render` receives the context, a recording graphics command buffer, one render-target description and one surface description. The command buffer must not be inside any pass @@ -171,7 +173,8 @@ The SDF backend consumes two private transient scalar-float streams: Both resources use a four-byte structured-buffer stride, matching the scalar storage model of `VG::IShapeBuffer` but remaining a separate GUI ABI. Widget semantics, Style names and arbitrary shader callbacks do not enter either -stream. Immediate API packages resolve Style values into concrete color instructions before submission. +stream. High-level packages or their frame compilers resolve Style values into concrete color instructions before +submission. #### Scalar instruction format Every instruction begins with an integer-valued float32 opcode. Opcodes remain below `2^24`, so conversion between @@ -312,9 +315,12 @@ interpolation. Shape evaluation, gradients, color effects, rectangular clips and surface coordinate system. The integrated VG draw list historically generates bottom-left coordinates; the GUI renderer converts those positions to the public top-left convention before applying the same surface transform. -The first implementation supports one plane per context. Per-element transforms and several non-coplanar surfaces in -one context are deferred; a host that needs several independently placed panels uses one context and one renderer -preparation per surface. +The design supports one plane per context. Generic per-element rotation and scale are not part of the GUI element +model. Canvas layout provides arbitrary axis-aligned child placement, but it does not rotate or scale children. If a +future requirement justifies transformed subtrees, it should be implemented as an explicit specialized layout type +that owns the corresponding layout, clipping and hit-test semantics rather than as transform fields on every element. +A host that needs several independently placed non-coplanar panels uses one context and one renderer invocation per +surface. #### Perspective antialiasing Analytic coverage remains derivative based. The SDF pixel shader evaluates signed distance in perspective-correct @@ -379,6 +385,9 @@ after a pointer leaves the panel. Surface selection, occlusion and nearest-hit o - Initial primitives should include flex layout, canvas placement, grid layout, table tracks, scroll viewport layout, and clipping. - The element axis size model should stay small: `fixed`, `percent`, and `fit` with explicit min/max constraints. Content-driven `fit` sizing is supplied by measure callbacks owned by higher-level packages instead of by scanning draw commands or hard-coding text knowledge into GUI. - Specialized sizing such as ratio table tracks belongs to the specific parent layout primitive that owns that placement policy. + - Canvas layout may position children arbitrarily while keeping their geometry axis-aligned. Generic per-element + rotation and scale do not belong to the core element record; any future transformed-subtree support must be an + explicit specialized layout with matching clipping and hit-test rules. - Layout should be axis-agnostic where practical so that width and height logic do not diverge. - Layouting is implemented by independent functions operating on element data, not as element methods. @@ -392,6 +401,9 @@ after a pointer leaves the panel. Surface selection, occlusion and nearest-hit o - Routes pointer, keyboard, text, scroll, focus, blur, and capture events through layers and interactables. - Supports focus scopes and keyboard navigation. - Supports scroll routing and future scroll-to-rect propagation. + - Maintains one selected interaction stream at a time. Device and pointer identifiers may be preserved on events, + but they do not create independent concurrent pointer, capture or focus state. Hosts select controller or + keyboard/pointer interaction rather than operating those modes concurrently. 8. **Draw command generation** - Records static GUI-level draw commands during tree construction and supports an optional delayed draw callback on each element. @@ -399,7 +411,7 @@ after a pointer leaves the panel. Surface selection, occlusion and nearest-hit o - Invokes callbacks before children, after children, or at both traversal phases so parent backgrounds and overlays have deterministic painter order. - Provides primitive commands for rectangles, rounded rectangles, gradients, text, images, shapes, lines, clipping, and explicitly modeled rendering effects such as shadows. - Does not know widget rendering policies. - - High-level immediate API packages install package-owned callbacks or submit static commands directly. + - High-level packages or their frame compilers install package-owned callbacks or submit static commands directly. 9. **Inspection and performance instrumentation** - Exposes read-only spans for the current frame's dense element and layer arrays. Tools inspect element topology, layout input and results, interactables, sparse callback bindings, routed interaction state, styles, and draw commands through ordinary public APIs instead of requesting a duplicated debug snapshot. @@ -466,7 +478,7 @@ target fields. `NavigationConfig` is rebuilt each frame like layout, interactable and draw configuration. GUI owns semantic events, default algorithms, opt-out modes, callback dispatch and focus mutation APIs, but not application navigation edges. Inspection tools can report automatic, disabled and callback modes from the current element array, but cannot -predict all targets selected inside business callbacks. GUIAsset must serialize higher-level navigation properties or +predict all targets selected inside business callbacks. GameGUI assets must serialize higher-level navigation properties or handler identifiers rather than raw callback pointers. Drag-drop is a composite interaction protocol rather than a GUI primitive. GUI does not store payloads, source or target type lists, deliveries, or drag-drop state, and its input router does not special-case pointer release for dropping. A high-level package may implement drag-drop by composing generic hit testing, pointer capture, routed input events, context state, layers, and draw commands. Different packages may choose different payload protocols, activation thresholds, previews, target feedback, and delivery lifetimes. @@ -480,12 +492,13 @@ An immediate API package owns: 2. The algorithms that translate those APIs into `GUI` element data and package-owned draw callbacks. 3. The visual style and interaction behavior of those controls. 4. The small set of user-facing customization points intentionally supported by that package. -5. GUIAsset runtime generation helpers for widgets provided by that package. -6. Optional composite interaction protocols such as drag-drop when required by that package. +5. Optional composite interaction protocols such as drag-drop when required by that package. An immediate API package does not need to be universally customizable. The default `EditorGUI` package should expose simple customization points such as margins, spacing, colors, font choices, and selected behavior flags. It should not promise that users can completely replace the rendering logic of every control. -Different immediate API packages can coexist and nest because they all submit elements into the same `GUI` context. For example, an editor-style package and an in-game HUD package may both build into one frame as long as they follow the same core layer, layout, input, and draw command rules. +Immediate API packages and retained semantic packages can contribute to the same `GUI` context when desired because +they follow the same core layer, layout, input, and draw-command rules. Hosts may instead isolate them in separate +contexts, as proposed for GameGUIEditor's EditorGUI chrome and GameGUI document previews. ### Built-in vector icons and composition The default `EditorGUI` package provides a curated core icon set for common navigation, editing, file, status and @@ -509,7 +522,10 @@ license is distributed with that data. ### Rendering policy and style schema `EditorGUI::RenderProxy` is removed from the target architecture. -In the new model, a high-level immediate API installs a package-owned draw callback or emits static commands for each element it creates. The callback runs only during GUI's fixed painter-order traversal and can read final layout, interaction state, state objects, and style values before emitting primitive commands. It has no object identity, registration, inheritance, RTTI type, or lifecycle of its own. +In the new model, a high-level package or its frame compiler installs a package-owned draw callback or emits static +commands for each element it creates. The callback runs only during GUI's fixed painter-order traversal and can read +final layout, interaction state, state objects, and style values before emitting primitive commands. It has no object +identity, registration, inheritance, RTTI type, or lifecycle of its own. This callback is not a revival of `EditorGUI::RenderProxy`. It is a low-level, typeless GUI command-generation hook. The default editor-style EditorGUI package still owns its rendering policy and does not promise that users can replace every widget renderer. @@ -518,20 +534,25 @@ The GUI rendering layer rasterizes the primitive commands produced by these call Style usage schema remains useful, but it is no longer attached to `EditorGUI::RenderProxy`. Instead: 1. `GUI` provides style records, style resolution, and read-only access to style records referenced by elements. -2. Immediate API packages declare the style keys that their APIs read. -3. GUIEditor and debug tools consume these declarations to display editable style properties. +2. High-level GUI packages declare the style keys that their APIs or node generators read. +3. High-level authoring tools and GUI debug tools consume these declarations to display relevant style properties. 4. Core draw primitives remain widget-independent. -### GUIAsset and GUIEditor -`GUIAsset` remains a design-time representation, not a direct serialization of the GUI element tree. GUIAsset nodes describe editable widgets/views and their properties. At runtime, GUIAsset generation calls high-level `EditorGUI` APIs, which then build GUI elements. +### GameGUI and GameGUIEditor +`GameGUI` owns a persistent high-level semantic node tree and its asset representation, not a direct serialization of +the GUI element tree. At runtime, each GameGUI instance compiles its semantic nodes directly into one or more +low-level `GUI` elements for the current frame. GameGUI and EditorGUI are sibling high-level packages; GameGUI does +not generate through EditorGUI. -GUIEditor should eventually consume three kinds of schema: +The proposed GameGUIEditor would consume three kinds of schema from GameGUI node descriptors: -1. Widget/view property schema from GUIAsset node types. -2. Style usage schema from the immediate API package that implements the widget. -3. Parent layout attachment schema from GUI layout primitives and high-level layout APIs. +1. Property, event and child-slot schema from GameGUI node types. +2. Style-usage schema declared by GameGUI node types for their generators. +3. Parent layout attachment schema from the parent GameGUI node type, which may reuse a GUI layout primitive. -This lets GUIEditor present meaningful inspector controls without depending on private runtime element records. +This would let GameGUIEditor present meaningful inspector controls without depending on private runtime element records. +The complete GameGUI asset, instance and editor-service decisions are recorded in +[[ADR-0015 Introduce GameGUI and GameGUIEditor]]. ### Module boundary Prefer a separate module: @@ -539,10 +560,11 @@ Prefer a separate module: ```text Modules/Luna/GUI Modules/Luna/EditorGUI +Modules/Luna/GameGUI Modules/Luna/GUIWindow ``` -`GUI` may depend on `Runtime`, `RHI`, `VG`, and `Font` only when required for draw command and font resource integration. It must not depend on `Window`, `HID` platform event types, Studio, GUIEditor, or high-level GUI widgets. +`GUI` may depend on `Runtime`, `RHI`, `VG`, and `Font` only when required for draw command and font resource integration. It must not depend on `Window`, `HID` platform event types, Studio, `EditorGUI`, `GameGUI`, `GameGUIEditor`, or other high-level GUI widgets and applications. Within `GUI`, logical data and algorithms should remain separable from rendering implementation code. RHI pipeline objects, descriptor sets, backdrop capture textures and other temporary GPU resources belong to the @@ -552,10 +574,18 @@ presentation. GUI owns attachment transitions and every render/compute pass reco `EditorGUI` depends on `GUI` and provides the default editor-style immediate API package. It must not define its own runtime context, runtime node tree, or render proxy system. +`GameGUI` depends on `GUI` and the asset/runtime facilities required by its persistent semantic model. It may own +semantic node trees, compiled node data and per-instance state, but it must not define a competing low-level GUI +context, element execution tree, layout engine, input router or renderer. It must not depend on `EditorGUI`. + +The proposed `GameGUIEditor` would use `GameGUI` for editable semantic documents and previews and `EditorGUI` for its +application chrome. Its service layer would remain independent of `EditorGUI`, window and swap-chain state. The +complete dependency and service boundary is proposed by [[ADR-0015 Introduce GameGUI and GameGUIEditor]]. + `GUIWindow` adapts Window/HID input and clipboard APIs to `GUI` input interfaces. ### Migration plan -The migration should be staged to avoid keeping two competing GUI runtimes alive for too long. +The migration should be staged to avoid keeping two competing low-level GUI execution runtimes alive for too long. #### Phase 1: GUI skeleton Create the `GUI` module and add: @@ -573,7 +603,7 @@ Create the `GUI` module and add: The current `EditorGUI` module should still build and run while this layer is introduced. #### Phase 2: Data-oriented element tree -Move the runtime tree source of truth from `EditorGUI::Node` to the `GUI` element tree. +Move the low-level execution-tree source of truth from `EditorGUI::Node` to the `GUI` element tree. This phase should: @@ -583,7 +613,7 @@ This phase should: 4. Add element ID lookup, dense element/layer scanning, and human-readable debug names. 5. Add adapter code so old GUI widgets can submit core elements during migration. -The goal of this phase is to make `GUI` element data the only long-term runtime tree representation. +The goal of this phase is to make `GUI` element data the only long-term low-level execution-tree representation. #### Phase 3: Interactable graph Implement core interactables before migrating most widgets. This phase should include: @@ -634,13 +664,15 @@ Migrate complex views: These should use interactable hierarchy, focus scopes, layers, and draw primitives instead of widget-specific context branches. -#### Phase 7: Docking and Studio/GUIEditor integration +#### Phase 7: Docking and Studio integration DockSpace can remain in the high-level `EditorGUI` layer until core layout and interactable primitives are stable. It should be migrated only after the simpler systems are proven. -Studio and GUIEditor should be updated after the corresponding widget groups are migrated. GUIEditor must remain schema-driven and should not edit GUI internal element records directly. +Studio should be updated after the corresponding widget groups are migrated. GameGUIEditor is a new application, not +part of this foundation migration; its implementation plan is defined by +[[ADR-0015 Introduce GameGUI and GameGUIEditor]]. #### Phase 8: Remove legacy GUI runtime concepts -After all high-level APIs build through GUI, remove: +After all migrated EditorGUI APIs build through GUI, remove: 1. `EditorGUI::Node`. 2. `EditorGUI::RenderProxy`. @@ -654,10 +686,10 @@ Expected benefits: 1. **Cleaner extension model**: users can build custom widgets by composing core primitives instead of modifying the GUI module. 2. **Less context coupling**: widget-specific input and rendering branches can be removed from the context over time. -3. **Better tooling**: GUIEditor, debug panels, and external inspection tools can read a smaller and more regular runtime model without duplicating it into a second snapshot representation. +3. **Better tooling**: debug panels and preview inspection tools can read a smaller and more regular execution model without treating it as persistent semantic data. The proposed GameGUIEditor would edit GameGUI semantic data and may correlate that data with generated GUI elements for preview diagnostics. 4. **Better performance potential**: dense data arrays and data-oriented traversal are easier to optimize than many concrete widget node types. 5. **Better in-game GUI support**: layers, draw primitives, interactables, and input routing are host-independent and can be used for window GUI and in-game surfaces. -6. **Specialized UI packages**: different domains can provide different immediate API packages without duplicating core layout, input, state, and draw infrastructure. +6. **Specialized UI packages**: different domains can provide immediate or retained semantic packages without duplicating core layout, input, state, and draw infrastructure. 7. **Simpler default EditorGUI package**: the default editor-style package can stop pretending to be fully generic and can optimize for DCC/editor workflows. 8. **Extensible rendering orchestration**: GUI can preserve one painter-ordered command stream while selecting one or many passes and combining VG vector rasterization with dedicated RHI pipelines and temporary targets. @@ -671,12 +703,12 @@ Expected benefits: Risks: -1. **Large migration cost**: current widgets, layout code, input code, debug tools, and GUIEditor integration must be migrated gradually. -2. **Temporary duplication**: old node runtime and new core runtime may coexist during migration. This must be minimized. +1. **Large migration cost**: current widgets, layout code, input code, debug tools, and Studio integration must be migrated gradually. +2. **Temporary duplication**: the old EditorGUI execution-node runtime and the new GUI execution runtime may coexist during migration. This must be minimized. 3. **Over-generalization risk**: GUI may become too abstract if it tries to solve every future UI problem up front. 4. **Boundary erosion risk**: high-level widget behavior may leak into GUI if module boundaries are not enforced. 5. **Performance regression risk**: a data-oriented design still needs benchmarks; new abstractions should not assume they are faster without measurement. -6. **Customization regression risk**: removing render proxies reduces per-widget render customization in the default EditorGUI package. This is intentional, but it must be documented and offset by supporting additional immediate API packages. +6. **Customization regression risk**: removing render proxies reduces per-widget render customization in the default EditorGUI package. This is intentional, but it must be offset by supporting additional high-level GUI packages. 7. **Rendering-layer scope risk**: the new rendering layer may duplicate VG or grow into a general-purpose render graph if its responsibilities are not kept to GUI command execution. 8. **Backend interleaving risk**: VG-backed and direct-RHI commands must preserve exact painter order, clipping and resource states across GUI-owned pass boundaries. @@ -701,8 +733,8 @@ Mitigations: 3. Migrate one widget group at a time. 4. Preserve existing GUI tests and add new stress tests for each migrated subsystem. 5. Do not migrate DockSpace first. -6. Keep GUIAsset as a design-time representation rather than serializing GUI internals. -7. Document each immediate API package's intended domain and supported customization surface. +6. Keep GameGUI assets as persistent semantic representations rather than serializing GUI internals. +7. Define each high-level GUI package's intended domain, programming model and supported customization surface. 8. Keep vector path rasterization in VG and add direct RHI pipelines only for explicit GUI primitives that do not naturally use VG shape buffers. 9. Keep RHI execution internal to the rendering layer; do not expose arbitrary command-buffer callbacks as ordinary draw commands. 10. Expose one renderer entry point that starts and ends outside every pass. Let GUI compile the complete plan, @@ -744,7 +776,7 @@ Mitigations: 3. Nested captures multiply work and make target/depth equivalence difficult to validate. 4. Pass count and attachment lifetimes remain split across two owners. -### Give each immediate API package direct RHI rendering callbacks +### Give each high-level GUI package direct RHI rendering callbacks * Status: rejected. * Pros: 1. Maximum rendering freedom for custom packages. @@ -759,13 +791,13 @@ Mitigations: * Status: rejected. * Pros: 1. Lowest short-term migration cost. - 2. Existing Studio and GUIEditor code keeps working with fewer changes. + 2. Existing Studio code keeps working with fewer changes. 3. Current widget APIs already cover many practical needs. * Cons: 1. Context and input code will continue to accumulate widget-specific branches. 2. Custom widgets still depend on implementation details. 3. Debugging complex focus, popup, drag-drop, and scroll interactions remains harder. - 4. GUIEditor will need more ad-hoc knowledge about runtime behavior. + 4. Debug and visual-authoring tools will need more ad-hoc knowledge about runtime behavior. ### Put GUI inside the existing EditorGUI module only as an internal namespace * Status: rejected for long-term architecture, acceptable only as a temporary migration step. @@ -778,26 +810,28 @@ Mitigations: 2. Widget-specific code can easily leak into the core layer. 3. Other modules cannot depend on the core without also depending on high-level widgets. -### Make GUIAsset directly serialize GUI element trees +### Make GameGUI directly serialize GUI element trees * Status: rejected. * Pros: 1. One runtime representation could be reused for saving and editing. - 2. GUIEditor could inspect exactly what the runtime consumes. + 2. GameGUIEditor could inspect exactly what the runtime consumes. * Cons: 1. GUI element records are frame-runtime data, not stable design data. - 2. GUI assets need semantic widgets/views, not low-level draw and interaction records. + 2. GameGUI assets need semantic widgets/views, not low-level draw and interaction records. 3. Runtime element internals will change more frequently than editable asset format. - 4. This conflicts with ADR-0003's decision that GUIAsset uses its own editable node types. + 4. This conflicts with [[ADR-0015 Introduce GameGUI and GameGUIEditor]], which gives GameGUI its own editable + semantic node types. -### Keep high-level widgets as concrete runtime node types -* Status: rejected as the long-term direction. +### Keep high-level widgets as concrete GUI execution-node types +* Status: rejected as the long-term core direction. * Pros: 1. Familiar implementation model. 2. Easy to attach widget-specific data and virtual methods. * Cons: 1. Encourages widget-specific behavior in the runtime. 2. Makes dense traversal and data-oriented optimization harder. - 3. Makes custom user widgets depend on extending the runtime node model. + 3. Makes custom user widgets depend on extending the low-level GUI execution-node model. This does not prohibit + higher-level packages from owning semantic node types that compile into GUI elements. ### Keep EditorGUI::RenderProxy as the primary customization mechanism * Status: rejected. @@ -818,7 +852,7 @@ Mitigations: * Cons: 1. It creates two context concepts: one in EditorGUI and one in GUI. 2. It makes it unclear which layer owns frame state, input routing, layout, and rendering. - 3. It weakens the rule that GUI owns the only runtime element tree. + 3. It weakens the rule that GUI owns the only low-level per-frame execution tree. ### Store an explicit navigation graph in GUI::IContext * Status: rejected. @@ -905,7 +939,7 @@ Mitigations: 3. Prevents SDF and VG analytic coverage from being evaluated directly at the final scene sampling rate. ### Store a 4x4 transform in every SDF instance -* Status: rejected for the initial surface model. +* Status: rejected for the general element model. * Pros: 1. Allows independently transformed elements inside one context. * Cons: @@ -913,6 +947,9 @@ Mitigations: 2. Complicates clipping, CPU culling, batching and input semantics. 3. A surface-level transform already covers ordinary in-game panels. +If transformed subtrees are required later, they should be introduced through a specialized layout type with explicit +layout, clipping and hit-test behavior rather than by enlarging every element or SDF instance. + ### Submit world-space rays directly to GUI input routing * Status: rejected. * Pros: @@ -931,15 +968,21 @@ Mitigations: 2. Applications would need a separate ad-hoc rendering path for depth-tested GUI. ## Remarks -This ADR does not require an immediate full rewrite. It defines the target architecture and migration discipline. The project may temporarily build GUI beside the current EditorGUI runtime, but every migration phase should reduce the old widget-node-specific code surface. +This ADR does not require an immediate full rewrite. It defines the target architecture and migration discipline. The +project may temporarily build GUI beside the legacy EditorGUI execution runtime, but every migration phase should +reduce the old widget-node-specific code surface. The name `GUI` identifies the general LunaSDK foundation used by GUI systems. The name `EditorGUI` makes the bundled component package's editor- and data-oriented scope explicit. Applications should use `GUI` directly for foundational primitives and use `EditorGUI` when its component set matches their product domain. Until GUI is formally released, architectural refinements to its logical layer, rendering layer, navigation, SDF programs, surface model and related core contracts are recorded by revising this ADR rather than creating additional GUI-specific ADRs. The version history below preserves when each refinement was adopted. +High-level package decisions remain separate; [[ADR-0015 Introduce GameGUI and GameGUIEditor]] proposes GameGUI and +GameGUIEditor without changing GUI's primitive-only responsibility. ## Version history +* **2026/8/29** Updated the GameGUI cross-reference after the GameGUI decision was renumbered to ADR-0015. +* **2026/8/25** Clarified that GUI owns the only low-level per-frame execution tree while retained high-level packages may own persistent semantic trees. Added the GameGUI/GameGUIEditor sibling-package boundary, retained the single selected input stream, and rejected generic per-element rotation and scale in favor of a possible future specialized layout type. * **2026/8/7** Finalized the pre-release module names. Renamed the general-purpose `GUICore` foundation to `GUI`, and renamed the editor- and data-oriented `GUI` component package to `EditorGUI`. Updated namespaces, public headers, build targets, tests, module boundaries, and documentation to match the finalized responsibilities. * **2026/7/30** Replaced the application-owned `prepare`/`render` split with one GUI-owned rendering entry point. The host supplies a final color texture and optional depth-stencil texture while GUI records all attachment diff --git a/LunaSDK-Docs/ADRs/ADR-0015 Introduce GameGUI and GameGUIEditor.md b/LunaSDK-Docs/ADRs/ADR-0015 Introduce GameGUI and GameGUIEditor.md new file mode 100644 index 00000000..33428e32 --- /dev/null +++ b/LunaSDK-Docs/ADRs/ADR-0015 Introduce GameGUI and GameGUIEditor.md @@ -0,0 +1,445 @@ +## Status +Proposed. + +## Last updated +2026/9/11 + +## Background +LunaSDK separates its current GUI infrastructure into two layers. `GUI` is the low-level, data-oriented foundation +that owns one typeless element execution tree for each frame, while `EditorGUI` is an immediate API package designed +for editors, DCC tools, office software and other data-oriented desktop applications. This division is defined by +[[ADR-0004 Introduce GUI foundation layer]]. + +Games, console interfaces and mobile applications need a different high-level model. Their interface structure must +survive across frames, be visually authored, be loaded through the Asset module, support reusable nested content and +allow application modules to add domain-specific node types. Their visual and interaction policies also should not +depend on EditorGUI's compact editor-oriented controls. + +The earlier [[ADR-0003 GUI Visual Editing]] proposed `GUIAsset` and `GUIEditor`. That proposal predates the finalized +`GUI`/`EditorGUI` boundary and assumes that asset generation calls high-level EditorGUI widget APIs. It also assigns +one operation stack to the editor process and proposes a custom TCP MCP path. Those choices no longer fit the current +GUI foundation, multi-document requirement, Application as a Service pattern or existing MCP module. + +A retained GameGUI tree must not become a second low-level GUI runtime. `GUI::IContext::begin_frame` rebuilds the +typeless element tree, callback records and draw commands every frame, and GUI element handles are frame-local. The +retained tree must therefore be a persistent semantic source that compiles into the sole low-level GUI execution tree. + +The initial scope also follows the current GUI interaction and geometry model: + +1. GUI selects one interaction stream at a time. Controller and keyboard/pointer modes are not operated concurrently, + and pointer identifiers do not create independent simultaneous capture or hover state. +2. Canvas layout may place children arbitrarily in two dimensions, but GUI elements remain axis-aligned and do not + have general rotation or scale. +3. Asset changes are applied by explicit reload. LunaSDK does not watch files or automatically replace live asset + instances when files change. + +## Decision + +### Module and dependency boundary +Introduce three build targets grouped into two product components: + +1. `GameGUI`, a runtime module under `Modules/Luna/GameGUI`. +2. `GameGUIEditorService`, a headless service library under `Programs/GameGUIEditor/Service`. +3. `GameGUIEditor`, an application under `Programs/GameGUIEditor` that links the service library. + +The simplified high-level dependency graph is: + +```text +Game and application code --> GameGUI --> GUI +GameGUIEditorService --> GameGUI +GameGUIEditor GUI --> GameGUIEditorService +GameGUIEditor GUI --> GameGUI +GameGUIEditor GUI --> EditorGUI --> GUI +CLI/MCP adapter --> GameGUIEditorService +``` + +`GameGUI` directly depends on `GUI`, `Asset`, `VFS` and `Runtime`; its loader reads and writes the current cooked +runtime representation. It must not depend on `EditorGUI` or authoring-format compatibility code. `GameGUIEditorService` depends on `GameGUI`, +`Asset`, `Frontend`, `VariantUtils` and `VFS`, but not on `EditorGUI`, `Window` or swap-chain state. The +`GameGUIEditor` application uses EditorGUI for its own editor chrome and GameGUI for previews. An optional protocol +adapter depends on `MCP` to expose selected service operations through standard IO or Streamable HTTP. + +GameGUI may own persistent semantic nodes, compiled node data and per-instance state. It must not define another +low-level GUI context, execution tree, layout engine, input router or renderer. Each frame, GameGUI submits one or +more GUI elements for every semantic node that contributes content to that frame. + +### GameGUI asset data units +Register one stable GameGUI asset type with the Asset module. Following +[[ADR-0014 Introduce multiple data units to assets]], each GameGUI asset uses two data units with the same asset GUID +and path: + +1. The implicit main data unit uses the `Luna.GameGUI.Runtime` loader and stores the cooked runtime document. +2. The named `Authoring` data unit uses the `Luna.GameGUIEditor.Authoring` loader and stores versioned JSON source. + +The initial physical-file convention is `.cooked` for the compact binary main unit and `.json` for the +human-readable Authoring unit. The cooked stream has a fixed identifying magic but no compatibility version field; +the cooker and runtime codec change together. + +The main data unit contains only the latest semantic node representation expected by the matching GameGUI code. It +has no document-format or node-payload version fields, migration callbacks, editor schemas, extension records or +unknown-provider preservation contract. Loading still performs integrity and topology validation. Because runtime +assets and code are cooked and shipped together, incompatible authoring data never reaches this loader. + +The `Authoring` unit contains the canonical editor source model: + +The canonical document model contains: + +1. An authoring document format version. +2. The stable GUID of the root node. +3. A flat collection of node records addressed by stable node GUID. +4. Optional extension data that older editors can preserve without interpreting. + +Each node record contains: + +1. A stable node GUID that does not change across save and load. +2. A stable node-type GUID and node-type schema version. +3. A human-readable name. +4. A raw `Variant` property object. +5. Ordered child links. + +Each child link identifies the child node, an optional named slot and parent-owned layout attachment data. Canvas +anchors, table cells and similar placement policies belong to this parent-child link rather than to the child node's +general properties. + +The disk model stores raw property Variants instead of directly serializing polymorphic Runtime `Any` objects. A +known node type may compile that Variant into typed cached data after loading. GameGUIEditor must retain the original +type GUID, version, properties and children for an unknown node type so that opening and saving a document without an +optional node provider does not destroy its data. Runtime instantiation reports a diagnostic when a required node type +is unavailable. + +GameGUI owns only the current cooked codec and main-unit loader. GameGUIEditorService owns the authoring codec, +authoring loader, migrations, unknown-record preservation and cooking operation. Saving an editor document writes the +`Authoring` unit only. Cooking validates and migrates a snapshot, rejects unsupported node types, produces a current +`GameGUI::Document`, and writes the main unit. Preview cooking happens in memory and never publishes the dirty preview +snapshot into the main Asset data unit. Source and cooked saves are intentionally independent; cooked data is derived +and can always be rebuilt. + +GameGUIEditorService exposes both document-snapshot cooking and a headless asset cooking helper so an asset cooking +pipeline can rebuild the main unit from the loaded Authoring unit. ADR-0014 does not provide a shipping-strip +operation: packaging must deliberately include the main data and emit metadata without the `Authoring` descriptor or +source file. It must not blindly package every path returned by generic asset-file enumeration. + +The document owns persistent semantic data only. GUI element handles, callback pointers, RHI pointers, interaction +state, selection, editor history and preview state are never serialized into the asset. + +### Runtime node registry and editor schema +GameGUI provides a public runtime node-type registry. Every runtime node type descriptor contains: + +1. A stable type GUID and programmatic name. +2. Current-payload validation and optional typed-cache preparation. +3. Per-instance state construction and the frame-generation callback. +4. Direct asset-reference collection. + +GameGUIEditorService provides the corresponding authoring node-type registry. Its descriptors contain display name, +category, current authoring payload version, schemas, default properties and stepwise migration. The editor descriptor +refers to the same stable node type GUID but is not linked into runtime-only applications. + +User modules may register matching runtime and editor descriptors without modifying either framework. Duplicate stable +type identities are errors rather than silent replacement. Reflection metadata and property attributes may generate +default editor schemas, while custom editor adapters may provide richer controls. Neither schema nor EditorGUI draw +callbacks are stored in the GameGUI runtime registry. + +Authoring loading performs raw JSON decoding, document-format migration and generic topology validation. Topology +validation rejects duplicate node GUIDs, missing roots or children, multiple parents and cycles in the document tree. +Only nodes with a registered editor provider and a supported schema version undergo node-type migration. Unknown node +types and nodes newer than the installed provider remain raw records and do not prevent lossless editor round trips. +Cooking rejects them, so runtime instance preparation receives only current payloads. + +### Nested GameGUI assets +A built-in asset-instance node stores the stable GUID of another GameGUI asset as a canonical string in its +Variant/JSON record. +During typed-cache construction and dependency resolution, GameGUI resolves that GUID to an `Asset::asset_t`; the raw +Variant never stores the process-local asset handle. The referenced asset remains separate and is not copied into the +parent document. + +The initial design treats a mounted asset as a closed subtree and does not provide cross-asset property overrides. +The same asset may be mounted more than once. A single-document decode cannot detect an indirect cross-asset cycle. +After referenced assets have been explicitly loaded, dependency resolution or instance preparation traverses the +loaded document graph and rejects direct or indirect cycles with a diagnostic that identifies the complete mount +chain. This traversal performs no file I/O. + +GameGUI registers direct referred-asset enumeration for nested GameGUI assets and for resources reported by node type +descriptors. The Asset module does not recursively load this graph. Hosts explicitly load or reload the required +assets before instance generation; GameGUI does not synchronously load dependencies from the render or GUI-build path. + +Asset refresh is explicit. `Asset::load_asset_data_unit(asset, Name(), true)` or an equivalent host operation replaces the registered +asset data, after which the host rebuilds or rebinds affected GameGUI instances. Automatic file watching, implicit +reload, asset revision notifications and live instance patching are outside the initial design. + +### Instance and frame generation +A GameGUI instance refers to an asset document or an equivalent in-memory semantic document and owns all mutable +per-instance state. Mutating one instance must not mutate a shared loaded asset definition or another instance. + +Every frame after `GUI::IContext::begin_frame`, the instance traverses the complete relevant semantic tree and emits +GUI elements. It may cache parsed properties, resolved node descriptors, immutable resource data and other source-level +compilation results, but it does not retain GUI element handles or patch the previous frame's element tree. + +The frame contract has explicit phases: + +1. The host begins the GUI frame, queues input and asks each GameGUI instance to build its elements. +2. The host performs the normal GUI layout and input-routing passes. +3. The instance resolves high-level interactions from the routed GUI state, returns symbolic actions to the host and + may request another layout pass after non-structural state changes such as scrolling. +4. The host performs any requested relayout, then generates draw commands and renders through GUI. + +GameGUI does not begin the context frame, route input or invoke the renderer on the host's behalf. Frame-local root +handles and sidecar mappings may exist between these phases, but they are discarded before the next `begin_frame`. + +Runtime GUI identities are derived from: + +1. A caller-provided root instance scope. +2. The complete nested-asset mount-node path. +3. The asset-local stable node GUID. +4. A node-generator-defined element role such as `owner`, `content`, `label` or `thumb`. + +This identity path lets one node emit multiple GUI elements and lets the same asset be mounted repeatedly without +duplicate core IDs. GameGUI keeps frame-local sidecar mappings between semantic node instances and generated GUI +element IDs for event resolution, diagnostics and editor selection. + +GameGUI resolves its high-level interactions after `GUI::IContext::route_input`. Asset data stores symbolic event or +action identifiers rather than callback pointers. The host binds those identifiers to application behavior. Structural +changes requested by an interaction take effect when the next frame is built; they do not mutate the already-routed +GUI tree or callback userdata of the current frame. + +GameGUI follows GUI's selected single-input-stream policy. It does not add concurrent multi-pointer capture, simultaneous +controller and keyboard/pointer operation, or a gesture system on top of the current router. A host may map one touch +contact into the existing pointer stream; GameGUI does not require a concurrent-touch redesign. + +### Layout and transformation scope +GameGUI reuses GUI layout primitives. Canvas layout supports arbitrary axis-aligned child placement. The initial +GameGUI model does not provide general per-element rotation or scale and does not emulate those operations only in +drawing, because layout, clipping, hit testing and interaction would disagree with the rendered result. + +If transformed subtrees become a demonstrated requirement, they must be designed as an explicit specialized layout +type that owns transform-aware layout, clipping, hit testing and rendering behavior. General transform fields are not +added to every GUI element or every GameGUI node. + +### GameGUIEditor service +GameGUIEditor follows the Application as a Service pattern. The `GameGUIEditorService` library implements document and +editing behavior before the GUI adapter. The service has no dependency on EditorGUI, Window or the application swap +chain. + +Each open document has one service-owned document ID and independent: + +1. Editable GameGUI working copy. +2. Monotonic document revision. +3. Undo/redo history and current history state. +4. Saved history state and dirty status. +5. Validation diagnostics. + +The service provides create, open, save, save-as and close operations. An untitled document has a document ID but no +asset binding until save-as succeeds. Closing a dirty document without saving requires an explicit discard request; +the service never infers that choice from GUI state. + +Opening an asset that is already open returns its existing document ID rather than creating another mutable copy; the +GUI adapter decides which tab becomes active. The editor may keep several different documents open. Dirty state is +determined by whether the current history state equals the saved history state, so undoing back to the save point +restores a clean document. + +Opening loads and copies the `Authoring` data unit into service-owned editable state. Frontend queries expose immutable +snapshots tagged with the document revision for preview generation; no adapter receives the mutable working copy. +Edits and source saves do not mutate GameGUI definitions already published in the main data unit. Save writes the +`Authoring` unit and advances the document's save point. An explicit cook writes the main unit; runtime users adopt the +new definition only through the explicit reload and instance +rebuild/rebind path. This keeps live preview responsive without introducing implicit runtime hot reload. + +Edits are expressed as semantic commands addressed by document ID and stable node GUID. Structural edits, property +changes and batches validate an expected document revision. Continuous pointer drags and text input may coalesce into +one committed history item. `VariantUtils::diff` may be used internally to construct inverse data, but serialized diffs +are not the public editing protocol. + +Document revision and history state are distinct. The revision is a concurrency token that increases on every accepted +edit, undo, redo or save-state transition and never moves backward. History state identifies the current content +position, may move in either direction and may branch after an undo. + +The service exposes operations through one `Frontend::IFrontend` instance using stable resource URLs and Variant +parameters. Dynamic document IDs are parameters rather than parts of registered URLs. GUI actions invoke those +Frontend functions instead of mutating document objects directly. Automation uses the same functions. Access to the +service and Frontend is serialized on the owning thread. + +The existing MCP module is the protocol shell when MCP access is requested. Standard IO serves CLI-only mode. A GUI +process may service the existing loopback Streamable HTTP server with `poll(0)` and a bounded number of iterations per +frame. This makes the readiness wait non-blocking, but Frontend handlers still execute synchronously and can stall the +frame; operations exported to the GUI-hosted MCP server must therefore have bounded execution time. Long-running or +cancellable work requires a future asynchronous job contract. GameGUIEditor does not implement a separate TCP MCP +protocol. + +### Working directories and asset databases +The editor service owns multiple explicitly opened native working directories. Each directory owns a distinct VFS +mount and an explicitly registered Asset Database as defined by [[ADR-0019 Introduce Pak storage and asset metadata databases]]. +Opening a directory registers metadata for all asset types, without eagerly loading payloads. If `assets.db` exists, +it is authoritative and is opened read/write; otherwise the directory uses a read/write sidecar database. Opening +does not convert formats or fall back from an invalid centralized database. Duplicate GUIDs and overlapping native +directory roots are rejected. Reopening the same directory selects its existing entry. + +Documents, including untitled documents, belong to an opened working directory. All document creation, loading, +saving and dependency loading is checked against service-owned directory roots. Native file dialogs do not grant +access to additional directories, and selecting a path outside those roots never implicitly mounts its parent. +Working-directory selection does not change the process current directory. Mount paths and session directory IDs +are not asset identity and are not serialized into references; references continue to use persistent asset GUIDs. + +Save writes Authoring data, persists changed metadata, flushes the owning database and checks filesystem publication +before advancing the save point. Save As may target another opened directory, but changes the document binding only +after success. Overwriting an existing asset requires explicit confirmation. Save and Cook remain separate. Failures +retain editable state and report errors; no transaction across all payload and metadata files is promised. + +Explorer displays each directory as a root, its folder hierarchy and GameGUI asset records only. Other asset types +remain registered for resource resolution but are not editable through Explorer. A GameGUI asset without editable +Authoring data is identified as such rather than silently receiving an empty source unit. Cached directory entries +are refreshed explicitly, not scanned on every GUI frame. External changes do not overwrite unsaved documents. + +Unloading first collects Save/Discard/Cancel decisions for every dirty document, without discarding documents while +confirmation is incomplete. Cancellation or save failure leaves the directory open. After saving, the host releases +preview resources at a safe frame boundary; the service unloads owned data units, unregisters the database and unmounts +VFS before removing documents and the Explorer root. Failed cleanup remains retryable. References from surviving +directories retain their GUIDs and produce missing-dependency diagnostics until the directory is reopened. Application +exit uses the same checked workflow. Unloading never deletes asset files or shuts down the global Asset registry. + +Editor preview preparation loads referenced Authoring data outside GUI generation and cooks immutable dependency +snapshots in memory. Open documents contribute their current working snapshots. Instances and diagnostics use the +same no-I/O resource resolver, and editor changes invalidate affected preview state. Preview cooking never publishes +unsaved data into an Asset main unit or writes cooked files. Explicit directory reload remains distinct from editor +revision updates; automatic filesystem watching and runtime hot reload are not introduced. + +### GameGUIEditor GUI and preview +The GUI application uses EditorGUI DockSpace or tabs for multiple open documents. Its hierarchy, node palette, +property inspector and validation views consume GameGUIEditor authoring node schemas rather than hard-coded knowledge of built-in +node C++ types. All mutations pass through the service command surface. + +Each open document owns independent preview state. When its preview is visible, the GUI adapter lazily creates or +resumes an independent `GUI::IContext`, GameGUI instance and offscreen render target, displays that target inside +EditorGUI and maps viewport input into the preview context. Only visible previews need to render; inactive targets may +be released or pooled. Separate contexts isolate focus, capture, popup layers, styles and stable IDs between documents +and between the preview and editor chrome. When a document revision changes, the adapter obtains the corresponding +immutable snapshot through the Frontend query surface and rebinds the preview instance; it never receives a mutable +service-owned document pointer. + +## Impact +Expected benefits: + +1. Games and mobile applications gain a retained, asset-backed GUI model without coupling to EditorGUI visuals. +2. GUI remains the only low-level execution, layout, input and rendering runtime. +3. Stable node identities and editor-only schemas support visual editing, automation, migration and custom providers + without increasing runtime registry or asset size. +4. Nested assets allow reusable interface composition without copying source trees. +5. AaaS gives the GUI, CLI and MCP adapters one tested editing behavior. +6. Per-document history and save points make simultaneous asset editing predictable. + +Costs and risks: + +1. The semantic tree and generated GUI tree are intentionally different representations and require a compiler, + identity mapping and diagnostics. +2. Complete frame generation has CPU cost and needs large-tree benchmarks and frame-storage discipline. +3. Maintaining separate authoring and cooked representations requires explicit cooking and stale-output diagnostics. +4. Nested assets require explicit dependency loading and cycle diagnostics because Asset does not provide them. +5. Separate preview contexts and render targets increase editor GPU memory and require a dedicated input bridge. +6. The selected single-input-stream model does not support concurrent touch points or simultaneous input modes. +7. Axis-aligned elements cannot express rotated or scaled subtrees without a future specialized layout design. +8. Explicit reload does not update instances until the host performs the reload and rebuild/rebind operation. + +## Alternatives considered + +### Implement game interfaces with EditorGUI +* Status: rejected. +* Pros: + 1. Reuses the existing widget package without a new runtime module. +* Cons: + 1. EditorGUI's immediate API, density and rendering policy target editor and data-oriented desktop software. + 2. It does not provide a persistent asset-authored semantic tree. + 3. Game-specific visuals and composition would accumulate inside the wrong package. + +### Make GameGUI another immediate API package +* Status: rejected as the primary model. +* Pros: + 1. Closely follows EditorGUI's implementation pattern. + 2. Avoids maintaining a persistent semantic tree. +* Cons: + 1. Cannot directly provide asset-authored hierarchy, stable node identity or multi-document visual editing. + 2. Moves persistent state and composition back into application code. + +### Make retained GameGUI nodes the low-level GUI runtime +* Status: rejected. +* Pros: + 1. Uses one object tree for authoring and execution. +* Cons: + 1. Reintroduces widget-specific node types into the primitive GUI foundation. + 2. Duplicates or bypasses GUI's context, layout, input, state and rendering responsibilities. + 3. Makes the asset schema change at the pace of low-level execution internals. + +### Serialize GUI element records directly +* Status: rejected. +* Pros: + 1. Avoids a separate generation step. +* Cons: + 1. GUI elements and callback attachments are frame-local execution data. + 2. Element handles, runtime pointers and draw data are not stable asset content. + 3. The format would expose private core changes to artists and editor tools. + +### Serialize polymorphic node objects directly through Runtime Any +* Status: rejected for the canonical disk model. +* Pros: + 1. Known node types deserialize directly into typed C++ objects. +* Cons: + 1. Missing node providers make unknown type GUIDs impossible to materialize. + 2. An editor could not open and save unknown nodes without losing data. + 3. Explicit raw-property migration is clearer across node-type versions. + +### Add general transform fields to every GUI element +* Status: rejected. +* Pros: + 1. Makes arbitrary rotation and scale available to every node. +* Cons: + 1. Complicates hit testing, clipping, culling and layout semantics. + 2. Enlarges hot element and rendering data for a capability most interfaces do not require. + 3. A future specialized layout can keep the cost and semantics local to transformed subtrees. + +### Add concurrent multi-pointer and mixed input modes as a GameGUI prerequisite +* Status: rejected for the initial scope. +* Pros: + 1. Supports simultaneous touch gestures and concurrent input sources. +* Cons: + 1. Requires a broader GUI input-router and capture-state redesign. + 2. The current product interaction model intentionally selects one controller or keyboard/pointer stream. + +### Add automatic asset watching and live instance patching +* Status: rejected for the initial scope. +* Pros: + 1. External file changes appear without an explicit reload operation. +* Cons: + 1. Requires generic Asset revision, notification, file-watching and instance migration contracts. + 2. Explicit reload is sufficient for the initial runtime and editor workflow. + +### Let the GUI adapter mutate documents directly +* Status: rejected. +* Pros: + 1. Requires less initial service code. +* Cons: + 1. Duplicates behavior across GUI, CLI and automation paths. + 2. Makes validation, history, revision checks and headless testing UI-dependent. + 3. Conflicts with the Application as a Service pattern. + +### Implement a custom TCP MCP server in GameGUIEditor +* Status: rejected. +* Pros: + 1. Matches the transport wording in the retired [[ADR-0003 GUI Visual Editing]] proposal. +* Cons: + 1. Duplicates the existing MCP, HTTP and standard IO modules. + 2. Creates another protocol and lifecycle implementation to maintain. + +## Remarks +This ADR supersedes the retired [[ADR-0003 GUI Visual Editing]]. +[[ADR-0004 Introduce GUI foundation layer]] remains authoritative for GUI core responsibilities and has been revised +to distinguish GameGUI's persistent semantic tree from GUI's sole low-level per-frame execution tree. + +Manual programming documentation for GameGUI and GameGUIEditor is intentionally deferred until the main application +framework, service boundary and preview lifecycle are sufficiently stable. This proposal and its implementation tests +define the initial contract during that period. + +## Version history +* **2026/9/11** Added explicit multi-directory Asset Database ownership, Explorer, checked source persistence, + editor preview dependency resolution and cancellable directory unload. Unreleased GameGUI decisions continue + to evolve in this ADR rather than allocating additional ADR numbers. +* **2026/8/29** Renumbered to ADR-0015 after ADR-0014 was assigned to Asset data units; split each GameGUI asset into + an editor-owned versioned `Authoring` unit and a latest-only cooked main unit. +* **2026/8/27** Renumbered from ADR-0012 to ADR-0014 after restoring the approved application-menu and message-box ADR numbers. +* **2026/8/25** Proposed. diff --git a/LunaSDK-Docs/ADRs/ADR-0016 Introduce GUI Paint Order.md b/LunaSDK-Docs/ADRs/ADR-0016 Introduce GUI Paint Order.md new file mode 100644 index 00000000..7237f6c6 --- /dev/null +++ b/LunaSDK-Docs/ADRs/ADR-0016 Introduce GUI Paint Order.md @@ -0,0 +1,303 @@ +## Status +Approved. + +## Last updated +2026/9/3 + +## Background +Before this decision, GUI preserved painter order by replaying every element and command operation in submission +order. The renderer consumed that order immediately while maintaining lexical clip state and emitting work to the VG +and SDF backends. This guaranteed correct composition, but it also prevented commands from otherwise independent +widget subtrees from being grouped by compatible rendering state. + +A common editor interface sequence is: + +```text +component 1 background +component 1 text +component 2 background +component 2 text +``` + +When background and text use different rendering backends or pipelines, this sequence repeatedly switches backend +state and produces separate draw calls. If the complete painted regions of the two components do not overlap, the +equivalent order is: + +```text +component 1 background +component 2 background +component 1 text +component 2 text +``` + +GUI construction and layout already know where sibling subtrees are independent. Recovering that information later +by comparing command bounds in the renderer would duplicate layout knowledge, would need to account for clips, +shadows, visual overflow, backdrop dependencies, and custom primitives, and may require quadratic comparisons. + +GUI also already defines `GUI::Layer`. A GUI Layer is a stable structural object used for root ownership, local +coordinate origins, top-level input priority, popup and floating-window ordering, and `bring_layer_to_front`. +Naming the new ordering coordinate as another kind of Layer would make these distinct responsibilities ambiguous. + +The required solution must: + +1. Preserve defined composition order for overlapping or otherwise order-dependent content. +2. Let explicitly independent sibling subtrees share ordering positions so compatible commands can batch. +3. Express top-level GUI Layer ordering through the same rendering-order mechanism. +4. Preserve lexical clip semantics and ordered framebuffer dependencies such as backdrop capture. +5. Keep GUI's typeless element tree and Context-controlled traversal. +6. Require every draw command to declare its Paint Order explicitly, so no hidden strict-order path can interrupt + otherwise batchable shared runs. +7. Avoid release-time geometric overlap analysis. + +## Decision +Introduce **Paint Order** as the single frame-local ordering coordinate for GUI rendering. + +### Paint Order identity and ordering contract +`paint_order_id_t` is an unsigned, frame-local, densely allocated integer. It is regenerated with draw commands and +has no stable identity across frames. + +Every drawable command carries a `paint_order_id`. Commands with a lower Paint Order ID must execute before commands +with a higher Paint Order ID. Commands with the same Paint Order ID explicitly grant the renderer permission to +reorder them across complete batch keys. Submission order remains deterministic within one complete batch key. + +The same Paint Order ID is therefore stronger than an equal Z value: it declares that cross-key reordering cannot +change the required visual result. Callers must use distinct Paint Order IDs for overlapping primitives whose +composition order matters, unless those primitives are represented by one atomic command with internally defined +ordering. + +Paint Order count is independent of draw-call count. Adjacent Paint Order ranges may still be emitted in one draw +call when their complete batch key is compatible and preserving instance order also preserves Paint Order. + +### Existing GUI Layer responsibilities +`GUI::Layer`, `Layer::id`, and `DrawCommand::layer` retain their existing structural meanings. In particular, +`DrawCommand::layer` remains the owning GUI Layer index used to resolve local coordinates and element ownership. + +`DrawCommand` gains a separate `paint_order_id`. Each GUI Layer also records the first and maximum Paint Order IDs +allocated to its root subtree for inspection and validation. + +GUI Layers are visited in their current bottom-to-top order. The first non-empty GUI Layer root starts at Paint Order +ID zero. Every later non-empty GUI Layer root starts at one greater than the previous non-empty GUI Layer's maximum +Paint Order ID. An empty GUI Layer has no Paint Order range and does not consume an ID. Reordering GUI Layers with +`bring_layer_to_front` therefore regenerates non-overlapping Paint Order ranges on the next command generation pass. +The renderer does not use GUI Layer boundaries as an additional ordering rule after Paint Order has been assigned. + +GUI Layer order remains authoritative for input routing. Input is routed before delayed draw generation, and one +element may occupy a Paint Order range rather than one scalar order, so Paint Order does not replace structural input +topology. + +### Element paint protocol +Draw callbacks receive the first Paint Order ID available to their phase and return the maximum Paint Order ID they +used or reserved. + +The `before_children` phase emits an element's background or other underlay. Context then allocates Paint Order for +the element's children. The `after_children` phase receives the first free Paint Order ID after the child subtrees and +emits overlays such as focus feedback, splitters, and docking indicators. + +Context continues to own element traversal. Draw callbacks do not recursively paint children. This preserves the +typeless element model, prevents missing or duplicate child traversal, and keeps phases, clips, and backdrop markers +under one generation algorithm. + +Context validates that a callback does not emit below its input Paint Order ID and that its returned maximum is not +below any Paint Order ID it emitted. A callback that emits nothing does not advance the enclosing subtree merely by +returning its unchanged input value. Checked increment is used when allocating the next ID. + +### Child Paint Order modes +Each element declares one `ChildPaintOrderMode` for its direct child subtrees: + +1. `sequential` gives each child a non-overlapping Paint Order range in submission order. It is the safe default. +2. `shared` gives every child in the current shared run the same initial Paint Order ID and takes the maximum of the + ranges returned by those children. + +GUI core elements default to `sequential`. Selecting `shared` is a performance-oriented composition contract: the +container grants permission to group compatible commands across its direct child subtrees. If overlapping content, +negative margins, visual overflow, explicit clips, shadows, or custom drawing require painter order, the container +must instead select `sequential`. Release command generation performs no geometric overlap analysis. + +Minor differences in ornamental overlap, such as one-pixel antialiasing, border, or soft-shadow intersections, are +acceptable when the containing package deliberately selects `shared`. This does not relax the requirement for +semantic content: overlapping text, controls, overlays, or other content whose visible result depends on submission +order must use distinct Paint Order ranges. + +### EditorGUI default container policy +EditorGUI selects `shared` by default for the following containers: + +1. Horizontal and vertical Flex layouts. +2. Grid layouts. +3. Canvas layouts. +4. Table layouts. +5. Menu bars. + +These defaults optimize the common case in which sibling widgets occupy independent layout regions. They are package +policy rather than a proof derived from the layout algorithm. In particular, Canvas intentionally permits children +to occupy the same rectangle. A caller that uses Canvas, or any other listed container, for order-dependent overlap +must explicitly call `set_child_paint_order_mode(container, ChildPaintOrderMode::sequential)`. + +This explicit opt-out keeps ordinary EditorGUI construction batchable while leaving overlapping composition visible +at its call site. Containers outside this list retain their existing audited policy, and raw GUI elements retain the +core `sequential` default. + +Containers that require several ordering groups express them through typeless grouping elements. For example, a +scroll view uses a sequential content subtree followed by a shared scrollbar group. Dock panels may share one group, +while splitters are emitted by an `after_children` callback at a later Paint Order. + +A Paint Order barrier ends the current shared run. Backdrop capture and future custom operations with framebuffer or +external ordering dependencies are barriers and own exclusive Paint Order IDs. Normal children following a barrier +start at a greater Paint Order ID. + +### Explicit Paint Order submission +`IContext::draw(const DrawCommand&, paint_order_id_t)` is the only draw-command submission entry point. The overload +without a Paint Order ID and the construction-time `draw_for_element` path are removed because both create implicit +strict-order barriers that are easy to introduce accidentally and are invisible at the call site. + +Drawing that depends on an element's final layout, style, or Paint Order is emitted from its `DrawConfig` callback +while Context generates the draw-command stream. A caller must pass the callback's current Paint Order ID to every +draw and return the maximum ID it emitted or reserved. Construction-time drawing is not supported: the final Paint +Order cannot be known until Context traverses the completed element tree. + +### Clip and backdrop dependencies +`push_clip` and `pop_clip` are lexical operations, not sortable drawables. The renderer first replays the original +submission stream and resolves the final effective clip into immutable per-command state. It then excludes the clip +stack operations from Paint Order sorting. Different immutable clip states may share a batch when the backend stores +clip selection per instance. + +A backdrop capture is an execution event with an exclusive Paint Order ID. All captured content has a lower Paint +Order ID, and every consumer of that capture has a higher Paint Order ID. The capture still ends the active color +pass, filters the accumulated target, and resumes rendering, but its position in the final plan is determined only by +Paint Order. No separate render epoch is introduced. + +### Renderer compilation +GUI rendering compilation is divided into four stages: + +1. Replay the original lexical stream to resolve element ownership, coordinate origins, clips, and backdrop capture + relationships into immutable resolved draw records. +2. Stable-sort resolved events by Paint Order ID. +3. Within each equal Paint Order range, group commands by their complete backend batch key in deterministic + first-seen-key order while preserving submission order inside each key. +4. Emit the grouped SDF, VG, and capture batches and coalesce adjacent compatible batches when doing so preserves + Paint Order. + +The initial SDF batch key contains the shape-program page and color-program page. The initial VG key contains the +effective shape buffer, texture, and complete sampler state. Per-instance transform, rectangle, color, and resolved +clip selection are not batch-key fields. A future material or custom pipeline must add all state that changes the +draw call to its complete key. + +The first implementation uses stable sorting for `O(C log C)` ordering, where `C` is the number of resolved commands. +Because Paint Order IDs are dense, a later implementation may use vector buckets for `O(C + O)` collection, where +`O` is the number of occupied Paint Order IDs. Both approaches avoid geometric overlap analysis. + +### Rollout and validation +The feature is introduced with GUI core elements using sequential child ordering. All draw producers migrate to the +explicit Paint Order API before shared child ordering is enabled; the public API provides no legacy strict-order +submission path. + +EditorGUI then enables the default shared-container policy above. Known overlapping compositions, including the +overlapping Canvas Paint Order test, explicitly select `sequential`. Tests cover shared button backgrounds and text, +overlapping canvas children, text-input selection/text/caret ordering, scroll content and scrollbar grouping, dock +panels and splitters, independent GUI Layer ranges, nested clips, backdrop capture barriers, and +`bring_layer_to_front` range regeneration. + +The acceptance comparison between the pre-policy and post-policy EditorGUITest RenderDoc captures produced the same +1,563 instances and 9,378 indices. `DrawIndexedInstanced` calls decreased from 173 to 36, pipeline-state bindings from +107 to 19, and graphics root descriptor-table bindings from 637 to 133. The full 1440 by 1024 output comparison found +1.844% exact pixel differences, while only 0.092% of pixels differed by more than 8 in any 8-bit color channel. The +larger differences were confined to one-pixel container borders and shadow intersections; no text, icon, image, or +widget content was missing. This level of ornamental variation is accepted for the EditorGUI shared defaults. + +## Impact +Expected benefits: + +1. Independent widget subtrees can batch backgrounds, text, icons, and other compatible primitives without global + overlap analysis. +2. Rendering order across normal content, floating windows, popups, and overlays uses one inspectable mechanism. +3. Existing before/children/after composition maps directly to Paint Order ranges. +4. Ordered effects remain expressible through distinct IDs and explicit barriers. +5. The common command-generation path stays linear apart from the initial stable sort. +6. Every draw site exposes its ordering decision explicitly; accidental legacy barriers cannot silently regress + batching. +7. Common EditorGUI layouts batch efficiently without requiring every application call site to opt in separately. + +Costs and risks: + +1. `paint_order_id_t`, child-order policy, and range tracking enlarge GUI's public and internal draw model. +2. Selecting `shared` incorrectly can change pixels because same-order commands may be reordered. +3. Renderer compilation becomes multi-stage and temporarily retains resolved draw records. +4. Removing the legacy overloads is a source- and ABI-breaking change, so `IContext` receives a newly generated IID; + construction-time draw sites must become callbacks, and existing binaries must be rebuilt. +5. Backdrop capture, custom operations, and future material systems must correctly declare barriers and complete + batch keys. +6. Documentation and diagnostics must consistently distinguish structural GUI Layer from Paint Order. +7. EditorGUI's performance-oriented defaults make callers responsible for selecting `sequential` when a listed + container uses meaningful overlap. +8. Reordering translucent ornamental overlap may cause accepted sub-pixel or one-pixel border and shadow differences. + +## Alternatives considered + +### Detect overlapping commands in the renderer +* Status: rejected. +* Pros: + 1. Requires no ordering declaration from containers. +* Cons: + 1. Duplicates layout knowledge after widget semantics have been erased. + 2. Must conservatively model shadows, clips, visual overflow, custom primitives, and backdrop footprints. + 3. Can require quadratic comparisons and still produce fragile results. + +### Introduce paint-plane push and pop operations +* Status: rejected. +* Pros: + 1. Fits a stack-based immediate drawing API. +* Cons: + 1. Makes correct balancing a caller responsibility. + 2. Does not naturally expose a subtree's maximum order to its parent. + 3. Adds mutable implicit state where the element traversal already provides the required scopes. + +### Let every draw callback recursively paint its children +* Status: rejected for GUI's typeless element model. +* Pros: + 1. Closely resembles Slate's widget-owned `OnPaint` traversal. + 2. Allows arbitrary per-widget child grouping. +* Cons: + 1. A callback may omit or paint a child more than once. + 2. Existing phase callbacks become substantially harder to preserve. + 3. It moves structural traversal from GUI Context into high-level package callbacks. + 4. Grouping elements and before/after phases express the required initial cases without transferring ownership. + +### Reuse GUI Layer IDs as rendering order +* Status: rejected. +* Pros: + 1. Introduces no additional field named as an order coordinate. +* Cons: + 1. GUI Layer IDs are stable identities, while Paint Order IDs are dense and frame-local. + 2. One GUI Layer contains many independently ordered element subtrees. + 3. It conflates coordinates, input priority, ownership, and render batching. + +### Preserve strict painter order for every command +* Status: rejected as the long-term design. +* Pros: + 1. Has the simplest correctness model. +* Cons: + 1. Repeated SDF/VG alternation and state changes produce avoidable draw calls. + 2. It ignores independence already known by containers. + +### Keep EditorGUI layout containers sequential and opt in at every call site +* Status: rejected. +* Pros: + 1. Keeps every existing layout conservative unless an application explicitly audits it. + 2. Avoids any accepted ornamental pixel variation by default. +* Cons: + 1. Repeats the same policy decision across most ordinary EditorGUI construction sites. + 2. Leaves substantial batching gains unavailable unless each application performs a broad manual migration. + 3. Makes the common independent-sibling case verbose while the less common order-dependent overlap remains + implicit. + +## Remarks +This decision extends the GUI logical and rendering layers introduced by [[ADR-0004 Introduce GUI foundation layer]]. +It changes only rendering-order generation and compilation. It does not introduce widget types into GUI, replace GUI +Layer input semantics, or change the application-owned submission and presentation boundary. + +## Version history +* **2026/9/2** Proposed and approved. +* **2026/9/3** Removed implicit strict-order draw submission; every draw command now requires an explicit Paint Order + ID. Regenerated the public `IContext` IID because removing virtual methods changes its ABI. +* **2026/9/3** Approved `shared` as the EditorGUI default for Flex, Grid, Canvas, Table, and MenuBar containers after + RenderDoc validation. Order-dependent overlap must explicitly select `sequential`; minor ornamental border and + shadow differences are accepted. diff --git a/LunaSDK-Docs/ADRs/ADR-0017 Introduce GUI Element Visual Effects.md b/LunaSDK-Docs/ADRs/ADR-0017 Introduce GUI Element Visual Effects.md new file mode 100644 index 00000000..451a0220 --- /dev/null +++ b/LunaSDK-Docs/ADRs/ADR-0017 Introduce GUI Element Visual Effects.md @@ -0,0 +1,86 @@ +## Status + +Approved. + +## Last updated + +2026/9/3 + +## Background + +GUI elements currently draw through delayed `DrawCallback` functions. Delayed callbacks are necessary when final drawing depends on routed input, post-layout state, custom SDF construction, or other procedural decisions. They are unnecessarily indirect for static visuals such as a panel background, text, an image, or an ordered stack of shadows and strokes whose complete description is already known while the element tree is built. + +Attaching a generic draw callback to every potentially styled layout element is not acceptable. It would make non-visual elements pay callback and style-resolution costs, and generic style names could accidentally enable painting on elements that were intended to remain layout-only. Static painting must therefore be explicit and sparse. + +ADR-0016 assigns painter ordering and batching through Paint Order IDs. Element Visual Effects must participate in that same ordering model without introducing a second ordering mechanism. + +## Decision + +GUI Core provides Element Visual Effects as an optional, context-owned paint configuration on every typeless `Element`. + +- `ElementVisualEffect` stores one concrete, drawable `DrawCommand` template. +- `ElementVisualConfig` stores ordered `before_children` and `after_children` effect spans. +- `IContext::set_element_visual_config` copies both spans into frame-local storage. Callers may therefore pass temporary arrays and strings owned by the command value are copied with it. +- The configuration is sparse. Elements without a draw callback or Visual Effects do not allocate a paint record and do not perform style lookup. +- Visual Effects are explicit values. GUI Core does not inspect styles or infer effects from an element type. EditorGUI, GameGUI, or business code resolves its own styles and attaches only the effects it intends to draw. +- During draw-command generation, GUI Core directly copies each effect command into the generated command stream, overwrites its element, layer, and Paint Order metadata, and assigns consecutive Paint Order IDs in list order. +- Generation order for one element is: + 1. backdrop capture; + 2. `before_children` Visual Effects; + 3. the `before_children` draw callback; + 4. child subtrees; + 5. `after_children` Visual Effects; + 6. the `after_children` draw callback. +- Each effect reserves one Paint Order ID. Multiple effects may be attached in any order, so two differently colored strokes are represented by two stroke effects with independently configured rectangles, offsets, radii, colors, and widths. +- `rounded_rect_stroke` is added as a drawable GUI primitive. It represents a centered analytic rounded-rectangle stroke and uses `DrawCommand::line_width` as its total width. +- Element Visual Effects accept drawable commands only. Structural commands such as clip pushes and pops and backdrop-capture markers remain procedural generator responsibilities and are rejected by the setter. Backdrop capture continues to use its dedicated attachment API. +- Draw callbacks remain the escape hatch for visuals that depend on `route_input`, require procedural post-layout work, emit structural commands, or need custom Paint Order range management. + +The existing sparse element draw-configuration index is generalized internally into a sparse paint record that stores both callback and Visual Effect configuration. This avoids adding a per-element allocation or a separate hot-path lookup. + +## Migration policy + +This decision does not require every existing callback to migrate. + +Implementations are migrated when their final visual description does not depend on `route_input` and can be expressed by the available drawable commands. Interaction-dependent components keep callbacks. Static components that currently require procedural post-layout SDF construction may remain callbacks until an equivalent native effect exists. + +When a container uses shared child Paint Order ranges, its complete painted extent includes all of its Visual Effects, including shadow and stroke outsets. The caller remains responsible for choosing shared mode only when sibling painted extents do not overlap. + +## Impact + +### Positive + +- Static elements avoid callback invocation and callback userdata lifetime management. +- Layout-only elements remain paint-free unless a higher-level package explicitly attaches effects. +- Ordered fill, stroke, shadow, text, image, shape, backdrop-blur, and SDF commands use the same Paint Order and batching path as callback output. +- Multiple visual layers are data rather than bespoke callback code. +- GUI Core remains widget-free and does not interpret EditorGUI or GameGUI styles. + +### Negative + +- `IContext` changes ABI and receives a new interface ID. +- Effect command lists are copied into frame-local storage. +- One effect consumes one Paint Order ID even when the command later produces no visible pixels. +- Complex procedural visuals still need callbacks, so both mechanisms remain part of the GUI API. + +## Alternatives considered + +### Give every layout element a style-aware callback + +Rejected because it adds work to non-visual elements and allows broad style definitions to cause accidental rendering. + +### Reintroduce unrestricted `record_draw` + +Rejected because a general recording API obscures the distinction between static element visuals and procedural drawing, and structural commands make ownership and ordering across children ambiguous. The Visual Effects API deliberately records only explicit drawable commands in two well-defined element phases. + +### Translate effects in an element callback + +Rejected because it retains callback overhead and callback lifetime concerns. GUI Core already owns command generation and can translate static effects directly. + +### Reorder callback output by geometric overlap analysis + +Rejected as the primary solution because it is more expensive and less predictable than explicit Paint Order allocation. Geometry-based optimization may still be explored independently. + +## Version history + +* **2026/9/3** Proposed and approved the sparse GUI Core Element Visual Effects model, static-only migration policy, and analytic rounded-rectangle stroke primitive. diff --git a/LunaSDK-Docs/ADRs/ADR-0018 Introduce a standalone Zip module.md b/LunaSDK-Docs/ADRs/ADR-0018 Introduce a standalone Zip module.md new file mode 100644 index 00000000..26177a51 --- /dev/null +++ b/LunaSDK-Docs/ADRs/ADR-0018 Introduce a standalone Zip module.md @@ -0,0 +1,56 @@ +## Status +Proposed. + +## Last updated +2026/9/4 + +## Background +LunaSDK needs ZIP archive support both for ordinary application code and as the foundation of a future Pak module. ZIP storage must remain independent of VFS mounting and Asset metadata. Reimplementing ZIP64, Deflate, archive editing, and format validation would duplicate existing library functionality. + +## Decision +Introduce a `Zip` module, depending on `Runtime` and a pinned libzip dependency. Download libzip 1.11.4 and zlib 1.3.2 through `setup.sh` / `setup.bat` into the ignored `SDKs` directory, then compile them locally through LunaBuild. Third-party library sources must not be checked into the LunaSDK repository. The repository contains only download recipes, configuration templates, and build integration code. Keep third-party declarations out of the module's public API. + +Setup verifies the pinned source archives with SHA-256 and prepares the libzip configuration headers and generated error table inside `SDKs`. It checks these source SDKs even when the platform SDK bundle is already installed. Normal builds do not download dependencies or require a system libzip, zlib, or CMake installation. + +The module operates on ZIP archives and entries. It provides archive creation, opening, entry enumeration and lookup, sequential entry streams, adding and replacing entries from caller-provided streams, explicit directories, renaming, deleting, compression selection, saving, and discarding changes. Store and Deflate are supported initially; encrypted entry decoding and encoding are not enabled in this phase. Unsupported methods produce explicit errors. + +### Streams and ownership +Archive input uses `ISeekableStream`. Opening reads archive metadata on demand rather than loading the entire archive into memory. Added entry sources are seekable streams and are retained until the archive releases them. Their data must remain unchanged until save or discard. This permits large inputs without unconditional whole-file buffering. + +Entry readers provide `IStream` semantics. They do not promise seeking in compressed entries. An archive owns its libzip handle and readers retain their archive. An archive cannot be edited, saved, or discarded while a reader is open. Calls involving the same archive, its readers, or shared source streams must be externally serialized. + +ZIP entry indices are session-local and must not be treated as persistent identifiers. Entries can be deleted without renumbering the remaining session entries; enumeration skips deleted entries. + +### Editing and saving +Mutations are staged in the archive session. Neither mutation nor object destruction implicitly saves. Releasing an unsaved archive discards its changes. + +Stream-based saving writes a complete archive to a distinct caller-owned output stream. It must not alias the archive input or any retained entry source, including aliases through separate handles. The output must support writing, seeking and resizing, and its previous contents are replaced. The original inputs are never overwritten by the module. + +A failed save leaves the archive session available for retry or discard; the destination may contain incomplete data. A successful save consumes the session, as libzip closes its archive when committing. The caller can reopen the destination to continue editing. Empty archives are preserved as valid ZIP files. + +This output-stream contract deliberately does not claim atomic replacement of a filesystem path or physical-storage durability. A caller that needs those guarantees supplies a temporary destination and publishes it using its storage-specific replacement and synchronization protocol. `ISeekableStream` does not expose a durable synchronization operation, and Runtime's current `IFile::flush` cannot report synchronization errors. + +### Layering +`Zip` exposes archive-level edits such as replacing an entry with a data source. A future `Pak` module supplies directory-tree policy, file-style random writes, caching, stable logical file identities, and repeated flush sessions using these operations. VFS supplies its Pak driver. Asset stores its database as an ordinary VFS file. None of those higher-level features is part of this change. + +The ZIP format is kept standard, including ZIP64 when required. File naming, directory-tree validation, and extraction policy must not be confused with archive parsing: this module does not extract entry paths onto a filesystem. + +## Impact +Applications can read and write ordinary ZIP files without depending on Pak, VFS, or Asset. Store and Deflate archives remain interoperable with standard ZIP tools. Download recipes record third-party source versions and archive hashes; downloaded SDKs retain the upstream licenses. Optional codecs and AES backends are disabled to keep the initial dependency set bounded. The wrapper rejects encrypted data operations, including legacy ZIP encryption compiled into libzip's core. + +Both third-party libraries are static targets even in a shared-module build. LunaBuild must honor an explicit `StaticLibrary` target kind when choosing output names, archive commands, Windows import-library outputs, and runtime staging. Normal Luna `SharedLibrary` targets continue to follow the global linkage option. + +Users of stream-based save must provide a separate output and explicitly handle publication. Compressed entry readers are sequential. These boundaries avoid promising filesystem transactions and random-access decoding that neither a generic stream nor the selected library provides automatically. + +## Alternatives considered +### Implement a private container immediately +A private container could optimize block-level random access and incremental commits, but those requirements are not part of the first Zip module. It would also lose direct interoperability with ordinary ZIP tools. + +### Expose libzip directly +Exposing libzip types would couple application code and future Pak code to its allocation, error, and handle-lifetime conventions. A Luna interface keeps those details private while retaining archive-level capabilities. + +### Hide ZIP support inside Pak or VFS +This would prevent business code from using ordinary ZIP archives independently and mix archive algorithms with filesystem policy. + +## Version history +* **2026/9/4** Proposed and implemented for review. Introduce Zip first; future Pak depends on Zip, and VFS integrates Pak through a driver. diff --git a/LunaSDK-Docs/ADRs/ADR-0019 Introduce Pak storage and asset metadata databases.md b/LunaSDK-Docs/ADRs/ADR-0019 Introduce Pak storage and asset metadata databases.md new file mode 100644 index 00000000..1f5f769e --- /dev/null +++ b/LunaSDK-Docs/ADRs/ADR-0019 Introduce Pak storage and asset metadata databases.md @@ -0,0 +1,129 @@ +## Status +Approved. + +## Last updated +2026/9/4 + +## Background +Shipping a game as loose files increases deployment cost. A single package should represent a directory tree, support read-only and editable use, and defer physical writes until explicit flush or VFS unmount/shutdown. Asset discovery should read one metadata database instead of opening many `.meta` files. + +These requirements cross three separate domains. VFS routes file operations between storage devices. Asset manages logical assets, including GUIDs, types, shared paths, and multiple data units. An archive is a general container of files and directories and must not depend on either domain. ADR-0018 supplies the independent Zip module used by this decision; ADR-0014 defines the asset records that the database must preserve. + +Before this change, VFS had driver callbacks and an error-returning unmount operation, but no device flush operation. Its module shutdown cleared mounts without calling their unmount callbacks. Runtime `IFile::flush` and module shutdown return no error. Current Asset metadata loading, saving, moving, and copying directly assume per-asset JSON `.meta` files. Adding only a package reader or a second discovery function would not address these lifecycle and persistence assumptions. + +## Decision +Treat Pak storage, VFS integration, and Asset metadata databases as one architecture delivered in stages under this ADR. The standalone Pak, VFS integration and Asset database stages are implemented with documentation and tests. All stages update this ADR's delivery record rather than introducing separate competing architectural decisions. + +### Module responsibilities +| Module | Responsibility | Dependencies relevant to this feature | +| --- | --- | --- | +| Zip | Standard ZIP/ZIP64 parsing, archive edits, codecs and checksums | Runtime, libzip; libzip uses zlib | +| Pak | Validated directory tree, seekable file handles, staged file writes, repeatable flush | Runtime, Zip | +| VFS | Filesystem instances, path routing, mount references, storage publication, unmount/shutdown | Runtime, Pak | +| Asset | Asset records and database implementations over ordinary VFS files | VFS and existing serialization facilities | + +Pak does not define asset GUIDs, loaders, data units, `.meta` files, database filenames, or mount points. Applications can use Pak directly with arbitrary seekable streams. VFS mounting does not implicitly register assets. Application code first mounts storage, then opens/imports the appropriate Asset database through its VFS root. + +### Package representation and paths +A phase-one `.pak` is a standard single-volume ZIP/ZIP64 archive with a validated filesystem naming profile. There is no mandatory private header, asset manifest, or reserved file. Ordinary ZIP tools can inspect it, and compatible ordinary ZIP archives can be opened as packages. + +Store and Deflate are supported in the first phase. Creation accepts a default compression selection for new files; Store is the default. Individual files may override it. Existing files preserve their method unless explicitly changed. The default is a session policy, not a hidden package entry. Encryption and other codecs are rejected explicitly during package opening. Future codecs/encryption use ZIP method identifiers, flags and extra fields through Zip, with Pak defining their file-access policy. A format revision is required before adding semantics that existing readers must not silently ignore. + +Paths are UTF-8, case-sensitive and slash-separated, with no Unicode normalization. Imported names are decoded by Zip/libzip before Pak validates and indexes them. The package has an implicit root and synthesizes parent directories missing from the ZIP directory. Duplicate explicit paths, file/directory collisions, non-empty directory entries, absolute ZIP entry names, empty/dot/parent components, backslashes and drive-style colons are rejected. Public operations accept an optional leading slash and a directory trailing slash; the empty path and `/` identify the root. No symbolic-link resolution or host filesystem permission interpretation is performed. ZIP entries marked as links by external attributes are ordinary file bytes here. + +Opening scans the ZIP central directory and builds a memory index. It does not decode file payloads. Zip's bounded archive-tail reads may overlap payload bytes while locating structural records, so this is not a guarantee that every physical read lies exclusively in metadata. + +### File operations and staging +Pak exposes Runtime file access/creation modes, file attributes, directory iteration, directory creation, file copying, subtree moves, and deletion. Parents must exist before creation. Copy operates on a file; deleting a directory requires it to be empty. Directory iteration is a sorted snapshot. ZIP timestamps and external attributes are not part of the phase-one Pak API; Runtime timestamp fields are zero. + +Read-only files are decoded on demand through Zip, with independent logical cursors. Seeking forward skips decoded bytes; seeking backward reopens and replays decoding. This keeps read-only memory bounded but does not promise constant-time random access, even for Store with the current Zip interface. Reading through EOF verifies the ZIP CRC; a partial read does not validate unread data. + +Writable opening materializes that file into a private seekable staging stream, unless its contents are being discarded. The default staging stream uses memory, with a configurable per-file limit (256 MiB initially). Applications can supply a staging-stream factory backed by temporary files or other storage for larger editable files. The limit applies to the built-in memory implementation, not to read-only decoding or custom staging. Extending a file zero-fills the gap. Staging never writes the source package. + +A file permits multiple readers or one exclusive writable handle. Operations affecting an open file, and moves of a subtree containing open files, return `E_BUSY`. Other files remain editable. Handles retain their package; directory snapshots do not block mutations or flush. Operations on one package and its handles are serialized by a package mutex. Separately opened packages sharing the same underlying stream still require caller synchronization. Staging callbacks must not reenter the package or return aliased storage. + +### Repeatable flush and error handling +`IPak::flush(destination)` writes a complete package to a distinct readable, writable, seekable and resizable stream. All package file handles must be released first. The output must not alias the current source or any retained staging stream, including through separate handles. Pointer-identical aliases are rejected. Output contents are replaced; input contents remain unchanged. + +Pak retains its own directory and staged data independently of Zip's editing session. Flush opens a separate Zip edit session over the current source, applies changes, saves, and reopens the output before adopting it as the new source. Surviving unchanged ZIP entries can be copied without decoding/recompression. This isolates Pak from Zip's consuming save operation and allows repeated edit/flush cycles on the same Pak object. + +On failure, Pak keeps its previous source, pending changes and dirty state for retry; the destination may be incomplete, or complete if reopening failed. On success, it releases old sources/staging, adopts the destination, and clears dirty state. Flushing an unchanged editable package still writes a complete output. A newly created empty package is dirty until first flush. Read-only packages reject flush. Explicit discard closes the package without writing, and ordinary destruction also discards pending changes. + +This is stream serialization, not filesystem publication or durable synchronization. It deliberately takes an output stream rather than overwriting a source that libzip is still reading. Native namespace replacement is handled by VFS through Runtime `move_file` with explicit flags; arbitrary streams still have no generic transactional publication primitive. `IFile::flush` cannot report synchronization failures. + +### VFS integration stage +Expose a reference-counted `IFileSystem` representing an already-created filesystem instance. It supplies file access, attributes, directory iteration, directory creation, copying, moving, deletion, native-path translation and error-returning `flush()`. Its paths are relative to the instance root; the empty path names that root. Concrete filesystem implementations can be used directly without a VFS mount. + +VFS mounts an instance with `mount(IFileSystem*, mount_path)` and retains it. There is no driver-name registry, `DriverDesc`, mount callback, or erased `params_type`/`params_data` object. Runtime reflection still identifies interfaces and objects, but is not a VFS driver registry. Native instances are constructed by `new_native_file_system(native_path)`. Pak instances are constructed by `new_pak_file_system(native_path, mode, options)` or its `IPakStorage*` overload. Factories validate and resolve storage before mounting; mount only attaches an existing instance to a namespace. + +A filesystem can be mounted at multiple paths. These aliases share data, access mode, publication state and file-handle constraints. Copy/move methods accept an optional destination instance; null selects the source instance. VFS offers the operation to the source instance first. Native instances preserve native copying and file/directory moving across native roots. Pak handles operations within one instance, including between aliases. Implementations return `E_NOT_SUPPORTED` without side effects for unsupported combinations, selecting bounded streaming of file bytes. Fallback moves copy then delete and do not provide a multi-device transaction; this generic path copies files only. Concrete implementations recognize compatible destination objects without a router-owned driver registry. + +A specific filesystem is saved by calling `IFileSystem::flush()` directly, without resolving a mount path. Remove `VFS::flush(mount_path)`. Retain `VFS::flush_all()` for application-wide saving; it visits each distinct mounted instance once, attempts all instances and preserves the first failure's diagnostic. Native filesystem flush has no deferred device-level work. Pak flush requires all of that instance's file handles to be released, whether opened through VFS, another mount alias or the instance directly. Directory snapshots do not block Pak flush. File-handle `flush()` only flushes staging and does not publish the package. + +Checked unmount requires that mount's VFS files and directory iterators to be released, then calls the instance's flush. Failure keeps the mount available for retry. Success removes only that mount's reference; it does not close or invalidate the filesystem instance. External references and other mounts may continue using it or mount it again. Ordinary instance destruction releases storage and discards any unsaved changes; applications using instances without mounting explicitly flush before releasing them. + +Pak retains its storage provider, package and pending publication in the filesystem object. Clean and read-only instances need no serialization or publication. Serialization failure preserves the original input and staged edits. Publication failure retains the completed output; reads remain available while new edits return `E_BUSY`. Retry publishes the same output without recompressing. Instance-level synchronization and file-handle accounting also guard direct flush and pending-publication retry; mount bookkeeping is not the authority for Pak persistence. + +`IPakStorage` supplies `open_source`, `create_output` and `publish` for application-owned streams. The instance retains the provider. The provider owns publication and output cleanup; writable instances require exclusive control of backing storage, including aliases through other paths or objects. Providers and filesystem methods invoked by VFS must not reenter VFS. Provider callbacks must not reenter their owning filesystem or mutate a retained input. + +Runtime uses one `move_file(from, to, FileMoveFlag flags = FileMoveFlag::none)` API for ordinary moves and replacement. `allow_overwrite` permits replacing an existing file; existing directories cannot be overwritten, and a directory cannot replace an existing file. Moving a directory to an absent destination remains supported with either overwrite setting. `no_copy` independently forbids native cross-volume copy/delete fallback. The default preserves existing calls: no overwrite, with Windows permitting cross-volume file copying and POSIX requiring a native rename. Keeping these flags independent preserves ordinary move behavior while making Pak publication's stronger requirement explicit. Source and destination must identify distinct files or directories, and callers must exclude concurrent namespace changes. + +For native packages, serialize to a sibling temporary file and publish through Runtime `move_file(from, to, FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy)`: POSIX `rename` or Windows `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`, without cross-volume copy fallback. See the [Apple rename reference](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/rename.2.html) and [Microsoft MoveFileExW reference](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw). A reopenable stream closes native handles for replacement while preserving the adopted stream's identity, size and logical cursor. Failed serialization cleans up incomplete outputs. Failed publication preserves the complete temporary file and reports its path. Namespace replacement does not promise physical-media durability. + +VFS shutdown first invalidates/releases all VFS-owned file and iterator wrappers, then flushes each distinct instance once and releases the mounts. Failures are logged because module shutdown cannot return errors. VFS does not close caller-owned filesystem objects or direct handles, and has no unmount/force-unmount callbacks. A caller-owned Pak file still open at shutdown can therefore cause `E_BUSY`; applications must release direct handles before checked flush/unmount or shutdown. Failed publication outputs follow the storage provider's retention policy; complete native outputs remain available for recovery. + +Path routing selects the longest matching mount prefix in the same root and absolute/relative namespace; remount rejects occupied destinations. VFS wrappers coordinate mount lifetimes. Concrete instances own synchronization of their direct operations; Pak serializes its own operations and file handles. Native handles remain ordinary Runtime files. VFS retains its global routing/device-operation lock, and instance methods used by VFS must not call back into the router. + +### Asset database stage +Implement this stage through a pure-virtual `IAssetDatabase` interface and typed factories for sidecar and single-file databases. Both implementations use ordinary VFS APIs and existing serialization facilities. Database records contain a GUID, a relative path, a type and named data-unit descriptors. Database objects can be used independently by conversion tools; registration attaches one retained database to the Asset registry. Registered databases have nonoverlapping roots, and callers must keep their VFS bindings stable. Direct mutation/reload of a registered provider is reserved for Asset's coordinating operations. + +Database snapshots separate external reads from registry publication. Read and validate a complete candidate snapshot before reconciling entries; reserve affected entries during commit so active data-unit operations cannot invalidate validation. Registration and reload reject duplicate paths/GUIDs, ownership conflicts and changes incompatible with loaded units without partially changing the registry. Reload rejects dirty databases. Unregistration requires unloaded idle assets and a successful checked database flush; handles remain unregistered placeholders. Legacy paths outside explicit database roots continue using sidecars. + +Single-file databases stage record changes in memory, and database flush writes JSON through VFS to a sibling temporary file before replacing the database. Sidecars preserve immediate per-record saves using the same replacement helper. Extend `FileMoveFlag` through VFS and Pak moves; `no_copy` forbids the router's streaming fallback, and replacement fails without deleting the destination when unsupported. Pak validates all affected entries and handles before changing its staged namespace. No Asset implementation accesses native paths or imports Pak/Zip. + +Conversion exports a validated relative-path snapshot from an independent source database into a newly created single-file database, preserving GUIDs and named units. It copies metadata only and never deletes source sidecars; callers copy the selected payload files separately. A database file and reserved metadata temporary files are excluded from generic asset payload maintenance. + +One centralized database covers a specified root and its complete descendant tree, as selected by the user. For example, `/Assets/assets.db` contains records whose asset paths are relative to `/Assets`. The main data unit remains implicit in the asset type. The JSON database has its own `format_version` of `1` and an `assets` array, with GUIDs encoded through Runtime's existing `[low, high]` unsigned 64-bit representation. Sidecars continue reading versions 1 and 2 and writing version 2. The filename is configurable and is an ordinary file to VFS/Pak. Records and named units are sorted on output; in-memory indexes support GUID/path lookup. + +Opening a centralized database reads and validates the one database file without registering assets. Explicit registration resolves paths beneath the chosen root and installs records from the logical snapshot without reading payload files or scanning for `.meta` files. When stored in Pak, the database itself is a file payload that must be read; the requirement is to avoid decoding the other asset payloads. The same database works in a normal directory. + +Database roots have explicit ownership: reject overlapping registered roots initially. An explicitly selected database is authoritative within its root; do not silently merge sidecars and centralized records, or fall back to scanning after a corrupt database. Validate versions, relative paths, duplicate GUID/path mappings, record contents and existing-registry conflicts before applying a batch. Metadata updates retain the existing busy/loaded data-unit checks. Metadata failure must not leave a partially registered batch. + +Route metadata load/save and asset create/move/copy/delete through the selected database. A discovery-only second API is insufficient because the existing maintenance operations unconditionally write or move `.meta` files. In centralized mode, metadata save updates a database record and database flush writes the file; generic asset file enumeration must not treat that shared file as one asset's payload. Moves across database roots transfer the record through both providers and report partial physical-operation failures; no global filesystem transaction is implied. Sidecar mode preserves the existing developer workflow and old metadata compatibility. + +Conversion is an explicit tooling operation: collect and validate sidecar records, write the centralized database, verify it, and only remove sidecars when explicitly requested. Packaging can leave source sidecars untouched while emitting only the database and payload files. Database authority is never inferred solely from a `.pak` extension. + +### Persistence order across the layers +Applications save asset data units and metadata records, flush the Asset database into ordinary VFS files, release file handles, then flush/unmount the VFS device. For Pak, database and asset file updates are then serialized into the same package output. Closing Asset precedes closing VFS, which precedes Pak/Zip dependencies. VFS never calls back into Asset to discover pending metadata. Checked application shutdown must follow this order; Pak cannot save Asset changes that were never written to its files. + +## Impact +Applications gain ordinary filesystem operations on a single archive without depending on VFS or Asset. Asset databases remain usable on any mounted filesystem, and ZIP tools remain useful for inspection and interoperability. No additional third-party dependency is required beyond the setup-managed Zip dependencies. + +An archive flush is proportional to the resulting package size and is not an append journal or block transaction. Read-only compressed seeking can be expensive; writable files require staging proportional to their uncompressed size. Custom staging limits memory use but adds application storage responsibilities. Block compression, encryption, extraction, background compaction, filesystem permissions and crash-durable generic stream transactions are outside the first stage. + +VFS replaces driver registration and parameter-erased mounting with retained filesystem objects, typed factories and instance flush. Callers must migrate and rebuild; no compatibility driver registry is retained. Instance identity determines shared-state operations; compatible implementations may handle cross-instance copies/moves directly, with generic streaming as fallback unless `no_copy` forbids it. VFS depends on Pak/Zip, so its builds require the setup-managed libzip/zlib source SDKs. Existing sidecar workflows remain supported. Centralized Asset mode routes discovery and maintenance through providers and requires explicit flush ordering. `get_asset_files` now lists payload files only; metadata files are owned and maintained by the provider. + +Existing move callers remain source-compatible through default flags arguments, but consumers must rebuild for changed Runtime, VFS and Pak function signatures. Custom `IFileSystem` implementations must accept the new move flags and implement their semantics or return `E_NOT_SUPPORTED` without mutation. The separate Runtime replacement API is removed; its callers use `allow_overwrite | no_copy`. Zip entry-data replacement is a separate operation and retains its own interface. + +## Alternatives considered +### Retain the driver registry and expose an instance wrapper +Keeping both construction mechanisms would preserve erased mount parameters and duplicate the ownership model. Direct instance factories make construction, references and explicit flush usable independently of routing. Driver registration and mount lifecycle callbacks are removed rather than wrapped. + +### A private binary Pak format +A private format could optimize block access and incremental commits, but would add format tooling and validation work before those requirements are demonstrated. Standard ZIP/ZIP64 plus a strict directory profile satisfies the current scope and already supports codec extension identifiers. + +### Asset records in the ZIP central directory +This would couple general archive storage to logical assets and prevent the same metadata implementation from working in loose directories. A normal database file keeps the responsibilities separate. + +### Expose Zip directly as the VFS driver +Zip has entry-level edits, sequential readers, nonrecursive directory markers and a consuming save. A Pak layer provides the file semantics, tree validation and repeated flush lifecycle that a VFS driver needs, and makes those capabilities reusable outside VFS. + +### Buffer the entire archive or overwrite it in place +Whole-archive buffering scales with package size. In-place generic stream rewrites can destroy source bytes still needed for a flush and cannot promise recovery. Separate destinations and per-file staging give explicit ownership and retry behavior. + +## Version history +* **2026/9/4** Proposed the complete Pak/VFS/Asset design and implemented the standalone Pak stage for review, with API documentation, programming guides, PakTest and Python ZIP interoperability checks. Build and tests passed on macOS arm64. VFS integration and Asset database implementations remain the next delivery stages under this ADR. +* **2026/9/4** Implemented the VFS Pak driver stage: native/custom storage mounts, checked flush/publication/retry, unmount/shutdown cleanup, retained driver registrations, handle tracking, routing and streaming copy fixes, and native Runtime replacement. Added the VFS programming guide and VFSPakTest. VFSPakTest and the existing AssetTest passed on macOS arm64, including output I/O faults, native replacement failure/retry, shutdown recovery and existing sidecar workflows. Windows replacement is implemented but has not been executed on a Windows host in this stage. Asset databases remain pending. +* **2026/9/4** Replaced the VFS driver registry and callbacks with `IFileSystem`, direct Native/Pak factories and instance-level flush. Added shared mount aliases, distinct-instance flush deduplication, direct-handle accounting and caller-owned object lifetimes. Preserved native cross-instance file/directory moves through optional destination-instance arguments. Migrated repository callers and added the filesystem-instance guide and ownership/alias/direct-access regression tests. VFSPakTest, AssetTest, GameGUITest and GameGUIEditorServiceTest passed on macOS arm64; GameGUIEditor and Studio built successfully. GameGUITest passed a standalone retry after one macOS RHI initialization failure. Windows execution remains unverified. Asset databases remain pending under this ADR. +* **2026/9/4** Consolidated native file replacement into Runtime `move_file` with independent `FileMoveFlag::allow_overwrite` and `FileMoveFlag::no_copy` options. Migrated native Pak publication and updated public API and programming documentation. RuntimeTest passed all four option combinations, destination-content preservation, directory moves and replacement rejection, argument checks and POSIX dangling-link protection. VFSPakTest passed, including native publication failure/retry and shutdown persistence, on macOS arm64. Windows execution remains unverified. +* **2026/9/4** Approved and implemented the Asset database stage: pure `IAssetDatabase`, independent sidecar/file providers, versioned JSON records, explicit root ownership, validated batch registration/reload, database-aware asset maintenance, checked metadata replacement/flush and metadata-only export. Propagated move flags through VFS/Pak for replacement without streaming fallback. Added three Asset guides and AssetDatabaseTest. AssetDatabaseTest, AssetTest, PakTest, VFSPakTest and GameGUIEditorServiceTest passed on macOS arm64. Coverage includes metadata-only discovery, malformed records, late batch conflicts, read-only policies, cross-provider transfer rollback, publication failure/retry, shutdown flush and Pak mount relocation. The editor service test required access to macOS graphics services after RHI initialization failed inside the sandbox; it passed outside that sandbox. Windows execution remains unverified. diff --git a/LunaSDK-Docs/Drafts/Asset Database Format.md b/LunaSDK-Docs/Drafts/Asset Database Format.md new file mode 100644 index 00000000..76c3274f --- /dev/null +++ b/LunaSDK-Docs/Drafts/Asset Database Format.md @@ -0,0 +1,56 @@ +The centralized asset database is a UTF-8 JSON file containing all records beneath one VFS root. It uses the existing Variant serialization infrastructure and requires no additional database library. VFS and Pak treat it as an ordinary file; only Asset interprets its contents. + +See [[Asset Databases]] for provider lifetime and save operations, and [[Converting Asset Metadata]] for generating this file from sidecars. + +## Concepts + +The database has its own `format_version`, currently `1`, and an `assets` array. It does not embed the VFS root. Each record uses a relative asset path, so the same database and payload tree can be mounted at a different root without rewriting records. + +| Field | Meaning | +| --- | --- | +| `guid` | A nonzero asset GUID using Runtime's two-element unsigned 64-bit array, `[low, high]`. | +| `path` | The nonempty, canonical relative asset path using `/` separators. | +| `type` | The nonempty asset type name. | +| `data_units` | Optional array of named units, each containing a nonempty `id` and `loader`. | + +The main data unit is implicit in `type` and is not included in the named-unit array. Type and loader names need not be registered during discovery. The format contains no asset payload bytes or requirement that those files already exist. + +The built-in writer sorts records by path and named units by ID. The in-memory provider indexes paths and GUIDs for record lookup and conflict checking; these indexes are rebuilt when opening the file. + +## Programming guide + +### Read and write through the provider + +Use `open_file_database`, `create_file_database` and `export_asset_database` instead of editing JSON during normal application operation. A minimal file looks like: + +```json +{ + "format_version": 1, + "assets": [ + { + "guid": [10681767224022494022, 2655911415428893589], + "path": "Characters/Hero", + "type": "Game.Character", + "data_units": [ + { "id": "Editor", "loader": "Game.CharacterEditor" } + ] + } + ] +} +``` + +Opening this file with root `/Game` resolves the asset to `/Game/Characters/Hero`. It does not read any files belonging to that asset. Tools that process the JSON independently must preserve unsigned 64-bit integer precision for GUIDs. + +### Validate input + +The reader checks the database version, field types, nonzero GUIDs, nonempty names, relative paths and unique GUID/path/data-unit-ID mappings. Absolute paths, parent traversal, noncanonical spellings, embedded NULs and invalid path components are rejected. Paths are case-sensitive at the Asset/VFS level; callers must also respect the underlying filesystem's naming behavior. + +The database filename is configurable and relative to the root. It cannot use the `.meta` extension or the reserved `.luna-asset-` filename prefix. The database file itself, sidecars and reserved temporary filenames cannot be registered as asset paths in a file database. Payload enumeration excludes them so moving or deleting one asset cannot consume shared metadata. + +Storage validation occurs before registration. Registry validation then checks root ownership, existing GUID/path mappings and loaded or busy data units before committing a complete batch. Errors retain the previous logical database and registry state. + +### Preserve sidecar compatibility + +Sidecar providers accept the existing metadata versions `1` and `2`; an absent sidecar version retains the legacy default. They write version `2`. Export preserves the GUID, type and named units and adds the asset path relative to the selected root. The centralized database version is independent of these sidecar versions. + +Opening, exporting or registering a database never deletes the source sidecars. Close and flush providers according to [[Asset Databases]]. diff --git a/LunaSDK-Docs/Drafts/Asset Databases.md b/LunaSDK-Docs/Drafts/Asset Databases.md new file mode 100644 index 00000000..9e30c0c0 --- /dev/null +++ b/LunaSDK-Docs/Drafts/Asset Databases.md @@ -0,0 +1,108 @@ +Asset databases store the persistent identity and structure of assets. A database covers one VFS root and its complete descendant tree. Applications can keep the development layout of one `.meta` file per asset, or store all records in one file for distribution. Both layouts use ordinary VFS operations and work with native directories, Pak files and other filesystem implementations. + +See [[Asset Database Format]] for the centralized format, [[Converting Asset Metadata]] for export, and [[Mounting Pak Files]] for package storage. + +## Concepts + +An **asset record** contains a persistent GUID, a path relative to the database root, an asset type and named data-unit descriptors. The main data unit is implicit in the type. Metadata discovery does not require payload files, registered types or registered loaders; loading a data unit does require the corresponding type and loader. + +A **database provider** implements `IAssetDatabase`. A sidecar provider reads and writes individual `.meta` files. A file database holds the complete logical record set in memory and writes one JSON file on flush. Providers can also be used independently of the runtime asset registry, for example by packaging tools. + +The **asset registry** associates records with stable runtime handles and loaded objects. Registering a database retains its provider and registers its records as one validated batch. Database roots must not overlap, and a GUID cannot be registered at multiple paths, including paths through VFS mount aliases. Register the database before discovering or creating assets within its root. Assets outside explicitly registered roots continue using the legacy sidecar workflow. + +The **logical snapshot** includes accepted metadata edits that may not yet have reached storage. A fresh storage snapshot is separate: it can be validated against the registry before replacing either view. + +## Programming guide + +### Initialize and select a provider + +Add `DependsOn("Runtime", "Asset", "VFS")` to the application target. Initialize Runtime, add `module_asset()` and initialize modules. Asset initializes its VFS dependency. Create and mount the desired filesystem before opening its metadata database. + +Include `` for the provider and registration APIs: + +```cpp +#include + +Luna::R> open_game_assets() +{ + using namespace Luna; + auto opened = Asset::open_file_database("/Assets", "assets.db", + Asset::DatabaseMode::read_write); + if(failed(opened)) return opened.errcode(); + auto database = opened.get(); + auto registered = Asset::register_asset_database(database); + if(failed(registered)) return registered.errcode(); + return database; +} +``` + +Choose a factory according to the storage layout: + +| Factory | Behavior | +| --- | --- | +| `new_sidecar_database(root, mode)` | Creates a provider without scanning. Defaults to read/write. Registration scans sidecars under the root. | +| `open_file_database(root, filename, mode)` | Reads and validates the centralized file only. Defaults to read-only access. Registration uses its already loaded logical snapshot. | +| `create_file_database(root, filename)` | Publishes an empty writable database. Requires an existing parent directory and refuses an existing file. | + +These factories do not register assets. The default centralized filename is `assets.db`; it is configurable and relative to the root. `DatabaseMode` controls metadata writes independently of the filesystem's access mode. Selecting read/write access does not make a read-only mount writable. + +Keep a registered provider's VFS bindings and backing files stable. Before changing the mount, unregister the database. After deliberately changing its contents externally, use the checked reload API below. + +### Discover and edit metadata + +| API | Database behavior | +| --- | --- | +| `register_asset_database(database)` | Validates all records and ownership before registering the complete snapshot. Unregistered GUID placeholders retain their handles. | +| `load_assets_meta(path, allow_overwrite)` | Uses registered database snapshots within their roots; scans legacy sidecars elsewhere. Validates the complete batch before publication. | +| `load_asset_meta(asset)` | Reconciles one asset from its provider's current logical record. | +| `new_asset(path, type, save_meta)` | Creates metadata through the selected provider when `save_meta` is true. | +| `save_asset_meta(asset)` | Saves one runtime descriptor into its provider, including type and named data units. | +| `get_asset_database(path)` | Returns the explicitly registered provider covering a path; legacy fallback paths return `E_NOT_FOUND`. | + +Changing a runtime descriptor does not implicitly save it. After changing the type or named data units, call `save_asset_meta`. `set_asset_path` changes the runtime path only; a subsequent metadata save relocates the record from its last saved location. Use `move_asset` when payload files must move as well. + +Within an explicit database root, that provider is authoritative. Asset does not merge stray sidecars into a centralized database or silently fall back after a database error. + +Use `reload_asset_database(database)` to reread storage. It rejects a dirty provider with `E_BUSY`, checks the complete candidate against existing ownership and loaded/busy data units, and then replaces the provider snapshot and registry view. Invalid records or conflicts leave the registry and any cached provider snapshot intact. Sidecar providers read files directly and do not cache an old storage snapshot. Records removed from storage are unregistered only when their data units are unloaded and idle. A failed reload never partially installs earlier records in the batch. + +### Move, copy and delete assets + +`get_asset_files` enumerates payload files associated with the asset base name. It excludes `.meta` files, centralized database files and reserved metadata temporary files. Metadata is maintained through the provider instead of being treated as one of these payload files. + +`move_asset` preserves the GUID, transfers payload files and relocates the record. Moves between database roots transfer metadata through the source and destination providers. `copy_asset` creates an independent record and normally a new GUID; it may also populate a caller-supplied unregistered GUID handle. `delete_asset` removes payloads and the record. Metadata writes to read-only providers fail before payload changes; copying from a read-only source to a writable destination is permitted. + +Maintenance reserves affected assets against concurrent data-unit operations. Moves and copies attempt to roll back transferred payloads after a later failure, and report paths whose rollback failed. These operations do not provide a transaction spanning multiple physical files or filesystems. Deletion can likewise fail after deleting some payloads; check every result. + +### Save and publish + +Use this order for a checked save: + +1. Save changed asset data units through their loaders. +2. Call `save_asset_meta` for changed runtime descriptors. +3. Call `database->flush()` or `flush_asset_databases()`. +4. Release filesystem file handles. +5. Call the filesystem instance's `flush()`, or checked `VFS::unmount`. + +Sidecar saves write immediately. File databases stage changes until database flush. A flush serializes the complete JSON into a sibling temporary file and publishes it through checked VFS replacement. Failed publication preserves the previous database file and leaves the provider dirty for retry. Temporary-file cleanup is attempted; if cleanup fails, the error reports the retained path. Custom filesystems must support the requested move/replacement operation without streaming fallback. These saves do not promise physical-media durability. + +Database flush writes ordinary VFS files. In a Pak filesystem these edits are still staged until the filesystem itself flushes. A mount or filesystem flush never asks Asset to save pending runtime descriptors or database records. Both save steps must succeed before a package is ready to distribute. + +`flush_asset_databases()` attempts every registered provider and returns the first failure with its diagnostic. It does not call `save_asset_meta` or save data units implicitly. + +### Unregister and shut down + +Unload all owned data units and finish their operations before calling `unregister_asset_database`. It flushes the provider, then removes its registrations. Failure retains registration for retry. Existing GUID handles remain valid unregistered placeholders, and the filesystem remains mounted. + +Module shutdown closes Asset before VFS. Asset attempts database flush and logs failures because `Asset::close()` cannot return an error. It does not save unsaved runtime descriptors or data-unit objects. Use the checked save sequence for application shutdown, then release application-held providers and other Runtime objects before `Luna::close()`. + +### Implement a custom provider + +Implement every pure virtual method of `IAssetDatabase`. Record paths are relative to its immutable root. `get_records` exposes the logical snapshot; `read_snapshot` reads a candidate from storage without mutating that snapshot; `accept_snapshot` validates and installs a clean candidate without writing storage. A dirty provider rejects snapshot acceptance. + +Implement `write_record`, `remove_record`, `is_dirty` and `flush` with consistent logical-view and retry semantics. `is_metadata_file` identifies files that asset payload maintenance must exclude. A read-only provider rejects record writes and removals before changing storage. + +Synchronize provider operations and do not reenter Asset from provider methods. For a registered provider, let coordinating Asset APIs perform record mutations and reloads so ownership and the runtime registry remain consistent. Independent tools may call provider methods directly. Explicit `flush` is valid in either use. + +## Validation + +Run `AssetDatabaseTest` through LunaBuild for centralized discovery, snapshot validation, metadata maintenance, export, read-only access, failed-publication retry and Pak persistence. `AssetTest` covers existing sidecar and data-unit workflows. diff --git a/LunaSDK-Docs/Drafts/Converting Asset Metadata.md b/LunaSDK-Docs/Drafts/Converting Asset Metadata.md new file mode 100644 index 00000000..4cd5f872 --- /dev/null +++ b/LunaSDK-Docs/Drafts/Converting Asset Metadata.md @@ -0,0 +1,49 @@ +Metadata export converts a provider's complete logical record set into a centralized database. It preserves asset identities and relative paths, allowing a development directory with sidecars to produce a distribution directory or Pak containing one metadata file. + +The exporter handles metadata only. Applications choose and copy asset payload files separately. See [[Asset Database Format]] for the result and [[Asset Databases]] for using it at runtime. + +## Concepts + +The **source provider** supplies records beneath one root. It need not be registered with the global Asset registry. A read-only sidecar provider is useful for packaging directly from project files without loading data units or registering asset types and loaders. + +The **destination root** defines where the exported relative paths will resolve. The exporter creates a new centralized database there. It does not change the source provider, remove sidecars or rewrite payload files. The destination payload tree must retain the corresponding relative layout. + +## Programming guide + +### Export a directory tree + +Initialize Asset and mount the source and destination filesystems. Create the destination root and the parent directory of the database file before export: + +```cpp +#include + +Luna::RV export_project_metadata() +{ + using namespace Luna; + auto source = Asset::new_sidecar_database("/Project/Assets", + Asset::DatabaseMode::read); + if(failed(source)) return source.errcode(); + return Asset::export_asset_database(source.get(), + "/Distribution/Assets", "assets.db"); +} +``` + +Export collects and validates the complete snapshot before publishing the destination file. Duplicate GUIDs, invalid records or an existing destination are errors. Source sidecars remain untouched. An independently opened file database can also be used as the source; export includes its accepted but unflushed logical edits. + +### Package payloads + +Copy the selected payload files to the destination with the same relative paths. If the destination is a writable Pak filesystem, both payload copies and the exported database remain staged in that filesystem. Release file handles, check `IFileSystem::flush()` and then unmount or release it. Export itself does not flush the filesystem. + +To export only selected records or named units, create an empty unregistered file database, obtain records with the source's `get_records`, filter them, write each accepted record with `write_record`, and check `flush`. Filtering a named-unit descriptor does not automatically remove files belonging to that unit; the packaging tool must choose its payload set consistently. + +### Verify and adopt + +Reopen the destination with `open_file_database` to validate the stored file. Registration is a separate runtime operation. If the same GUIDs are already registered from the source, unregister the old database before registering the destination; export and independent validation do not require this switch. + +Use the destination provider as the authoritative metadata source within its root. Retain project sidecars for development. Sidecar removal, if desired, is a separate explicit application operation after verification. + +Release independent providers before Runtime shutdown. Registered providers follow the checked flush and unregister sequence described in [[Asset Databases]]. + +## Validation + +`AssetDatabaseTest` checks that export preserves GUIDs, relative paths and named units, refuses an existing destination and leaves source sidecar bytes unchanged. It also mounts an exported database inside Pak, edits and flushes it, then reopens the package at another VFS root. diff --git a/LunaSDK-Docs/Drafts/Creating an External LunaSDK Project.md b/LunaSDK-Docs/Drafts/Creating an External LunaSDK Project.md index 44735d92..633b34fa 100644 --- a/LunaSDK-Docs/Drafts/Creating an External LunaSDK Project.md +++ b/LunaSDK-Docs/Drafts/Creating an External LunaSDK Project.md @@ -90,8 +90,10 @@ MyLunaApp/ Main.cpp ``` -`global.json` selects the .NET 9 SDK family used by LunaBuild while allowing -newer installed .NET 9 feature bands. +`global.json` selects the .NET 9 SDK family used by LunaBuild when available, +and rolls forward to a higher installed major SDK when no compatible .NET 9 +SDK is installed. The generated LunaBuild runner also rolls forward to a higher +installed major runtime when the .NET 9 runtime is unavailable. ### 2. Inspect the project diff --git a/LunaSDK-Docs/Drafts/Drafts.md b/LunaSDK-Docs/Drafts/Drafts.md index 60df4f27..00679365 100644 --- a/LunaSDK-Docs/Drafts/Drafts.md +++ b/LunaSDK-Docs/Drafts/Drafts.md @@ -6,3 +6,20 @@ Documents in this directory are drafts awaiting review and promotion into the us ## Window - [[Application Main Menus]] - [[Message Boxes]] + +## Zip +- [[Zip Archives]] +- [[Editing Zip Archives]] + +## Pak +- [[Pak Files]] +- [[Editing Pak Files]] + +## VFS +- [[File System Instances]] +- [[Mounting Pak Files]] + +## Asset +- [[Asset Databases]] +- [[Asset Database Format]] +- [[Converting Asset Metadata]] diff --git a/LunaSDK-Docs/Drafts/Editing Pak Files.md b/LunaSDK-Docs/Drafts/Editing Pak Files.md new file mode 100644 index 00000000..bdf59652 --- /dev/null +++ b/LunaSDK-Docs/Drafts/Editing Pak Files.md @@ -0,0 +1,92 @@ +Pak stages changes to files and directories, then writes a complete package when explicitly flushed. The original package is read-only input even during editing. This lets applications control output storage and publication while continuing to edit the same `IPak` object across multiple flushes. + +See [[Pak Files]] for initialization, paths, readers and directory enumeration. + +## Concepts + +`Options` selects the default compression of newly created files and the storage used for writable contents. Store is the default. Deflate accepts level `0` for the library default or levels `1` through `9`; Store requires level `0`. The default is a session setting, not a file hidden inside the package. Existing files keep their compression unless changed explicitly. + +Selected levels remain in effect across flushes on the same Pak object. ZIP does not record the original encoder level, so opening an existing package uses level `0` for subsequent edits unless a level is selected explicitly. + +A **staging stream** contains one file's uncompressed editable bytes. The built-in staging implementation uses memory with a default per-file limit of 256 MiB. A custom staging factory can supply temporary files or other streams. Staged data is retained until successful flush or discard. Multiple staged files can collectively exceed the per-file memory limit. + +A **flush destination** is a separate readable, writable, seekable, resizable stream that receives a complete ZIP. Pak opens it after saving and adopts it as its source for subsequent reads and edits. This stream must remain unchanged while retained. The old source is released after successful adoption. + +## Programming guide + +### Create or edit + +Use `new_pak(options)` to create an empty editable package. No output is written at creation. To edit an existing package, pass `OpenMode::read_write` and options to `open_pak`. A read-only package rejects write access, directory mutations, compression changes and flush. + +Create parent directories first with `create_dir`. `open_file` supports Runtime's creation modes: `create_new`, `create_always`, `open_always`, `open_existing`, and `open_existing_as_new`. Creation or truncation requires write access. `FileOpenFlag::user_buffering` is accepted as a hint; Pak already manages its own decoding/staging. + +Opening an existing file for writing copies its complete decoded contents into staging. Truncating it skips this copy. A staged file supports random reads, writes and resizing; extending it or writing beyond EOF zero-fills the gap. This touches staging storage only. Each file allows several readers or one writer, so opening a conflicting handle returns `E_BUSY`. + +### Change the directory tree + +| Operation | Behavior | +| --- | --- | +| `create_dir(path)` | Creates one directory; its parent must exist. | +| `copy_file(from, to)` | Copies one file's contents and compression selection to a new path using independent staging. | +| `move_file(from, to, flags)` | Moves one file or a complete directory subtree. The default rejects an existing destination; `FileMoveFlag::allow_overwrite` permits replacing an existing file. Directories cannot be replaced. | +| `delete_file(path)` | Deletes a file or an empty directory. The root cannot be deleted. | +| `set_file_compression(path, method, level)` | Selects the encoding for the next flush, without immediate recompression. | +| `get_file_compression(path)` | Returns the current selected method. | + +Moving/deleting an open file, replacing an open destination, moving a subtree with open files, truncating an open file, or changing an open file's compression returns `E_BUSY`. Unrelated paths remain editable. Copying from a file with an active writer is also busy. Directory removal is nonrecursive, and directory moves cannot move a directory into itself. Failed validation leaves the tree unchanged. `FileMoveFlag::no_copy` is accepted: moves within one package change its staged namespace without copying file contents. + +### Flush repeatedly + +Release file handles, then call `flush(destination)`. It replaces the destination's contents with the complete package. The destination must not alias the current source or any retained staging stream, including through separate handles. Pak checks identical interface pointers; applications must exclude other storage aliases. + +A successful flush keeps the Pak object open, adopts the destination as its new source, releases staged contents and clears `is_dirty()`. New and mutated packages are dirty. An unchanged editable package can also be flushed to create a complete copy. A second flush needs another destination: the previous destination is now the retained input. + +A failed flush preserves the previous input, directory tree, staged contents and dirty state for retry. The destination may be incomplete. If writing succeeded but reopening the output failed, the output may be complete; it is still not adopted. Fix the output or supply another stream and retry. A normal file write to staging can fail after partially writing bytes; check its returned byte count and result as with Runtime files. + +`IFile::flush()` only concerns the file's staging stream. It does not write the package and cannot report errors through its Runtime signature. Always use `IPak::flush` to commit the archive and check its result. + +```cpp +#include + +Luna::RV create_game_package(Luna::ISeekableStream* destination, bool compress) +{ + using namespace Luna; + Pak::Options options; + options.compression = compress ? Pak::CompressionMethod::deflate + : Pak::CompressionMethod::store; + auto created = Pak::new_pak(options); + if(failed(created)) return created.errcode(); + auto package = created.get(); + auto made = package->create_dir("config"); + if(failed(made)) return made.errcode(); + { + auto opened = package->open_file("config/build.txt", + FileOpenFlag::write, FileCreationMode::create_new); + if(failed(opened)) return opened.errcode(); + const c8 text[] = "release"; + auto written = opened.get()->write(text, sizeof(text) - 1); + if(failed(written)) return written.errcode(); + } + return package->flush(destination); +} +``` + +### Customize writable storage + +Set `Options::create_staging_stream` and optionally `Options::staging_userdata`. The userdata is retained by the package. The factory receives it as `object_t` and returns a private `ISeekableStream`. Pak resets the stream to zero length before use. It must support reading, writing, seeking and resizing, must remain exclusively controlled by Pak, and must not alias the package input or other staging streams. + +The callback runs under the package mutex and must not reenter the package. Errors are returned to the initiating file operation. The factory can create temporary files and arrange their cleanup through the returned object's lifetime. The built-in `max_memory_file_size` limit does not apply to custom streams. + +Without a factory, opening a larger existing file for writing or growing past the memory limit returns `E_OUT_OF_RANGE`. Read-only streaming does not have this limit. Choose a suitable limit or custom storage based on expected uncompressed file sizes. + +### Publish and shut down + +Pak flush does not rename native files or guarantee physical-storage synchronization. For disk publication, write a distinct temporary file, check flush, and use the storage system's checked replacement protocol. Do not delete the original before a replacement is ready. After flush the Pak object retains the output; account for platform handle-sharing requirements when publishing it. A publication failure is still pending work even when `is_dirty()` is false. + +`discard()` closes a package without saving; repeat discard succeeds. Release handles before discard and release all objects before Runtime shutdown. Releasing the last package reference without flushing discards pending edits. + +## Validation + +Run `dotnet run --project LunaBuild.csproj -- run --target PakTest`. The tests cover tree validation, directory operations, access and sharing modes, seeking, zero-filled writes, compression, repeated flush, save failures and custom staging storage. They also read Pak output through Zip to check ordinary ZIP compatibility. + +Run `python3 Tests/PakTest/Interop.py` for independent ZIP interoperability checks. It invokes PakTest through LunaBuild, imports Python Store/Deflate and ZIP64 data, saves through native file streams, and validates the resulting packages with Python. Do not run it concurrently with another LunaBuild command. diff --git a/LunaSDK-Docs/Drafts/Editing Zip Archives.md b/LunaSDK-Docs/Drafts/Editing Zip Archives.md new file mode 100644 index 00000000..02d6dbb6 --- /dev/null +++ b/LunaSDK-Docs/Drafts/Editing Zip Archives.md @@ -0,0 +1,79 @@ +The `Zip` module creates and edits standard ZIP archives with an explicit save operation. Edits remain in the session until the application chooses a destination. The input archive and entry data sources are never implicitly overwritten. This supports applications that build ordinary ZIP files as well as higher-level archive storage systems. + +See [[Zip Archives]] for module initialization, entry readers, threading, names, and shutdown. + +## Concepts + +A **data source** is an `ISeekableStream` supplying one file's uncompressed contents. Adding or replacing an entry retains that stream and reads its entire contents from offset zero when needed. The source must stay unchanged until the archive releases it. Streams may be shared when access is serialized. Unchanged entries in an existing ZIP can be copied in their stored representation when saving. + +A **compression selection** consists of a `CompressionMethod` and a level. `store` writes bytes without compression and requires level `0`. `deflate` supports levels `1` through `9`, or `0` for the library default. Compression is selected per entry. A caller building a package can use the same selection for all entries or choose individually. Reading detects each entry's method automatically. + +A **destination** is a separate writable, seekable, resizable stream. Saving replaces its contents with the complete ZIP. Successful save consumes the session; reopen the destination to continue editing. A failed save preserves the session for retry or discard, but its destination can contain partial data. + +## Programming guide + +### Start a session + +Initialize Runtime and Zip as described in [[Zip Archives]]. Use `new_archive()` for a new editable archive, or `open_archive(input, OpenMode::read_write)` for an existing ZIP. Empty archives are valid and are preserved when saved. + +### Stage changes + +| Operation | Behavior | +| --- | --- | +| `add_file(name, source, method, level)` | Adds an entry from uncompressed bytes. Duplicate names return `E_ALREADY_EXISTS`. | +| `add_directory(name)` | Adds an explicit directory marker and appends `/` if needed. Parent markers are not created. | +| `replace_file(index, source)` | Replaces a file's data and preserves its current compression method, using the default compression level. | +| `set_compression(index, method, level)` | Selects the encoding used at the next save, including recompression of existing data. | +| `rename_entry(index, name)` | Renames one entry. The trailing `/` must preserve its file/directory kind. | +| `delete_entry(index)` | Deletes one entry without renumbering the remaining indices. | + +Enumeration and lookup reflect staged edits immediately. File readers can read staged source bytes before compression and saving. Release readers before further editing. Replacement may discard entry extra fields that libzip considers dependent on the old data; the initial API does not expose ZIP comments, timestamps, permissions, or arbitrary extra-field editing. + +### Save or discard + +Call `save(destination)` after closing all entry readers. The destination must not alias the original archive or any retained entry source, including separate handles to the same storage. Pointer-identical aliases are rejected; detecting aliases through different handles is the caller's responsibility. + +Saving can read all surviving entry data and rewrite the entire destination. It is not an in-place append or a cheap per-entry flush. After success, `is_open()` returns `false`; entry operations return `E_BAD_CALLING_TIME`. Reopening gives a new session and fresh indices. Saving an unchanged archive also writes a complete output. + +If saving fails, inspect the error and retry with a usable destination, or call `discard()`. Both an explicit discard and ordinary object destruction abandon pending edits. Neither performs a hidden save. `discard()` succeeds on an already closed session. + +For filesystem publication, provide a temporary output file, save successfully, release relevant handles, and publish the result using the storage system's replacement procedure. Zip itself does not rename files, promise atomic replacement, or guarantee physical-storage durability. Runtime's generic stream interface has no durable synchronization operation. Applications must choose a publication and synchronization protocol appropriate for their storage. + +### Example: create with optional compression + +The source and destination below are supplied by the caller; no VFS module is involved. Zip must already be initialized. + +```cpp +#include + +Luna::RV create_zip(Luna::ISeekableStream* contents, + Luna::ISeekableStream* destination, bool compress) +{ + using namespace Luna; + auto created = Zip::new_archive(); + if(failed(created)) return created.errcode(); + auto method = compress ? Zip::CompressionMethod::deflate + : Zip::CompressionMethod::store; + auto added = created.get()->add_file("data/content.bin", contents, method); + if(failed(added)) return added.errcode(); + return created.get()->save(destination); +} +``` + +For repeated saves, reopen each successfully saved output in `OpenMode::read_write`, stage the next group of edits, and save to a separate output. A higher-level component can manage temporary files and publication around this sequence. + +### Shutdown + +Release readers, save or discard sessions, and release all sources and destinations owned by the application before shutting down Runtime. External serialization is required for the session and every shared source stream. + +## Validation + +Build and run the module's regression tests with: + +```sh +dotnet run --project LunaBuild.csproj -- run --target ZipTest +``` + +The tests cover compressed and stored entries, staged reads, shared source cursors, empty directories and files, ZIP64 entry counts, editing, reader lifetimes, malformed input, CRC errors, and save failures at initialization, writing, and commit. + +For optional interoperability checks with Python's standard ZIP implementation, run `python3 Tests/ZipTest/Interop.py`. This launches ZipTest through LunaBuild, tests imported Store, Deflate, and ZIP64 data, checks rejection of an unsupported codec, and validates Luna's output with Python. Do not run it concurrently with another LunaBuild command. diff --git a/LunaSDK-Docs/Drafts/File System Instances.md b/LunaSDK-Docs/Drafts/File System Instances.md new file mode 100644 index 00000000..2b7a0ede --- /dev/null +++ b/LunaSDK-Docs/Drafts/File System Instances.md @@ -0,0 +1,81 @@ +VFS separates filesystem storage from virtual path routing. An application creates an `IFileSystem` object for its storage, uses that object directly when appropriate, and mounts it into the VFS namespace when a shared virtual path is needed. The router retains instances and forwards operations to them. + +## Concepts + +A **filesystem instance** owns the state for one storage root. Its public interface is in `Luna/VFS/FileSystem.hpp`. Native directories and Pak files have concrete factory functions; custom implementations provide the same interface. There is no filesystem-driver registration step. + +A **mount** maps one VFS root path to an existing instance. Multiple mount paths may reference the same instance. Mounting does not create, configure or flush storage. Unmounting removes a reference after a successful flush; it does not destroy a caller-owned instance. + +An **instance-relative path** addresses a file or directory beneath that object's root. An empty `Path` addresses the root. A **VFS path** is resolved against the mounted namespace and then translated to an instance-relative path. + +## Programming guide + +### Initialize and create a native instance + +Add `DependsOn("Runtime", "VFS")` to your target, initialize Runtime, register `module_vfs()` and initialize modules. Include `` for routing and `` for native directory instances. + +```cpp +lupanic_if_failed(Luna::init()); +lupanic_if_failed(Luna::add_modules({Luna::module_vfs()})); +lupanic_if_failed(Luna::init_modules()); +{ + auto created = Luna::VFS::new_native_file_system("Data"); + lupanic_if_failed(created); + auto file_system = created.get(); + lupanic_if_failed(Luna::VFS::mount(file_system, "/Assets")); + { + auto file = Luna::VFS::open_file("/Assets/config.json", + Luna::FileOpenFlag::read, Luna::FileCreationMode::open_existing); + lupanic_if_failed(file); + // Read the file here. + } + lupanic_if_failed(Luna::VFS::unmount("/Assets")); +} +Luna::close(); +``` + +The native directory must already exist. Its path is resolved at creation, so later working-directory changes do not affect it. Direct operations use paths such as `file_system->open_file("config.json", ...)`. Absolute paths, named roots and unresolved parent components are invalid for instance methods. Runtime `Path` normalization still applies. Native permissions and symbolic-link behavior come from the platform filesystem. + +Native handles are ordinary Runtime `IFile` objects. Their `flush()` retains its normal Runtime meaning. Native `IFileSystem::flush()` has no deferred device-level work. Pak instances provide package-level publication through this same interface; see [[Mounting Pak Files]]. + +### Mount, route and remount + +Call `VFS::mount(file_system, mount_path)`. Null instances are rejected, and an occupied mount path returns `E_ALREADY_EXISTS`. A mount retains the object, so the local factory result can be released afterwards. Preserve a reference if the application needs direct access or explicit instance flush. + +Routing selects the longest matching mount prefix with matching path roots and absolute/relative flags. A more specific nested mount wins regardless of mounting order. `VFS::remount(from, to)` changes only the namespace location; an occupied destination is rejected. + +Several paths may mount the same instance. They share storage and access settings. VFS first offers copies and moves to the source instance, passing the destination instance as the optional third argument. Native instances support native file copying and file/directory moving between native roots. Pak instances provide optimized operations within the same instance, including between mount aliases. Other combinations fall back to bounded file streaming; fallback moves copy then delete. The generic streaming path does not move directories. + +Copies do not overwrite an existing destination. If streaming fails, VFS removes a partial destination only if that operation created it, and reports cleanup failure when it cannot remove it. + +### Flush and release + +Call `file_system->flush()` to save one instance. Its implementation defines deferred persistence and busy-handle requirements. `VFS::flush_all()` calls every distinct mounted instance once and returns the first failure after attempting the others. It does not discover unmounted objects. + +Checked unmount requires the selected mount's VFS files and iterators to be released. It calls instance flush before releasing the mount reference. Flush failure retains the mount. Other mounts or caller-held references keep the instance alive and usable afterwards. + +At shutdown, VFS releases underlying handles held by all of its wrappers, flushes each distinct mounted instance once, logs failures, and releases mounts. Caller-owned instances and directly opened handles are not closed by the router. They must follow their own lifetime/persistence requirements. Release application-held objects before shutting down Runtime. + +### Implement a filesystem + +Implement `IFileSystem` as a reflected Runtime object. Supply `open_file`, `get_file_attribute` and `open_dir`, and override the mutation/native-path methods that the storage supports. Unsupported optional operations return `E_NOT_SUPPORTED`. The default `flush()` succeeds without work; an implementation with deferred writes overrides it and preserves retryable state on error. + +Construct and configure the object through an application factory or constructor before mounting. No driver name, `DriverDesc`, erased parameter object, or mount/unmount callback is involved. The object's destructor releases resources. Returned handles must retain the storage they need, and the implementation must own synchronization and busy checks for direct use as well as routed use. + +`copy_file(from, to, destination)` and `move_file(from, to, destination, flags)` may handle another instance directly; an omitted or null destination selects the current instance. VFS tries these methods before streaming. Returning `E_NOT_SUPPORTED` must leave storage unchanged. Unless `FileMoveFlag::no_copy` forbids fallback, VFS can stream a file to an absent destination. The generic fallback never replaces an existing destination. Other errors propagate directly. Concrete implementations decide which destination objects they support; the router keeps no type registry. `get_native_path` returns `E_NOT_SUPPORTED` for entries without independent native paths. + +`VFS::move_file(from, to, flags)` rejects existing destinations by default. `FileMoveFlag::allow_overwrite` permits compatible native moves and same-Pak moves to replace an existing file; it does not enable replacing a directory. Combine it with `no_copy` when publishing a completed temporary file so an unsupported move cannot fall back to copying into the destination. Native and Pak implementations validate replacement before mutation; affected open Pak files return `E_BUSY`. + +VFS routing and device operations use a global mutex. Its handle wrappers also serialize operations within each mount while coordinating unmount/shutdown. An implementation's direct methods and handles follow that implementation's synchronization policy. Methods invoked by VFS must not reenter the router; provider callbacks must also respect their owning instance's reentrancy rules. + +## Migrating earlier VFS code + +| Earlier API | Instance API | +| --- | --- | +| Driver registration and `DriverDesc` callbacks | A concrete `IFileSystem` implementation and factory | +| Native driver name plus path passed to `mount` | `new_native_file_system(native_path)`, then `mount(instance, mount_path)` | +| Pak mount descriptor and `mount_pak` | `new_pak_file_system(path_or_storage, mode, options)`, then `mount(instance, mount_path)` | +| `VFS::flush(mount_path)` | `instance->flush()` | +| Cleanup callbacks owned by VFS | Reference-counted instance/handle lifetimes | + +Update callers and rebuild against the new public headers. The driver registry, driver-name lookup functions, descriptor and path-based single-device flush API have been removed. diff --git a/LunaSDK-Docs/Drafts/Mounting Pak Files.md b/LunaSDK-Docs/Drafts/Mounting Pak Files.md new file mode 100644 index 00000000..2c8bc378 --- /dev/null +++ b/LunaSDK-Docs/Drafts/Mounting Pak Files.md @@ -0,0 +1,128 @@ +VFS represents a Pak file as an `IFileSystem` instance. Applications create the instance, optionally mount it as a virtual directory, and use the same file APIs as native directories. Read-only instances suit shipped data; editable instances stage changes and publish a new package on instance flush, checked unmount or VFS shutdown. + +See [[File System Instances]] for the general instance/mount model, and [[Pak Files]] and [[Editing Pak Files]] for package paths, compression, seeking and staging storage. Creating or mounting a filesystem does not discover or register assets. + +## Concepts + +A **Pak filesystem** owns one opened package, its access mode, storage provider and pending publication. It is usable before mounting and after unmounting. Several VFS mount paths can reference the same instance and share its data, mode and file-handle constraints. + +A **storage provider** is an `IPakStorage`. It supplies the existing source stream, a fresh output stream for each save, and the operation that publishes a completed output. The built-in provider uses native files; custom providers can use memory or other application storage. + +A **pending publication** is a complete saved package whose publication failed. It remains readable through the instance and all its mount aliases. New edits are blocked until a retry publishes the same output. + +## Programming guide + +### Initialize and create + +Run setup to prepare Zip's libzip/zlib SDKs. Add `DependsOn("Runtime", "VFS")` to your target. Include `` and ``. VFS registers Pak and its Zip dependency automatically. + +```cpp +lupanic_if_failed(Luna::init()); +lupanic_if_failed(Luna::add_modules({Luna::module_vfs()})); +lupanic_if_failed(Luna::init_modules()); +{ + auto opened = Luna::VFS::new_pak_file_system("Game.pak"); + lupanic_if_failed(opened); + auto file_system = opened.get(); + lupanic_if_failed(Luna::VFS::mount(file_system, "/Game")); + // Use VFS files here, releasing them before unmounting. + lupanic_if_failed(Luna::VFS::unmount("/Game")); +} +Luna::close(); +``` + +`new_pak_file_system(native_path, mode, options)` opens an existing package. The defaults are read-only access and default `Pak::Options`. Create a new backing package through `Pak::new_pak` and flush it before opening a filesystem. + +Native paths are resolved at instance creation. Later working-directory changes and mount/remount operations do not change the backing location. An editable instance requires exclusive control of its backing storage, including aliases through other objects, processes or paths. Reusing one instance at multiple VFS paths is supported; opening independent editable instances over the same backing storage is not. + +### Access and mount aliases + +Direct instance paths are relative to its root: + +```cpp +auto direct = file_system->open_file("config/settings.json", + Luna::FileOpenFlag::read, Luna::FileCreationMode::open_existing); +auto routed = Luna::VFS::open_file("/Game/config/settings.json", + Luna::FileOpenFlag::read, Luna::FileCreationMode::open_existing); +``` + +The empty `Path` names the instance root. Absolute paths, named roots and remaining parent components are rejected by instance methods. Runtime `Path` normalization occurs before routing and validation. VFS paths select the longest matching mount prefix in the same root and absolute/relative namespace. + +Read/write flags, creation modes, attributes and directory operations follow Pak semantics. Read-only instances reject mutations. Timestamps are zero. Package entries have no independent native path, so `get_native_path` returns `E_NOT_SUPPORTED`. + +Mounting the same instance again creates an alias: + +```cpp +lupanic_if_failed(Luna::VFS::mount(file_system, "/GameAlias")); +``` + +Edits through either path or through the instance are immediately visible through the others. Copies and directory moves within the same instance use Pak operations even between aliases; file copies preserve their source compression. Copies between Pak and another instance stream file bytes and use the destination's defaults. These cross-instance moves copy then delete and have no multi-filesystem transaction guarantee. The generic path copies files only; native-to-native operations can use the native implementation's copy/move support. + +### Edit and flush + +```cpp +Luna::Pak::Options options; +options.compression = Luna::Pak::CompressionMethod::deflate; +auto opened = Luna::VFS::new_pak_file_system("UserData.pak", + Luna::Pak::OpenMode::read_write, options); +lupanic_if_failed(opened); +auto file_system = opened.get(); +lupanic_if_failed(Luna::VFS::mount(file_system, "/UserData")); +{ + auto file = Luna::VFS::open_file("/UserData/save.bin", + Luna::FileOpenFlag::write, Luna::FileCreationMode::create_always); + lupanic_if_failed(file); + const Luna::c8 contents[] = "saved state"; + lupanic_if_failed(file.get()->write(contents, sizeof(contents) - 1)); +} +lupanic_if_failed(file_system->flush()); +lupanic_if_failed(Luna::VFS::unmount("/UserData")); +``` + +`IFileSystem::flush()` acts directly on the instance without routing a mount path. It requires **all of this instance's file handles** to be released, including direct handles and handles from every mount alias. Otherwise it returns `E_BUSY`. Directory snapshots can remain open. `IFile::flush()` only flushes a file's staging storage and does not publish the package. + +`VFS::flush_all()` flushes each distinct mounted instance once, even when it has several aliases. It attempts the later instances after a failure and preserves the first error's diagnostic. Unmounted instances must be flushed directly by their owner. + +Clean and read-only instances need no output. A dirty native instance serializes into a sibling temporary file, then calls Runtime `move_file` with `FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy` for checked same-filesystem replacement. Successful flush keeps the instance open for further edits. + +### Handle save failures + +Serialization failure preserves the original package and staged edits, and removes the incomplete native output. Publication failure preserves the original backing file and the completed temporary output; its path is included in the error. Reads remain available, while new mutations return `E_BUSY`. + +Call the same instance's `flush()` to retry. After publication failure it retries the completed output without recompressing. Release any readers opened since the failure before retrying. Both the pending state and busy checks belong to the instance, so direct operations and every mount alias follow the same rules. + +Replacement follows native filesystem semantics. It does not guarantee physical-media durability or provide a multi-file transaction. Do not externally rename, edit or replace an opened backing package. + +### Custom storage + +Implement all three pure virtual methods of `IPakStorage` and pass it to the storage overload of the factory. The interface provides no default implementations. Read-only providers must explicitly implement `create_output()` and `publish(output)` to return `E_NOT_SUPPORTED`. + +```cpp +Luna::R> open_application_package( + Luna::VFS::IPakStorage* storage) +{ + return Luna::VFS::new_pak_file_system(storage, Luna::Pak::OpenMode::read_write); +} +``` + +| Method | Contract | +| --- | --- | +| `open_source()` | Returns a readable/seekable stream containing the existing package. Its bytes and size remain unchanged while retained. | +| `create_output()` | Returns a fresh readable, writable, seekable, resizable stream that does not alias a retained input or staging stream. Read-only instances never call it. | +| `publish(output)` | Publishes the completed output. Failure preserves the prior publication and keeps the output usable for retry. Success also keeps that same output stream readable because Pak has adopted it. | + +The filesystem retains its provider. Callbacks are synchronous and must not reenter the owning filesystem or VFS. Returned stream lifetimes own cleanup, including incomplete outputs; the provider defines retention when publication fails during shutdown. The built-in provider preserves logical stream identity while closing native handles for file replacement and reopening on later reads. + +### Unmount and shutdown + +Checked `VFS::unmount` requires that mount's VFS file handles and directory iterators to be released, then flushes its instance. The Pak flush can still return `E_BUSY` for files opened directly or through another alias. Failure retains the mount for retry. Success releases only that mount's reference: the instance remains usable through caller references and other mounts. + +VFS shutdown first releases the underlying files/iterators of all VFS wrappers, then flushes each distinct mounted instance once and releases its references. It logs failures. It does not close caller-owned instances or direct file handles. Release direct handles before shutdown so they do not block Pak saving. Complete native outputs awaiting failed publication remain available for recovery. + +Unloading the last instance reference discards unsaved edits. Applications using an instance without mounting must flush it explicitly. Release application-held Runtime objects before `Luna::close()`; shutdown invalidation does not make wrappers usable after Runtime ends. + +When integrating with Asset, save data units and runtime metadata, call the Asset database's `flush()`, release file handles, then flush the filesystem or unmount. A mount does not ask Asset to write pending records. See [[Asset Databases]] for opening a centralized database through a Pak mount and for the complete save sequence. + +## Validation + +Run `dotnet run --project LunaBuild.csproj -- run --target VFSPakTest`. The suite covers native/custom storage, direct instance operations, shared mount aliases, deferred publication and retries, file/iterator lifetimes, routing, bounded copies and shutdown ownership. `AssetTest` checks existing sidecar workflows through the updated VFS. diff --git a/LunaSDK-Docs/Drafts/Pak Files.md b/LunaSDK-Docs/Drafts/Pak Files.md new file mode 100644 index 00000000..989df6eb --- /dev/null +++ b/LunaSDK-Docs/Drafts/Pak Files.md @@ -0,0 +1,68 @@ +The `Pak` module presents a single ZIP/ZIP64 stream as a directory tree. It adds validated paths, seekable file handles and ordinary directory operations to Zip. It is useful for game packages and other grouped files, and can also open ordinary ZIP archives that conform to its naming and compression rules. It has no dependency on VFS or Asset and assigns no special meaning to asset metadata files. + +The public API is in `Luna/Pak/Pak.hpp`. See [[Editing Pak Files]] for creation, optional compression, writable staging, repeated flush and custom staging storage. + +## Concepts + +A **package** is an `IPak` object with one root directory. `open_pak` opens a retained seekable stream; `OpenMode::read` is the default. File contents are decoded on demand. Opening and directory enumeration build and query an index without decoding file payloads. ZIP structural reads can overlap a bounded portion of the archive tail. + +A **package path** is a case-sensitive UTF-8 string with `/` separators. The empty string and `/` identify the root. Public calls accept an optional leading `/`; a trailing `/` denotes a directory. Other empty components, `.` or `..` components, backslashes and colons are rejected. Unicode normalization is not performed, so differently encoded names remain distinct. Names are not native host paths. + +Imported entry names are decoded by Zip/libzip before Pak validates and indexes them. + +A **file handle** is a Runtime `IFile` with an independent cursor and read/write access flags. Several read-only handles can access one file, or one writable handle can access it exclusively. A handle keeps the package alive. Read-only files stream from Zip; files opened for writing use private staging storage. + +A **directory iterator** is an `IFileIterator` snapshot of the direct children, sorted by UTF-8 byte order. It includes synthesized parent directories, omits `.` and `..`, and does not block changes or flush. Later changes do not alter an existing snapshot. + +## Programming guide + +### Initialize + +Run setup to prepare Zip's libzip/zlib source SDKs. Add `DependsOn("Runtime", "Pak")` to your target rules. Register `module_pak()` after Runtime initialization; Pak registers Zip as a dependency. + +```cpp +lupanic_if_failed(Luna::init()); +lupanic_if_failed(Luna::add_modules({Luna::module_pak()})); +lupanic_if_failed(Luna::init_modules()); +``` + +### Open and inspect + +Pass a readable `ISeekableStream` to `Pak::open_pak`. The whole stream is interpreted from offset zero, regardless of its current cursor. An `IFile` or a custom memory/storage stream can supply it. The stream's contents and size must remain unchanged while the package retains it. + +Use `get_file_attribute` to query size and file/directory flags, and `open_dir` to enumerate direct children. The root always exists. Parent directories omitted from the ZIP are synthesized from file names. File timestamps are zero because this interface does not expose ZIP timestamps. `get_file_compression` returns Store or Deflate for files. + +The package rejects duplicate paths, file/directory conflicts, invalid paths, directory entries containing data, encrypted entries and unsupported compression methods. ZIP external permissions and symbolic-link attributes are not interpreted; their entries are ordinary file bytes, with no link traversal. + +### Read and seek + +Open a file with `FileOpenFlag::read` and `FileCreationMode::open_existing`. Read and check the returned `RV`, including the read that reaches EOF. `seek` accepts Runtime's beginning/current/end modes and permits a cursor beyond EOF. Negative positions and positions beyond signed 64-bit range are errors. + +For a file still backed by Zip, forward reads decode sequentially. A forward seek skips bytes during the next read; a backward seek reopens the decoder and replays the prefix. This applies to Store as well as Deflate with the current Zip implementation. Seeking changes the logical cursor immediately but can make the next read expensive. It does not allocate a whole-file buffer. + +Reading through EOF checks the original entry's CRC. Seeking past the end or releasing a partially read handle does not validate unread bytes. Data in writable staging is checked when it is encoded into a new ZIP and later read back. + +```cpp +#include + +Luna::R> open_packaged_config(Luna::ISeekableStream* source) +{ + using namespace Luna; + auto package = Pak::open_pak(source); + if(failed(package)) return package.errcode(); + return package.get()->open_file("config/settings.json", + FileOpenFlag::read, FileCreationMode::open_existing); +} +``` + +The returned file retains its package and the source even after the local package reference is released. + +### Concurrency and shutdown + +Pak serializes its operations and file-handle operations with a package mutex. Independent read cursors are supported, but I/O within one package runs serially. If external code or another package shares the underlying source stream, that sharing still needs external synchronization. Do not modify a retained source or staging stream outside Pak. + +Release all file handles before calling `flush` or `discard`; otherwise they return `E_BUSY`. Release packages and iterators before Runtime shutdown. Destruction discards pending changes and never writes a stream. For editable packages, explicitly check flush success as described in [[Editing Pak Files]]. + +## Current scope + +This module supplies standalone package operations. VFS provides Pak filesystem instances that can be mounted as virtual directories; see [[Mounting Pak Files]]. Asset metadata databases are a separate integration stage. Pak does not mount itself or discover assets. Encryption, codecs beyond Store/Deflate, symbolic links, native extraction and filesystem permission preservation are not provided. diff --git a/LunaSDK-Docs/Drafts/Zip Archives.md b/LunaSDK-Docs/Drafts/Zip Archives.md new file mode 100644 index 00000000..750fb688 --- /dev/null +++ b/LunaSDK-Docs/Drafts/Zip Archives.md @@ -0,0 +1,86 @@ +The `Zip` module reads ordinary ZIP and ZIP64 archives through Runtime streams. It provides an archive index and sequential readers for individual entries without depending on VFS, Pak, or Asset. Store and Deflate are supported. libzip and zlib are pinned source dependencies downloaded into `SDKs` by setup and compiled locally by LunaBuild; applications do not need a system installation of either library. + +See [[Editing Zip Archives]] for creation, compression selection, editing, and saving. The public API is declared in `Luna/Zip/Zip.hpp`. + +## Concepts + +An **archive session** is an `IArchive` object. `open_archive` opens an existing archive in `OpenMode::read` by default. `OpenMode::read_write` enables staged edits. The input is always read through `ISeekableStream`; opening an editable session does not require write access to that input. + +An **entry** is a named item in the ZIP central directory. Its `EntryInfo` includes its name, session-local index, uncompressed size, compression method, directory marker, and encryption indicator. Compressed size and CRC32 are meaningful only when `has_compressed_size` and `has_crc32` are set. For edited data, these values may remain unknown until saving. + +An **entry reader** is a read-only `IStream` returning uncompressed bytes. Reading is sequential for both Store and Deflate. The API does not promise seeking inside compressed entries. Read through EOF to validate an existing entry's stored CRC32; releasing a partially read stream does not validate unread data. Newly staged data has no stored checksum to compare until saved. + +Entry indices identify entries only within one open session. Deletion leaves gaps, and saving closes the session. Reopen and look up entries again rather than persisting these indices. + +## Programming guide + +### Build and initialization + +Run `setup.sh` on macOS or `setup.bat` on Windows to prepare libzip and zlib under `SDKs`, including when updating an existing checkout. Declare `DependsOn("Runtime", "Zip")` in the consuming LunaBuild target. Include ``. After Runtime initialization, register `module_zip()` with `add_modules`, then call `init_modules`. + +```cpp +lupanic_if_failed(Luna::init()); +lupanic_if_failed(Luna::add_modules({Luna::module_zip()})); +lupanic_if_failed(Luna::init_modules()); +``` + +libzip and zlib are implementation dependencies; application code does not include their headers or handle their native types. + +### Opening and enumeration + +Pass a seekable input to `Zip::open_archive`. An `IFile` is one implementation; memory streams and application-defined storage streams can also be used. The archive interprets the entire input from offset zero, regardless of its initial cursor. To open a subrange, provide a stream that exposes that range as a complete stream. + +The session retains the input. Its contents and size must remain unchanged while retained. The module can move its cursor. Serialize all operations on an archive, its readers, and streams shared with other archives or application code. + +Opening and enumeration read the ZIP directory and structural records without decoding the entry payloads or buffering the whole ZIP. libzip may read a bounded portion of the archive tail while searching for the end-of-directory record, so this is not a guarantee that every underlying read falls exclusively inside metadata bytes. + +Use `get_entries()` to list surviving entries or `find_entry(name)` for exact, case-sensitive lookup. Names are UTF-8; ZIP encoding conversion is handled by libzip. Imported entries using unsupported compression or encryption can still be listed. Opening their data returns `E_NOT_SUPPORTED`. + +### Reading and errors + +Call `open_entry(index)` and repeatedly read until zero bytes are returned. Directory markers cannot be opened as files and return `E_IS_DIRECTORY`. Always check `RV` even when some bytes were returned: decoding and CRC errors may appear near EOF. libzip diagnostics are available through Runtime's `explain(result.errcode())`; use `unwrap_errcode` when comparing an error code that may carry an error object. + +The following helper copies one ZIP entry to a stream without loading it completely into memory. Zip must already be initialized. + +```cpp +#include + +Luna::RV copy_zip_entry(Luna::ISeekableStream* input, + const Luna::c8* name, Luna::IStream* output) +{ + using namespace Luna; + auto opened = Zip::open_archive(input); + if(failed(opened)) return opened.errcode(); + auto index = opened.get()->find_entry(name); + if(failed(index)) return index.errcode(); + auto reader = opened.get()->open_entry(index.get()); + if(failed(reader)) return reader.errcode(); + byte_t buffer[16384]; + while(true) + { + usize size = 0; + auto read = reader.get()->read(buffer, sizeof(buffer), &size); + if(failed(read)) return read.errcode(); + if(!size) break; + auto written = output->write(buffer, size); + if(failed(written)) return written.errcode(); + } + return ok; +} +``` + +### Names and directories + +ZIP names are archive keys, not validated filesystem paths. A name ending in `/` marks an explicit directory. A file such as `textures/stone.png` does not require a separate `textures/` entry. Renaming or deleting a directory marker does not recursively change its descendants. + +The module does not extract files to a directory, normalize `..`, resolve symlinks, or enforce a virtual filesystem's naming rules. Code that maps entry names onto a filesystem must define and enforce its own path policy. + +### Shutdown + +Readers retain their archive. While a reader exists, edits, saving, and discarding return `E_BUSY`. Release readers first, then save or discard the archive. Releasing an unsaved session discards it without writing its input. Release all Zip objects before `Luna::close()`. + +## Supported boundaries + +ZIP64 is emitted when required by archive sizes or entry counts. Stream lengths are limited to the signed 64-bit offsets supported by Runtime seeking. Multi-volume ZIPs, password handling, encryption, codecs other than Store/Deflate, extraction, and asynchronous operations are outside the current API. + +The archive uses memory proportional to its index and pending edit descriptions. Payloads are read through streams as needed. The API does not impose whole-entry buffering; callers control whether their source streams use memory, files, or another medium. diff --git a/LunaSDK-Docs/Manual/Building LunaSDK.md b/LunaSDK-Docs/Manual/Building LunaSDK.md index 4e175d5e..fa7a000d 100644 --- a/LunaSDK-Docs/Manual/Building LunaSDK.md +++ b/LunaSDK-Docs/Manual/Building LunaSDK.md @@ -36,7 +36,7 @@ Install the iOS/iPadOS platform in Xcode. LunaBuild can compile iOS targets, ass ## Setup -Clone or download this project, then download LunaSDK's prebuilt third-party SDK bundle. +Clone or download this project, then run setup to install LunaSDK's third-party SDK bundle and source dependencies. On Windows: @@ -51,7 +51,9 @@ chmod +x ./setup.sh ./setup.sh ``` -The setup script downloads the platform SDK archive into `SDKs`. LunaBuild does not manage packages and does not download third-party libraries during build. All external SDK paths are declared by LunaSDK target rules. +The setup script installs the platform SDK bundle and versioned source SDKs into `SDKs`. Source archives for libzip and zlib are downloaded with pinned SHA-256 checksums; their C sources are compiled locally by LunaBuild when needed. Source SDK checks also run when the platform bundle is already installed, so rerun setup after updating the repository to install newly required dependencies. + +Third-party library sources stay in the Git-ignored `SDKs` directory. The repository contains download recipes and build integration code. LunaBuild does not download third-party libraries during build. All SDK paths are declared by LunaSDK target rules. ## Building diff --git a/LunaSDK-Docs/Manual/File Management.md b/LunaSDK-Docs/Manual/File Management.md index 432d2a36..a41cdb5b 100644 --- a/LunaSDK-Docs/Manual/File Management.md +++ b/LunaSDK-Docs/Manual/File Management.md @@ -16,6 +16,24 @@ Use `get_file_attribute` to fetch the attributes of a specified file or director Use `copy_file` to copy one file. It does not copy directories. Use `move_file` to move or rename a file or directory. Use `delete_file` to delete a file or an empty directory; a non-empty directory must be emptied first. +`move_file(from, to, flags)` accepts optional `FileMoveFlag` options. Omitting the flags preserves the default behavior: an existing destination causes `E_ALREADY_EXISTS` and remains unchanged. + +| Option | Behavior | +| --- | --- | +| `none` | Keeps the default move behavior and does not overwrite the destination. | +| `allow_overwrite` | Allows a file to replace an existing file. Directories cannot replace existing files or be overwritten; moving a directory to an absent destination is still allowed. | +| `no_copy` | Requires a native move on the same filesystem, without copy/delete fallback. A cross-filesystem move returns `E_NOT_SUPPORTED`. | + +Options can be combined with `|`. Windows permits cross-volume file copying unless `no_copy` is set; POSIX always requires a native rename. For example, `move_file(from, to, FileMoveFlag::allow_overwrite)` enables ordinary file replacement. To publish a prepared file on the same filesystem, VFS Pak storage uses: + +```cpp +RV result = move_file("game.pak.tmp", "game.pak", + FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy); +// Check result before treating the prepared file as published. +``` + +This combination performs checked native replacement without deleting the original file first or falling back to copying. The destination may also be absent. The source and destination must identify distinct files or directories, and callers must exclude concurrent changes to either entry. Close handles that prevent native moves first. The operation does not synchronize file data or promise power-loss recovery. Without `no_copy`, cross-volume copying follows native platform semantics and does not provide the same publication guarantees. + Use `open_dir` to create a file iterator (`IFileIterator`) that can be used to iterate over files and directories in the specified directory. Use `create_dir` to create a new empty directory on the specified directory. Use `get_current_dir` and `set_current_dir` to get and set the current working directory of the process. The string returned by `get_current_dir` must be released with `release_current_dir`. diff --git a/LunaSDK-Docs/Manual/GUI Drawing.md b/LunaSDK-Docs/Manual/GUI Drawing.md index 4060dfed..5cbd9e16 100644 --- a/LunaSDK-Docs/Manual/GUI Drawing.md +++ b/LunaSDK-Docs/Manual/GUI Drawing.md @@ -1,14 +1,14 @@ -GUI drawing records primitive GUI-level commands and preserves their painter order across two rendering +GUI drawing generates primitive GUI-level commands and preserves their declared Paint Order across two rendering backends. Analytic geometry, gradients and shadows use the GUI SDF renderer; text, images and arbitrary vector paths use VG. Higher-level packages decide which commands represent a widget. ## Designed functionality The drawing system provides a narrow bridge between GUI data and rendering: -1. Record static draw commands and attach optional delayed draw callbacks to elements. +1. Attach generation-time draw callbacks to elements and submit every command with an explicit Paint Order ID. 2. Support element-relative and layer-coordinate rectangles. 3. Draw analytic SDF shapes, CSS-like gradients, strokes and shadows alongside text, images and VG shape ranges. -4. Generate commands after layout and input routing while preserving element painter order and layer Z order. +4. Generate commands after layout and input routing while preserving required element and GUI Layer order. 5. Keep rendering policy outside GUI widgets, because GUI has no widgets. GUI owns every render/compute pass and target transition needed to execute its final command stream. The @@ -44,23 +44,43 @@ capsules, four-corner rounded rectangles, strokes, composed shapes, and multi-st `DrawPhase::before_children`, `DrawPhase::after_children`, or both phases. The callback runs after layout and input routing, so it can read final layout rectangles, interaction state, -state objects and style values. It emits commands by calling `IContext::draw`. It must not mutate the element -tree, layout, interaction state, or application data. +state objects and style values. It emits commands by calling +`IContext::draw(const DrawCommand&, paint_order_id_t)`. Commands cannot be submitted during element construction. +The callback must not mutate the element tree, layout, interaction state, or application data. `DrawConfig::userdata` is owned by the caller and must remain valid until command generation finishes. GUI stores draw configurations sparsely, so elements without callbacks do not carry callback pointers. -### Painter order -For each layer, GUI replays element construction as a painter-order operation stream: +### Paint Order +`paint_order_id_t` is an unsigned, dense, frame-local ordering coordinate. A lower ID renders before a higher ID. +Giving commands the same ID explicitly permits the renderer to reorder those commands across complete backend batch +keys; submission order remains deterministic within one batch key. Use distinct IDs for overlapping primitives +whose composition order matters. + +A draw callback receives the first Paint Order ID available to its phase and returns the maximum ID it emitted or +reserved. Every `draw` call must pass an ID no lower than the callback input, and the returned maximum must cover all +IDs emitted by the callback. A callback that emits nothing and reserves no IDs returns its input unchanged. + +Each non-empty GUI Layer receives a non-overlapping Paint Order range in bottom-to-top layer order. IDs are +regenerated with draw commands and have no stable identity across frames. `GUI::Layer` remains a structural object +for element ownership, coordinates and input priority; it is not a Paint Order ID. + +### Element paint traversal +For each layer, GUI traverses elements in painter order: 1. Invoke the element's `before_children` callback. -2. Replay static commands recorded at that position. -3. Generate child elements in construction order. -4. Invoke the element's `after_children` callback. +2. Generate child elements according to the element's child Paint Order mode. +3. Invoke the element's `after_children` callback with the first free ID after the child subtrees. This lets a container draw its background below children and its border or feedback above children without requiring the final layout rectangle during element construction. +`ChildPaintOrderMode::sequential` is the safe default and gives each direct child a non-overlapping range in +submission order. `ChildPaintOrderMode::shared` gives independent direct child subtrees the same initial ID so their +compatible commands can batch. Select `shared` only when the complete painted extents of those children cannot +interfere, including shadows, negative margins, visual overflow, explicit clips and custom drawing. A backdrop +capture or another Paint Order barrier ends the current shared run. + ### Backdrop capture and drawing `BackdropBlurCaptureDesc` is a sparse element attachment. When it is enabled, command generation inserts a `backdrop_blur_capture` marker immediately after entering the element and before its `before_children` callback. The @@ -157,34 +177,14 @@ luexp(context->register_font(Name("default"), Font::get_default_font())); ``` ## Programming guide -### Record a static command during element build -Outside draw generation, `draw` records a command at the current layer, element, and painter-order position. - -```cpp -GUI::DrawCommand bg; -bg.type = GUI::DrawCommandType::rounded_rect; -bg.rect_reference = GUI::DrawCommandRectReference::element; -bg.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); -bg.color = Float4U(0.12f, 0.16f, 0.22f, 1.0f); -bg.radius = 6.0f; -context->draw(bg); -``` - -### Record a command for a specific element -Use `draw_for_element` when a command is emitted after the element build scope has ended. +### Attach drawing to an element +All primitive commands are emitted from a `DrawConfig` callback. Store any command inputs in caller-owned frame data, +attach that data through `DrawConfig::userdata`, and keep it valid until command generation finishes. ```cpp -context->draw_for_element(element, bg); -``` - -Invalid handles are ignored. - -### Attach delayed drawing -Use a draw callback when rendering depends on final layout, input, state, or style data. - -```cpp -RV draw_button(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase phase, void* userdata) +R draw_button(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata) { if(phase == GUI::DrawPhase::before_children) { @@ -196,9 +196,9 @@ RV draw_button(GUI::IContext* context, const GUI::ElementHandle& element, background.color = interaction.hovered ? Float4U(0.20f, 0.40f, 0.70f, 1.0f) : Float4U(0.12f, 0.16f, 0.22f, 1.0f); background.radius = 6.0f; - context->draw(background); + context->draw(background, paint_order_id); } - return ok; + return paint_order_id; } GUI::DrawConfig draw_config; @@ -208,6 +208,17 @@ draw_config.phases = GUI::DrawPhaseFlag::before_children; context->set_draw_config(button, draw_config); ``` +If one callback emits overlapping commands that require order, assign increasing IDs and return the last one: + +```cpp +context->draw(background, paint_order_id); +context->draw(border, paint_order_id + 1); +return paint_order_id + 1; +``` + +Commands that are safe to regroup across backend batch keys may use the same ID. Returning an ID greater than every +emitted command reserves the intervening range for the enclosing traversal. + Use both phases when one element needs a background below its children and feedback above them: ```cpp @@ -215,6 +226,8 @@ draw_config.phases = GUI::DrawPhaseFlag::before_children | GUI::DrawPhaseFlag::after_children; ``` +The remaining primitive examples assume they run inside a draw callback and use its `paint_order_id` parameter. + ### Draw text ```cpp GUI::DrawCommand text; @@ -226,7 +239,7 @@ text.font = Name("default"); text.font_size = 16.0f; text.color = Float4U(0.92f, 0.95f, 1.0f, 1.0f); text.text = "Build"; -context->draw(text); +context->draw(text, paint_order_id); ``` ### Draw an image @@ -238,7 +251,7 @@ image.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); image.texture = texture; image.min_texcoord = Float2U(0.0f, 0.0f); image.max_texcoord = Float2U(1.0f, 1.0f); -context->draw(image); +context->draw(image, paint_order_id); ``` Set `nearest_sampler` when the image should show exact texels, such as low-resolution test textures. @@ -254,7 +267,7 @@ shape.shape.buffer = icon_shape_buffer; shape.shape.first_command = icon_first_command; shape.shape.num_commands = icon_num_commands; shape.shape.bounds = icon_bounds; -context->draw(shape); +context->draw(shape, paint_order_id); ``` ### Draw a composed SDF gradient @@ -281,7 +294,7 @@ ring.type = GUI::DrawCommandType::sdf; ring.rect = RectF(40.0f, 20.0f, 0.0f, 0.0f); // Places the local origin. ring.sdf.shape = shape_program; ring.sdf.color = color_program; -context->draw(ring); +context->draw(ring, paint_order_id); ``` ### Compose a shadow and fill in one color program @@ -299,7 +312,7 @@ lulet(effect_program, context->append_sdf_color_program(effect_floats.cspan())); GUI::DrawCommand shadowed_ring = ring; shadowed_ring.sdf.color = effect_program; -context->draw(shadowed_ring); +context->draw(shadowed_ring, paint_order_id); ``` ### Use clips @@ -307,16 +320,19 @@ context->draw(shadowed_ring); GUI::DrawCommand push; push.type = GUI::DrawCommandType::push_clip; push.rect = clip_rect; -context->draw(push); +context->draw(push, paint_order_id); // Draw clipped content. GUI::DrawCommand pop; pop.type = GUI::DrawCommandType::pop_clip; -context->draw(pop); +context->draw(pop, paint_order_id); ``` -Use explicit clips only when a command sequence needs a region stricter than the element layout clip. Scroll viewports and layout containers normally receive their base clipping from `LayoutResult::clip_rect` automatically. +Clip pushes and pops are resolved in lexical submission order before drawable commands are sorted, so they do not +consume separate Paint Order positions. Use explicit clips only when a command sequence needs a region stricter than +the element layout clip. Scroll viewports and layout containers normally receive their base clipping from +`LayoutResult::clip_rect` automatically. ### Draw a backdrop-filtered surface Attach capture configuration to the surface root, then emit `backdrop_blur` before its tint and children: @@ -327,16 +343,28 @@ capture.softness = 12.0f; capture.downsample_level = 1; context->set_backdrop_blur_capture(panel, capture); -GUI::DrawCommand backdrop; -backdrop.type = GUI::DrawCommandType::backdrop_blur; -backdrop.rect_reference = GUI::DrawCommandRectReference::element; -backdrop.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); -backdrop.radius = 8.0f; -context->draw_for_element(panel, backdrop); +GUI::DrawConfig draw_config; +draw_config.name = Name("example.backdrop"); +draw_config.callback = [](GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, + void*) -> R +{ + GUI::DrawCommand backdrop; + backdrop.type = GUI::DrawCommandType::backdrop_blur; + backdrop.rect_reference = GUI::DrawCommandRectReference::element; + backdrop.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); + backdrop.radius = 8.0f; + context->draw(backdrop, paint_order_id); + return paint_order_id; +}; +context->set_draw_config(panel, draw_config); ``` -The same element may own and consume the capture because lookup starts at self. A regular rounded rectangle drawn -after `backdrop_blur` supplies the translucent tint; blur capture does not select widget colors. +Context inserts the capture marker before the element's `before_children` callback and gives the marker an exclusive +Paint Order ID. The callback receives the following ID, so its backdrop consumer is ordered after the capture. The +same element may own and consume the capture because lookup starts at self. A regular rounded rectangle drawn after +`backdrop_blur` at a greater Paint Order ID supplies the translucent tint; blur capture does not select widget colors. The `EditorGUI` package wires this mechanism into popups, tooltips and floating dock panels. It exposes `gui.popup.backdrop_softness`, `gui.tooltip.backdrop_softness` and @@ -356,6 +384,11 @@ ordered plan, uploads resources, records every target transition, and begins and passes. Call it while the graphics command buffer is recording and outside every pass. It returns outside every pass and does not submit the command buffer. +Compilation first replays lexical commands to resolve clips and backdrop relationships, then stable-sorts drawable +events by Paint Order ID. Within one equal-order range it groups commands by complete backend batch key in +first-seen-key order while preserving submission order inside each key. Adjacent compatible batches may be +coalesced when doing so preserves Paint Order. + ```cpp Ref renderer; luset(renderer, GUI::new_renderer(device)); @@ -381,12 +414,12 @@ accumulated target through compute passes, then resume color drawing in a load/s single-sample `tex2d` color target with `color_attachment | read_texture` usage. A non-sampleable swapchain target remains valid when no live capture is present. -`generate_draw_commands` remains useful when tooling needs to inspect the final ordered stream. It is idempotent -until a tree, layout, layer, draw configuration, backdrop capture attachment, or stored program mutation invalidates -the stream. +`generate_draw_commands` remains useful when tooling needs to inspect the lexical generated stream and assigned Paint +Order IDs. It is idempotent until a tree, layout, layer, draw configuration, backdrop capture attachment, or stored +program mutation invalidates the stream. ## Examples -### Button chrome from delayed primitive commands +### Button chrome from a draw callback ```cpp GUI::DrawConfig button_draw; button_draw.name = Name("editor.button"); diff --git a/LunaSDK-Docs/Manual/GUI Elements and Layers.md b/LunaSDK-Docs/Manual/GUI Elements and Layers.md index 50e3a371..2f90f4f7 100644 --- a/LunaSDK-Docs/Manual/GUI Elements and Layers.md +++ b/LunaSDK-Docs/Manual/GUI Elements and Layers.md @@ -35,7 +35,9 @@ context->pop_data_scope(); It does not store a widget kind. A text label, hit-test box, layout container or image region is still the same `Element` type. -Draw commands store their owning dense element index. Delayed callbacks and `draw_for_element` can interleave commands owned by different elements. Call `IContext::generate_draw_commands()` first, then filter `IContext::get_draw_commands()` by `DrawCommand::element` when the actual command set is needed. +Draw commands store their owning dense element index. Context assigns that ownership while it invokes the element's +draw callback; commands cannot be submitted during element construction. Call `IContext::generate_draw_commands()` +first, then filter `IContext::get_draw_commands()` by `DrawCommand::element` when the actual command set is needed. ### Element handle `GUI::ElementHandle` is returned by `begin_element`. It stores stable ID, dense index and generation. Handles are frame-local. Do not keep a handle for use after the next `begin_frame`; keep the stable ID instead. The stable ID is intentionally kept in the handle so APIs can validate that the dense index still points at the same element and so callers can use the returned handle as a stable interaction/state key without another lookup. @@ -50,11 +52,14 @@ A data scope participates in ID generation but does not affect topology. It is u 2. Screen-space layer origin. 3. Root element index. 4. Ordered draw command indexes. -5. Human-readable debug name. +5. The first and maximum Paint Order IDs allocated to the layer for the generated frame. +6. Human-readable debug name. The first element created after `push_layer` becomes the root of that layer. Every element must belong to one layer. -Layer-local positions use the layer origin as `(0, 0)`. Screen positions are converted by adding the layer origin. `Layer::draw_command_indices` is the authoritative command order for a layer. +Layer-local positions use the layer origin as `(0, 0)`. Screen positions are converted by adding the layer origin. +`Layer::draw_command_indices` stores the layer's lexical command indexes; drawable execution order is determined by +the commands' Paint Order IDs. One context and frame describe one logical surface. Layers do not carry independent projection or surface transforms. Use separate contexts and render preparations for surfaces that must be transformed independently. @@ -78,7 +83,27 @@ context->end_element(); context->pop_layer(); ``` -Layers are appended in bottom-to-top order. Later layers render above earlier layers and receive input first. +Layers are appended in bottom-to-top order. Each non-empty layer receives a Paint Order range after the preceding +non-empty layer, so later layers render above earlier layers and receive input first. Empty layers consume no Paint +Order ID. Paint Order IDs are regenerated with draw commands and are not stable across frames. + +### Select child Paint Order allocation +Direct child subtrees use `ChildPaintOrderMode::sequential` by default. Context gives each child a non-overlapping +Paint Order range in submission order, which is safe when children may overlap or otherwise depend on painter order. + +An audited container may select `ChildPaintOrderMode::shared` when the complete painted extents of its relevant +direct children are independent: + +```cpp +context->set_child_paint_order_mode(container, + GUI::ChildPaintOrderMode::shared); +``` + +Children in one shared run receive the same initial Paint Order ID. This lets compatible commands from several +children batch together. The independence guarantee must include shadows, negative margins, visual overflow, +explicit clips and custom drawing; using a flex or grid layout alone is not sufficient proof. A backdrop capture or +another Paint Order barrier ends the shared run, and following children start at a greater ID. Use typeless grouping +elements when one container needs several independently ordered groups. ### Build element topology `begin_element` creates a child of the current element, or the layer root if the layer has no root yet. `end_element` returns to the parent. diff --git a/LunaSDK-Docs/Manual/GUI Overview.md b/LunaSDK-Docs/Manual/GUI Overview.md index c01d0364..26c884a9 100644 --- a/LunaSDK-Docs/Manual/GUI Overview.md +++ b/LunaSDK-Docs/Manual/GUI Overview.md @@ -8,7 +8,7 @@ GUI solves the part of GUI work that is common to multiple GUI packages: 5. Generate GUI-level draw commands after layout and input, then render them through the integrated SDF and VG backends. 6. Expose performance counters and direct read-only frame inspection. -GUI does not provide high-level widgets. Controls such as buttons, checkboxes, menus, sliders, color editors, dock panels and inspectors belong to a higher-level package such as `Luna::EditorGUI`. Those packages build GUI elements, attach input data, run layout helpers and install package-owned draw callbacks or static draw commands. +GUI does not provide high-level widgets. Controls such as buttons, checkboxes, menus, sliders, color editors, dock panels and inspectors belong to a higher-level package such as `Luna::EditorGUI`. Those packages build GUI elements, attach input data, run layout helpers and install package-owned draw callbacks. This split keeps GUI small and orthogonal. Applications can use only the systems they need, and different immediate API packages can coexist because they submit into the same `GUI::IContext`. @@ -40,7 +40,7 @@ The element tree is typeless. Every node is a `GUI::Element` record with the sam Elements do not inherit from widget classes and do not have virtual behavior. Algorithms operate on this data. ### Layer -A layer owns one root element tree and an ordered set of generated draw command indexes. Layers are stored from bottom to top. Rendering uses painter's algorithm, while input routing checks top layers first. +A layer owns one root element tree and an ordered set of generated draw command indexes. Layers are stored from bottom to top. Each non-empty layer receives a frame-local, non-overlapping Paint Order range in that order, while input routing checks top layers first. Normal content, popups, tooltips, modal panels, drag previews and debug overlays should be represented as layers instead of special widget branches. @@ -87,7 +87,7 @@ The usual frame order is: 6. Route input. 7. Resolve higher-level package interactions and update bound application values. 8. Reapply layout when the package reports that interaction changed layout-dependent state. -9. Generate draw commands explicitly only when tooling needs to inspect the final stream. +9. Generate draw commands explicitly only when tooling needs to inspect the generated stream and Paint Order IDs. 10. Ask the GUI renderer to compile and record the complete render plan. ```cpp @@ -124,17 +124,20 @@ luexp(renderer->render(context, command_buffer, target)); ``` `IRenderer::render` calls `generate_draw_commands` when the command stream is stale. Call -`IContext::generate_draw_commands` directly only when inspection or other tooling needs the final ordered commands -before rendering. Widget-bound values and package frame data must remain valid until interaction resolution and draw -command generation have both finished. +`IContext::generate_draw_commands` directly only when inspection or other tooling needs the generated commands and +their Paint Order IDs before rendering. `get_draw_commands` returns lexical submission order; the renderer applies +Paint Order during compilation. Widget-bound values and package frame data must remain valid until interaction +resolution and draw command generation have both finished. High-level packages attach dense `LayoutConfig` input and optional sparse `LayoutCallbackConfig` records while they build elements. `IContext::apply_layout` owns the top-down tree traversal and invokes those callbacks. GUI exposes the primitive helpers used by those callbacks in [[GUI Layout]]. ### Render -GUI records static commands and delayed element draw callbacks. `IContext::generate_draw_commands` evaluates -callbacks against final layout and interaction data. `IRenderer::render` then compiles the ordered stream, interleaves -the SDF and VG backends, performs required resource transitions, and begins and ends every required graphics or -compute pass. +GUI generates commands by evaluating element draw callbacks against final layout and interaction data. Every callback +submits commands with explicit Paint Order IDs and returns the maximum ID it used or reserved. +`IContext::generate_draw_commands` assigns non-overlapping ranges to GUI Layers and safe sequential ranges to child +subtrees by default. `IRenderer::render` then resolves lexical state, sorts drawable commands by Paint Order, groups +equal-order commands by compatible backend state, interleaves the SDF and VG backends, performs required resource +transitions, and begins and ends every required graphics or compute pass. The application owns the command buffer, final color and optional depth-stencil textures, submission, synchronization and presentation. Call `IRenderer::render` while the command buffer is recording and outside every @@ -143,6 +146,22 @@ pass. See [[GUI Drawing]] for details. ## Examples ### A minimal raw element ```cpp +R draw_hello(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void*) +{ + GUI::DrawCommand text; + text.type = GUI::DrawCommandType::text; + text.rect_reference = GUI::DrawCommandRectReference::element; + text.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); + text.text = "Hello GUI"; + text.font = Name("default"); + text.font_size = 16.0f; + text.color = Float4U(1.0f); + context->draw(text, paint_order_id); + return paint_order_id; +} + context->push_layer(1, Float2U(0.0f)); GUI::ElementHandle root = context->begin_element(1); @@ -156,15 +175,10 @@ layout.height.kind = GUI::SizeKind::fixed; layout.height.value = 36.0f; context->set_layout_config(item, layout); -GUI::DrawCommand text; -text.type = GUI::DrawCommandType::text; -text.rect_reference = GUI::DrawCommandRectReference::element; -text.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); -text.text = "Hello GUI"; -text.font = Name("default"); -text.font_size = 16.0f; -text.color = Float4U(1.0f); -context->draw(text); +GUI::DrawConfig draw_config; +draw_config.name = Name("example.hello"); +draw_config.callback = draw_hello; +context->set_draw_config(item, draw_config); context->end_element(); context->end_element(); diff --git a/LunaSDK-Docs/Manual/GUI Performance and Inspection.md b/LunaSDK-Docs/Manual/GUI Performance and Inspection.md index 86ef00cb..5821b62e 100644 --- a/LunaSDK-Docs/Manual/GUI Performance and Inspection.md +++ b/LunaSDK-Docs/Manual/GUI Performance and Inspection.md @@ -119,7 +119,9 @@ for(const GUI::DrawCommand& command : context->get_draw_commands()) } ``` -Call `generate_draw_commands` first when delayed draw callbacks are used and the final command stream is required. +Call `generate_draw_commands` first when the generated command stream is required. Draw callbacks run during +generation, and every generated command then has its owning element and explicit Paint Order ID. The returned span is +in lexical submission order; inspect `DrawCommand::paint_order_id` for render ordering. ### Attach debug names ```cpp diff --git a/Modules/Luna/Asset/Asset.Target.cs b/Modules/Luna/Asset/Asset.Target.cs index 8038b02b..14902fe2 100644 --- a/Modules/Luna/Asset/Asset.Target.cs +++ b/Modules/Luna/Asset/Asset.Target.cs @@ -11,6 +11,8 @@ public AssetTargetRules() Headers("*.hpp", "Source/**.hpp"); MetaHeaders( "Asset.hpp", + "Database.hpp", + "Source/DatabaseImpl.hpp", "Source/AssetMetaFile.hpp"); Sources("Source/**.cpp"); DependsOn("Runtime", "VariantUtils", "VFS"); diff --git a/Modules/Luna/Asset/Asset.hpp b/Modules/Luna/Asset/Asset.hpp index 36922219..88055961 100644 --- a/Modules/Luna/Asset/Asset.hpp +++ b/Modules/Luna/Asset/Asset.hpp @@ -200,49 +200,44 @@ namespace Luna LUNA_ASSET_API RV register_asset(asset_t asset, const Name& type); //! Creates a new asset by specifying the path and type of the asset. - //! @details This function performs the following options to create a new asset: - //! 1. Checks whether one asset with the specified path exists, and returns the existing asset if any. - //! 2. Call @ref get_asset with GUID (0, 0) to generate a new asset entry. - //! 3. Call @ref register_asset to register the asset with the specified type. - //! 4. Sets the path of the asset. - //! 5. If `save_meta_to_file` is `true`, saves asset metadata. - //! 6. Returns the created asset. + //! @details Returns the existing registered asset when its path is already present. + //! Otherwise generates a new GUID, saves metadata if requested, then publishes the registration. + //! A metadata save failure does not publish a partial registration. //! @param[in] path The path to place the new created asset. - //! If the asset with the specified path is already registered, this call does nothing and returnes + //! If the asset with the specified path is already registered, this call does nothing and returns //! the registered asset directly. //! //! This parameter can be empty (`Path()`), in such case, the asset is created as one dynamic asset that //! cannot be saved to files. If this is empty, path duplication check will be skipped. //! @param[in] type The type of the asset. - //! @param[in] save_meta_to_file Whether to create one metadata file for the new asset and saves - //! metadta to the file. + //! @param[in] save_meta Whether to write metadata to the selected database (or a legacy sidecar). + //! Single-file databases stage this record until their explicit flush. //! @return Returns the asset handle of the new created asset. //! @par Valid Usage - //! * If `save_meta_to_file` is `true`, `path` must not be empty. - LUNA_ASSET_API R new_asset(const Path& path, const Name& type, bool save_meta_to_file = true); - - //! Loads or reloads assets' metadata by reading asset metadata files. - //! @param[in] path The path of the asset or direcotry. - //! If `path` specifies one asset, the system loads the asset metadata by opening its meta file and reading from it. - //! If `path` specifies one directory, the system loads assets metadata for all assets in the directory recursively, every - //! asset metadata is loaded as if `load_assets_meta` is called for that particular asset. - //! @param[in] allow_overwrite Specify the behavior when the specified asset already exists in the system. - //! If `allow_overwrite` is `true`, the system will overwrite the asset metadata in the system using new asset metadata loaded - //! from asset meta file; if `allow_overwrite` is `false`, the system discards the new asset metadata and does not change the - //! asset metadata in the system if the specified asset already exists in the system. - //! If `path` specifies one directory, this parameter is applied to all assets in that directory. - //! @retval E_ASSET_DATA_UNIT_BUSY Existing metadata cannot be reconciled because an affected data unit is - //! loaded or busy, or because an asset path is changing while an operation is active. + //! * If `save_meta` is `true`, `path` must not be empty. + LUNA_ASSET_API R new_asset(const Path& path, const Name& type, bool save_meta = true); + + //! Discovers asset metadata at a path or beneath a directory. + //! @details Registered database roots use their logical record snapshots. Other paths use sidecars. + //! The complete batch is validated before registration; failure leaves the registry unchanged. + //! Centralized discovery does not scan directories or open payload files. Use reload_asset_database + //! to refresh a centralized provider from external storage. + //! @param[in] path The asset or directory VFS path. + //! @param[in] allow_overwrite Whether existing compatible registrations may be updated. + //! Ownership conflicts and duplicate batch GUIDs/paths are errors even when overwriting is disabled. + //! @retval E_ASSET_DATA_UNIT_BUSY An affected data unit prevents metadata reconciliation. LUNA_ASSET_API RV load_assets_meta(const Path& path, bool allow_overwrite = true); - //! Loads asset metadata from asset's metadata file. + //! Loads asset metadata from its database logical view, or from its legacy sidecar. //! @param[in] asset The asset handle of the asset to operate. //! @par Valid Usage //! * `asset` must have a valid VFS path. //! @retval E_ASSET_DATA_UNIT_BUSY The asset or one of its data units is busy. LUNA_ASSET_API RV load_asset_meta(asset_t asset); - //! Saves asset's metadata to asset's metadata file. + //! Saves runtime metadata to its database, or to its legacy sidecar. + //! @details Single-file databases stage record updates until IAssetDatabase::flush. This function + //! does not save any data unit or flush a VFS filesystem. An old metadata location is removed after a successful relocation. //! @param[in] asset The asset handle of the asset to operate. //! @par Valid Usage //! * `asset` must have a valid VFS path. @@ -265,13 +260,9 @@ namespace Luna LUNA_ASSET_API Path get_asset_path(asset_t asset); //! Sets the asset VFS path. - //! @details This function only changes the asset's metadata in the system, it will not save the modified metadata - //! to metadata file, it will also not move any asset file on VFS. The user should move asset files manually before - //! calling this function, and call @ref save_asset_meta manually after this function. - //! - //! The user can get files that should be moved by @ref get_asset_files. The user can also call @ref move_asset - //! to move asset files, change asset path and save asset metadata to file in one call, which is preferred if the user - //! does not need to perform custom copy tasks. + //! @details Changes the runtime path only. Move payloads manually, then call save_asset_meta to relocate + //! the persistent record from its last saved path, including between database providers. + //! Use get_asset_files to enumerate payloads, or move_asset to perform the complete operation. //! @param[in] asset The asset handle of the asset to operate. //! @param[in] path The new asset VFS path to set. LUNA_ASSET_API RV set_asset_path(asset_t asset, const Path& path); @@ -287,9 +278,7 @@ namespace Luna LUNA_ASSET_API Name get_asset_type(asset_t asset); //! Sets the asset type. - //! @details This function will only changes asset's metadata in system, it will not save the modified metadata - //! to metadata file. The user should call @ref save_asset_meta after this to save the modified metadata - //! back to file if needed. + //! @details Changes runtime metadata only. Call save_asset_meta to persist the modified type through its provider. //! Named data units and their loaded objects are not changed. //! @param[in] asset The asset handle of the asset to operate. //! @param[in] type The new asset type to set. @@ -304,36 +293,35 @@ namespace Luna //! filename followed by an extension. Files outside this convention are not returned. //! @param[in] asset The asset handle of the asset to query. //! @param[out] filenames Returns filenames of all files associated to the specified asset. - //! Existing elements in the array will be preserved. + //! Existing elements in the array will be preserved. Metadata files (including sidecars and shared databases) + //! are excluded; database implementations own their maintenance. LUNA_ASSET_API RV get_asset_files(asset_t asset, Vector& filenames); //! Deletes one asset and all of its associated files. //! @details This function performs the following tasks: //! 1. Delete all files fetched from @ref get_asset_files. - //! 2. Clear the asset path, type, data-unit descriptors, and all loaded data objects. + //! 2. Remove the database record, then clear the path, type, descriptors and loaded objects. + //! Read-only databases reject this operation before deleting payloads. Physical multi-file deletion is not transactional. //! The asset handle will still be valid after this operation, but the main data unit state will //! be set to @ref AssetDataUnitState::unregistered, and all operations on the asset are invalid. //! @param[in] asset The asset handle of the asset to operate. LUNA_ASSET_API RV delete_asset(asset_t asset); //! Moves all asset associated files to a new destination. - //! @details This function performs the following tasks: - //! 1. Moves all files fetched from @ref get_asset_files from old path to new path. - //! 2. Changes path in asset metadata. - //! 3. Saves modified asset metadata to asset metadata file. + //! @details Moves payloads from get_asset_files, transfers the persistent record, then updates the runtime path. + //! The GUID is preserved. Read-only source or destination databases reject mutation before moving payloads. + //! Completed file moves are rolled back where possible on error; rollback failures are reported. //! @param[in] asset The asset handle of the asset to operate. //! @param[in] new_path The new path of the asset. LUNA_ASSET_API RV move_asset(asset_t asset, const Path& new_path); //! Makes a duplication of the specified asset. - //! @details This function performs the following tasks: - //! 1. Copies all data files fetched from @ref get_asset_files from old path to new path. - //! 2. Creates a new asset or gets the asset with the provided GUID by calling @ref get_asset. - //! 3. Sets the new asset metadata, and saves the metadata to asset metadata file. + //! @details Copies payloads from get_asset_files, writes metadata through the destination provider, + //! then publishes a new registration. Completed copies are removed where possible after a later failure. //! @param[in] asset The asset handle of the asset to operate. //! @param[in] new_path The path of the new asset. //! @param[in] guid The GUID of the new asset. This will be provided to @ref get_asset as-is. - //! Is this is 0, the asset GUID of the new asset is generated automatically. + //! If this is zero, a new GUID is generated. A nonzero GUID must refer to an unregistered placeholder. //! @return Returns the new created asset. Every data unit of a newly created copy is unloaded. LUNA_ASSET_API R copy_asset(asset_t asset, const Path& new_path, const Guid& guid = Guid(0, 0)); @@ -485,6 +473,9 @@ namespace Luna //! Closes the asset registry. //! @details This call removes all registered assets, asset types, and asset loaders, and invalidates all asset handles. + //! Registered metadata databases are flushed before removal; failures are logged. + //! Call flush_asset_databases before close to handle save failures explicitly. + //! This does not implicitly save runtime metadata descriptors or data-unit objects. //! @par Valid Usage //! * Before calling this function, the caller must stop new Asset API submissions and wait until all Asset API //! calls and asset loader callbacks have returned. @@ -507,7 +498,7 @@ namespace Luna //! The Asset error category identifier. inline constexpr errcat_t ERROR_CATEGORY = make_error_category(ErrorDomain::LUNA_SDK, LunaErrorCategory::ASSET); - //! The asset metadata file was not found. + //! The asset metadata record was not found in its database or sidecar file. inline constexpr ResultCode E_META_FILE_NOT_FOUND = make_error_code(ErrorDomain::LUNA_SDK, LunaErrorCategory::ASSET, -1); //! The asset type is not registered. inline constexpr ResultCode E_UNKNOWN_ASSET_TYPE = make_error_code(ErrorDomain::LUNA_SDK, LunaErrorCategory::ASSET, -2); diff --git a/Modules/Luna/Asset/Database.hpp b/Modules/Luna/Asset/Database.hpp new file mode 100644 index 00000000..aa273f4b --- /dev/null +++ b/Modules/Luna/Asset/Database.hpp @@ -0,0 +1,122 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Database.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include "Asset.hpp" +#include +#include "Database.generated.hpp" + +namespace Luna::Asset +{ + //! @addtogroup Asset + //! @{ + + //! One persistent asset record, independent of runtime handles and loaded objects. + struct AssetMetadata + { + //! The nonzero persistent asset GUID. + Guid guid; + //! The nonempty asset path relative to its database root. + Path path; + //! The asset type name. Its loader need not be registered when reading metadata. + Name type; + //! Named data units. The main data unit is implicit in the asset type. + Vector data_units; + }; + + //! Selects metadata database access. This does not change the underlying VFS access mode. + enum class DatabaseMode : u8 + { + //! Permits reading metadata only. + read, + //! Permits reading and modifying metadata. + read_write + }; + + //! Stores metadata for one VFS root and its complete descendant tree. + //! @details All methods are pure virtual. Implementations synchronize their operations. + //! Paths in records and method arguments are relative to get_root(). No method reads asset payloads. + //! A registered database is retained by Asset; mutate/reload it only through coordinating Asset APIs. + //! Provider methods must not reenter Asset. Callers must keep its VFS bindings and backing files stable. + struct [[Luna::interface("{7EC400D1-196D-4353-A0D0-F231BA52D9A7}")]] IAssetDatabase : virtual Interface + { + //! Returns the immutable VFS root of the database. + virtual Path get_root() = 0; + //! Checks whether metadata writes are prohibited. + virtual bool is_read_only() = 0; + //! Checks whether accepted record edits still need flush. + virtual bool is_dirty() = 0; + //! Checks whether a relative file path is owned by metadata storage and must not be treated as payload. + virtual bool is_metadata_file(const Path& path) = 0; + //! Reads the logical record at a relative asset path, including accepted unflushed edits. + //! Returns E_META_FILE_NOT_FOUND when no record exists. + virtual R read_record(const Path& path) = 0; + //! Returns the complete logical record snapshot, including unflushed edits. + virtual R> get_records() = 0; + //! Reads a fresh snapshot from VFS without modifying the provider's logical records. + //! This enables validation before registry publication; failure leaves both views unchanged. + virtual R> read_snapshot() = 0; + //! Accepts a validated storage snapshot as the clean logical view, without writing VFS. + //! Returns E_BUSY when dirty. Validation failure leaves the old view unchanged. + //! Registered providers are updated through reload_asset_database. + virtual RV accept_snapshot(Span records) = 0; + //! Inserts or updates a record. An existing destination must belong to the same GUID. + //! @param[in] record The record to write. + //! @param[in] previous_path Optional old relative path when relocating this record within the database. + //! @details Sidecars save immediately; single-file databases stage changes until flush. + //! Read-only providers return E_NOT_SUPPORTED before changing storage. + virtual RV write_record(const AssetMetadata& record, const Path& previous_path = Path()) = 0; + //! Removes a record without deleting asset payloads. Missing records return E_META_FILE_NOT_FOUND. + virtual RV remove_record(const Path& path) = 0; + //! Writes pending metadata through VFS. Does not save data units or flush a VFS filesystem. + //! Failure retains dirty state for retry. No physical-media durability is promised. + virtual RV flush() = 0; + }; + + //! Creates a sidecar provider for a VFS root. Does not scan or register assets. + //! Reads legacy metadata versions 1 and 2 and writes version 2. + LUNA_ASSET_API R> new_sidecar_database(const Path& root, + DatabaseMode mode = DatabaseMode::read_write); + //! Opens one centralized JSON database. Reads this file only and does not register assets. + //! @param[in] root The VFS root covered by all records. + //! @param[in] filename The nonempty database file path relative to root. Its filename must not use the + //! `.meta` extension or the reserved `.luna-asset-` prefix. + //! @param[in] mode The metadata access policy. + LUNA_ASSET_API R> open_file_database(const Path& root, + const Path& filename = Path("assets.db"), DatabaseMode mode = DatabaseMode::read); + //! Creates an empty writable centralized database. An existing destination is never overwritten. + //! The parent directory must exist. Does not register assets. + //! Root and filename follow the same rules as open_file_database. + LUNA_ASSET_API R> create_file_database(const Path& root, + const Path& filename = Path("assets.db")); + //! Exports a complete logical metadata snapshot to a new centralized database under another root. + //! Preserves GUIDs and relative paths. Does not register assets, copy payloads or remove source sidecars. + //! The destination must not exist and its parent must exist. + //! Destination root and filename follow the same rules as open_file_database. + LUNA_ASSET_API RV export_asset_database(IAssetDatabase* source, const Path& destination_root, + const Path& filename = Path("assets.db")); + + //! Registers a retained provider and all its records after validating the complete snapshot. + //! Rejects overlapping roots, existing registered assets owned elsewhere and duplicate GUIDs/paths. + //! Unregistered GUID placeholders may be populated. Failure does not partially register records. + LUNA_ASSET_API RV register_asset_database(IAssetDatabase* database); + //! Reloads a registered provider from storage. Rejects dirty providers and incompatible loaded units. + //! Removed records are unregistered only when their data units are unloaded and idle. + //! Failure preserves the registry and any cached provider snapshot. + LUNA_ASSET_API RV reload_asset_database(IAssetDatabase* database); + //! Flushes and unregisters a provider. All owned assets must be unloaded and idle. + //! Handles remain valid unregistered placeholders; failure retains the registration. + LUNA_ASSET_API RV unregister_asset_database(IAssetDatabase* database); + //! Flushes all registered metadata databases, attempts each, and returns the first failure. + //! Does not implicitly call save_asset_meta or save any data unit. + LUNA_ASSET_API RV flush_asset_databases(); + //! Returns the explicitly registered database covering a path, or E_NOT_FOUND for legacy sidecar paths. + LUNA_ASSET_API R> get_asset_database(const Path& path); + //! @} +} diff --git a/Modules/Luna/Asset/Source/Asset.cpp b/Modules/Luna/Asset/Source/Asset.cpp index f0dc72bf..d567fb4d 100644 --- a/Modules/Luna/Asset/Source/Asset.cpp +++ b/Modules/Luna/Asset/Source/Asset.cpp @@ -9,12 +9,14 @@ */ #include #define LUNA_ASSET_API LUNA_EXPORT -#include "AssetMetaFile.hpp" +#include "DatabaseImpl.hpp" #include "AssetLoader.hpp" #include "AssetType.hpp" #include "Asset.meta.generated.hpp" #include #include +#include +#include #include #include #include @@ -30,7 +32,7 @@ namespace Luna static RV register_asset_error_codes() { if(!register_error_category(Asset::ERROR_CATEGORY, "Asset") || - !register_error_code(Asset::E_META_FILE_NOT_FOUND, "meta_file_not_found", "The asset metadata file was not found.") || + !register_error_code(Asset::E_META_FILE_NOT_FOUND, "meta_file_not_found", "The asset metadata record was not found.") || !register_error_code(Asset::E_UNKNOWN_ASSET_TYPE, "unknown_asset_type", "The asset type is not registered.") || !register_error_code(Asset::E_ASSET_NOT_REGISTERED, "asset_not_registered", "The asset is not registered.") || !register_error_code(Asset::E_ASSET_ALREADY_REGISTERED, "asset_already_registered", "The asset is already registered.") || @@ -62,6 +64,8 @@ namespace Luna }; Ref g_assets_mutex; + Ref g_metadata_mutex; + Vector> g_databases; SelfIndexedHashMap, AssetEntryExtractKey> g_assets; HashMap g_asset_path_mapping; bool g_asset_registry_closing = false; @@ -136,38 +140,6 @@ namespace Luna return nullptr; } - static RV validate_asset_meta_file(const AssetMetaFile& file) - { - if(file.format_version < 1 || file.format_version > ASSET_META_FORMAT_VERSION) - { - return set_error(E_BAD_DATA, "Unsupported asset metadata format version %u.", file.format_version); - } - if(file.guid == Guid(0, 0)) - { - return set_error(E_BAD_DATA, "Asset metadata contains an empty GUID."); - } - if(file.type.empty()) - { - return set_error(E_BAD_DATA, "Asset metadata contains an empty asset type."); - } - for(usize i = 0; i < file.data_units.size(); ++i) - { - const auto& data_unit = file.data_units[i]; - if(data_unit.id.empty() || data_unit.loader.empty()) - { - return set_error(E_BAD_DATA, "Named asset data unit IDs and loader names must not be empty."); - } - for(usize j = 0; j < i; ++j) - { - if(file.data_units[j].id == data_unit.id) - { - return set_error(E_BAD_DATA, "Asset metadata contains duplicate data unit ID %s.", data_unit.id.c_str()); - } - } - } - return ok; - } - static AssetMetaFile get_asset_meta_file(AssetEntry* entry) { AssetMetaFile file; @@ -189,7 +161,7 @@ namespace Luna return file; } - static RV reconcile_asset_meta_file(AssetEntry* entry, const AssetMetaFile& file, + static RV validate_asset_reconciliation(AssetEntry* entry, const AssetMetaFile& file, bool path_changes, bool allow_maintenance = false) { if(entry->maintenance && !allow_maintenance) return Asset::E_ASSET_DATA_UNIT_BUSY; @@ -209,6 +181,14 @@ namespace Luna } } + return ok; + } + + static RV reconcile_asset_meta_file(AssetEntry* entry, const AssetMetaFile& file, + bool path_changes, bool allow_maintenance = false) + { + auto valid = validate_asset_reconciliation(entry, file, path_changes, allow_maintenance); + if(failed(valid)) return valid; if(entry->type != file.type) { entry->type = file.type; @@ -249,6 +229,7 @@ namespace Luna set_serializable(); set_serializable(); g_assets_mutex = new_mutex(); + g_metadata_mutex = new_mutex(); g_asset_registry_closing = false; } @@ -258,46 +239,107 @@ namespace Luna g_assets.shrink_to_fit(); g_asset_path_mapping.clear(); g_asset_path_mapping.shrink_to_fit(); + g_databases.clear(); + g_databases.shrink_to_fit(); } - static R internal_load_asset_meta(const Path& meta_path) + static IAssetDatabase* find_database(const Path& path) { - AssetMetaFile file; - lutry - { - lulet(stream, VFS::open_file(meta_path, - FileOpenFlag::read | FileOpenFlag::user_buffering, - FileCreationMode::open_existing)); - lulet(data, VariantUtils::read_json(stream)); - luexp(deserialize(file, data)); - luexp(validate_asset_meta_file(file)); - } - lucatch - { - if(unwrap_errcode(luerr) == E_NOT_FOUND) - { - return set_error(Asset::E_META_FILE_NOT_FOUND, - "Asset meta file %s is not found.", meta_path.encode().c_str()); - } - return luerr; - } - return file; + for(auto& database : g_databases) + if(database_contains(database->get_root(), path)) return database.get(); + return nullptr; + } + + static R> resolve_database(const Path& path) + { + if(auto database = find_database(path)) return Ref(database); + Path root; + root.root() = path.root(); + root.flags() = path.flags(); + return new_sidecar_database(root); + } + + LUNA_ASSET_API R> get_asset_database(const Path& path) + { + MutexGuard guard(g_metadata_mutex); + auto valid = validate_database_path(path, false, true); + if(failed(valid)) return valid.errcode(); + if(auto database = find_database(path)) return Ref(database); + return E_NOT_FOUND; + } + + static R read_metadata_at(const Path& path) + { + auto provider = resolve_database(path); + if(failed(provider)) return provider.errcode(); + Path relative = database_relative_path(provider.get()->get_root(), path); + auto record = provider.get()->read_record(relative); + if(failed(record)) return record.errcode(); + if(record.get().path != relative) return E_BAD_DATA; + auto valid = validate_metadata(record.get()); + if(failed(valid)) return valid.errcode(); + return metadata_to_file(record.get()); } - static RV internal_save_asset_meta(const AssetMetaFile& file, const Path& meta_path) + static RV check_metadata_destination(const Path& path, const Guid& guid) + { + auto provider = resolve_database(path); + if(failed(provider)) return provider.errcode(); + if(provider.get()->is_read_only()) return E_NOT_SUPPORTED; + auto relative = database_relative_path(provider.get()->get_root(), path); + if(provider.get()->is_metadata_file(relative)) return E_BAD_ARGUMENTS; + auto record = provider.get()->read_record(relative); + if(succeeded(record)) return record.get().guid == guid ? RV(ok) : RV(E_ALREADY_EXISTS); + return unwrap_errcode(record.errcode()) == E_META_FILE_NOT_FOUND ? RV(ok) : RV(record.errcode()); + } + + static RV write_metadata_at(const AssetMetaFile& file, const Path& path, + const Path& previous_path = Path(), IAssetDatabase* previous_database = nullptr) { lutry { - lulet(data, serialize(file)); - lulet(stream, VFS::open_file(meta_path, - FileOpenFlag::write | FileOpenFlag::user_buffering, - FileCreationMode::create_always)); - luexp(VariantUtils::write_json(stream, data)); + lulet(target, resolve_database(path)); + if(target->is_read_only() || (previous_database && previous_database->is_read_only())) return E_NOT_SUPPORTED; + Path relative = database_relative_path(target->get_root(), path); + auto record = metadata_from_file(file, relative); + bool same = previous_database == find_database(path) && + (previous_path.empty() || database_contains(target->get_root(), previous_path)); + if(same) + { + return target->write_record(record, previous_path.empty() ? Path() : + database_relative_path(target->get_root(), previous_path)); + } + auto old_target = target->read_record(relative); + if(failed(old_target) && unwrap_errcode(old_target.errcode()) != E_META_FILE_NOT_FOUND) return old_target.errcode(); + luexp(target->write_record(record)); + if(!previous_path.empty()) + { + auto source_result = previous_database ? R>(Ref(previous_database)) : resolve_database(previous_path); + RV removed = source_result.valid() ? source_result.get()->remove_record( + database_relative_path(source_result.get()->get_root(), previous_path)) : RV(source_result.errcode()); + if(failed(removed) && unwrap_errcode(removed.errcode()) != E_META_FILE_NOT_FOUND) + { + Error error = removed.errcode() == E_ERROR_OBJECT ? get_error() : Error(removed.errcode(), String(explain(removed.errcode()))); + auto restored = succeeded(old_target) ? target->write_record(old_target.get()) : target->remove_record(relative); + if(failed(restored)) error.message.append("; failed to restore destination metadata"); + get_error() = move(error); + return E_ERROR_OBJECT; + } + } } lucatchret; return ok; } + static RV remove_metadata_at(const Path& path, IAssetDatabase* database) + { + if(path.empty()) return ok; + auto provider = database ? R>(Ref(database)) : resolve_database(path); + if(failed(provider)) return provider.errcode(); + auto result = provider.get()->remove_record(database_relative_path(provider.get()->get_root(), path)); + return failed(result) && unwrap_errcode(result.errcode()) == E_META_FILE_NOT_FOUND ? RV(ok) : result; + } + LUNA_ASSET_API asset_t get_asset(const Guid& guid) { MutexGuard guard(g_assets_mutex); @@ -320,41 +362,50 @@ namespace Luna LUNA_ASSET_API RV register_asset(asset_t asset, const Name& type) { + MutexGuard metadata_guard(g_metadata_mutex); lucheck_msg(asset.handle, "Asset handle must not be null!"); AssetEntry* entry = (AssetEntry*)asset.handle; LockGuard guard(entry->lock); + if(entry->maintenance || g_asset_registry_closing) return E_ASSET_DATA_UNIT_BUSY; if(!entry->type.empty()) return Asset::E_ASSET_ALREADY_REGISTERED; entry->type = type; ++entry->main_data_unit.revision; return ok; } - LUNA_ASSET_API R new_asset(const Path& path, const Name& type, bool save_meta_to_file) + LUNA_ASSET_API R new_asset(const Path& path, const Name& type, bool save_meta) { - if(save_meta_to_file && path.empty()) return Asset::E_EMPTY_ASSET_PATH; - MutexGuard registry_guard(g_assets_mutex); - if(!path.empty()) + MutexGuard metadata_guard(g_metadata_mutex); + if(g_asset_registry_closing) return E_ASSET_NOT_REGISTERED; + if(type.empty()) return E_BAD_ARGUMENTS; + if(save_meta && path.empty()) return E_EMPTY_ASSET_PATH; + auto valid = validate_database_path(path, false, true); + if(failed(valid)) return valid.errcode(); { - auto iter = g_asset_path_mapping.find(path); - if(iter != g_asset_path_mapping.end()) return iter->second; + MutexGuard guard(g_assets_mutex); + auto found = g_asset_path_mapping.find(path); + if(!path.empty() && found != g_asset_path_mapping.end()) return found->second; } + auto database = path.empty() ? nullptr : find_database(path); + if(database && database->is_read_only()) return E_NOT_SUPPORTED; asset_t asset = get_asset(); AssetEntry* entry = (AssetEntry*)asset.handle; + if(save_meta) { - LockGuard entry_guard(entry->lock); - entry->path = path; - entry->type = type; - ++entry->main_data_unit.revision; - if(save_meta_to_file) - { - AssetMetaFile file = get_asset_meta_file(entry); - Path meta_path = path; - meta_path.append_extension("meta"); - entry_guard.unlock(); - auto result = internal_save_asset_meta(file, meta_path); - if(failed(result)) return result.errcode(); - } + AssetMetaFile file; + file.format_version = ASSET_META_FORMAT_VERSION; + file.guid = entry->guid; + file.type = type; + auto result = write_metadata_at(file, path); + if(failed(result)) return result.errcode(); } + MutexGuard guard(g_assets_mutex); + LockGuard entry_guard(entry->lock); + entry->path = path; + entry->metadata_path = save_meta ? path : Path(); + entry->database = database; + entry->type = type; + ++entry->main_data_unit.revision; if(!path.empty()) g_asset_path_mapping.insert(make_pair(path, asset)); return asset; } @@ -363,33 +414,258 @@ namespace Luna { Path path; AssetMetaFile meta_file; + IAssetDatabase* database = nullptr; + }; + + struct MetadataReservations + { + Vector entries; + ~MetadataReservations() { for(auto entry : entries) end_asset_maintenance(entry); } }; - static RV recursive_load_asset_meta(const Path& directory, Vector& assets) + static bool has_loaded_data(AssetEntry* entry) + { + if(entry->main_data_unit.data) return true; + for(const auto& unit : entry->data_units) if(unit.second.data) return true; + return false; + } + + static void clear_asset_registration(AssetEntry* entry) + { + if(!entry->path.empty()) g_asset_path_mapping.erase(entry->path); + entry->type.reset(); + entry->path.clear(); + entry->metadata_path.clear(); + entry->database = nullptr; + entry->data_units.clear(); + entry->main_data_unit.data = nullptr; + ++entry->main_data_unit.revision; + } + + // The metadata mutex serializes registry ownership changes. Entry reservations bridge + // external provider I/O and commit without holding registry or spin locks during I/O. + static RV apply_metadata_batch(const Vector& updates, bool allow_overwrite, + IAssetDatabase* reload = nullptr, Span snapshot = {}) + { + if(g_asset_registry_closing) return E_ASSET_NOT_REGISTERED; + HashSet guids; + HashSet paths; + for(const auto& info : updates) + { + auto valid = validate_metadata(metadata_from_file(info.meta_file, Path("record"))); + if(failed(valid)) return valid; + if(guids.contains(info.meta_file.guid) || paths.contains(info.path)) return E_ALREADY_EXISTS; + guids.insert(info.meta_file.guid); + paths.insert(info.path); + } + MetadataReservations reservations; + Vector removed; + Vector accepted; + { + MutexGuard guard(g_assets_mutex); + for(const auto& info : updates) + { + auto found = g_assets.find(info.meta_file.guid); + AssetEntry* entry = found == g_assets.end() ? nullptr : found->get(); + auto mapped = g_asset_path_mapping.find(info.path); + if(mapped != g_asset_path_mapping.end() && (!entry || mapped->second.handle != entry)) return E_ALREADY_EXISTS; + if(entry) + { + LockGuard entry_guard(entry->lock); + if(!entry->type.empty() && entry->database != info.database) return E_ALREADY_EXISTS; + if(!entry->type.empty() && !allow_overwrite) continue; + auto valid = validate_asset_reconciliation(entry, info.meta_file, entry->path != info.path); + if(failed(valid)) return valid; + entry->maintenance = true; + reservations.entries.push_back(entry); + } + accepted.push_back(&info); + } + if(reload) + { + for(auto& asset : g_assets) + { + auto entry = asset.get(); + LockGuard entry_guard(entry->lock); + if(entry->database != reload || guids.contains(entry->guid)) continue; + if(entry->maintenance || has_asset_data_unit_operation(entry) || has_loaded_data(entry)) return E_ASSET_DATA_UNIT_BUSY; + entry->maintenance = true; + reservations.entries.push_back(entry); + removed.push_back(entry); + } + } + } + if(reload) + { + auto result = reload->accept_snapshot(snapshot); + if(failed(result)) return result; + } + MutexGuard guard(g_assets_mutex); + for(auto entry : removed) + { + LockGuard entry_guard(entry->lock); + clear_asset_registration(entry); + } + for(auto info : accepted) + { + auto asset = get_asset(info->meta_file.guid); + auto entry = (AssetEntry*)asset.handle; + LockGuard entry_guard(entry->lock); + // All error conditions were checked while reserving existing entries. + luassert_always(succeeded(reconcile_asset_meta_file(entry, info->meta_file, entry->path != info->path, true))); + if(!entry->path.empty()) g_asset_path_mapping.erase(entry->path); + entry->path = info->path; + entry->metadata_path = info->path; + entry->database = info->database; + g_asset_path_mapping.insert_or_assign(entry->path, asset); + } + return ok; + } + + static RV append_database_records(IAssetDatabase* database, const Vector& records, + const Path& filter, Vector& updates) + { + auto valid = validate_records(records.cspan()); + if(failed(valid)) return valid; + for(const auto& record : records) + { + if(database->is_metadata_file(record.path)) return E_BAD_DATA; + AssetMetaUpdateInfo info; + info.path = database_absolute_path(database->get_root(), record.path); + if(!database_contains(filter, info.path)) continue; + info.meta_file = metadata_to_file(record); + info.database = database; + updates.push_back(move(info)); + } + return ok; + } + + LUNA_ASSET_API RV register_asset_database(IAssetDatabase* database) + { + MutexGuard metadata_guard(g_metadata_mutex); + if(!database) return E_BAD_ARGUMENTS; + if(g_asset_registry_closing) return E_ASSET_NOT_REGISTERED; + Ref retained(database); + Path root = database->get_root(); + lutry + { + luexp(validate_database_path(root, false, true)); + for(auto& existing : g_databases) + { + auto other = existing->get_root(); + if(database_contains(root, other) || database_contains(other, root)) return E_ALREADY_EXISTS; + } + { + MutexGuard guard(g_assets_mutex); + for(auto& asset : g_assets) + { + LockGuard entry_guard(asset->lock); + if(!asset->type.empty() && !asset->path.empty() && database_contains(root, asset->path)) return E_ALREADY_EXISTS; + } + } + lulet(records, database->get_records()); + Vector updates; + luexp(append_database_records(database, records, root, updates)); + luexp(apply_metadata_batch(updates, true)); + g_databases.push_back(move(retained)); + } + lucatchret; + return ok; + } + + LUNA_ASSET_API RV reload_asset_database(IAssetDatabase* database) + { + MutexGuard metadata_guard(g_metadata_mutex); + bool registered = false; + for(auto& item : g_databases) if(item.get() == database) registered = true; + if(!registered) return E_NOT_FOUND; + if(database->is_dirty()) return E_BUSY; + lutry + { + lulet(records, database->read_snapshot()); + Vector updates; + luexp(append_database_records(database, records, database->get_root(), updates)); + luexp(apply_metadata_batch(updates, true, database, records.cspan())); + } + lucatchret; + return ok; + } + + LUNA_ASSET_API RV unregister_asset_database(IAssetDatabase* database) + { + MutexGuard metadata_guard(g_metadata_mutex); + usize index = 0; + while(index < g_databases.size() && g_databases[index].get() != database) ++index; + if(index == g_databases.size()) return E_NOT_FOUND; + MetadataReservations reservations; + { + MutexGuard guard(g_assets_mutex); + for(auto& asset : g_assets) + { + auto entry = asset.get(); + LockGuard entry_guard(entry->lock); + if(entry->database != database) continue; + if(entry->maintenance || has_asset_data_unit_operation(entry) || has_loaded_data(entry)) return E_ASSET_DATA_UNIT_BUSY; + entry->maintenance = true; + reservations.entries.push_back(entry); + } + } + auto result = database->flush(); + if(failed(result)) return result; + MutexGuard guard(g_assets_mutex); + for(auto entry : reservations.entries) + { + LockGuard entry_guard(entry->lock); + clear_asset_registration(entry); + } + g_databases.erase(g_databases.begin() + index); + return ok; + } + + LUNA_ASSET_API RV flush_asset_databases() + { + MutexGuard metadata_guard(g_metadata_mutex); + Error first; + bool failed_any = false; + for(auto& database : g_databases) + { + auto result = database->flush(); + if(failed(result) && !failed_any) + { + first = result.errcode() == E_ERROR_OBJECT ? get_error() : Error(result.errcode(), String(explain(result.errcode()))); + failed_any = true; + } + } + if(failed_any) { get_error() = move(first); return E_ERROR_OBJECT; } + return ok; + } + + static RV scan_legacy_metadata(const Path& directory, Vector& updates) { + if(find_database(directory)) return ok; lutry { lulet(iter, VFS::open_dir(directory)); - Path path = directory; for(; iter->is_valid(); iter->move_next()) { - const c8* filename = iter->get_filename(); - if(!strcmp(filename, ".") || !strcmp(filename, "..")) continue; - path.push_back(filename); + const c8* name = iter->get_filename(); + if(!strcmp(name, ".") || !strcmp(name, "..")) continue; + Path path(directory); + path.push_back(name); if(test_flags(iter->get_attributes(), FileAttributeFlag::directory)) { - luexp(recursive_load_asset_meta(path, assets)); + luexp(scan_legacy_metadata(path, updates)); } else if(path.extension() == "meta") { - lulet(meta_file, internal_load_asset_meta(path)); - AssetMetaUpdateInfo info; path.remove_extension(); + if(find_database(path)) continue; + lulet(file, read_sidecar(path)); + AssetMetaUpdateInfo info; info.path = path; - info.meta_file = move(meta_file); - assets.push_back(move(info)); + info.meta_file = move(file); + updates.push_back(move(info)); } - path.pop_back(); } } lucatchret; @@ -398,54 +674,40 @@ namespace Luna LUNA_ASSET_API RV load_assets_meta(const Path& path, bool allow_overwrite) { - MutexGuard registry_guard(g_assets_mutex); - Vector update_assets; + MutexGuard metadata_guard(g_metadata_mutex); + Vector updates; lutry { - auto attributes = VFS::get_file_attribute(path); - if(succeeded(attributes) && test_flags(attributes.get().attributes, FileAttributeFlag::directory)) + luexp(validate_database_path(path, false, true)); + if(auto database = find_database(path)) { - luexp(recursive_load_asset_meta(path, update_assets)); + lulet(records, database->get_records()); + luexp(append_database_records(database, records, path, updates)); + if(updates.empty() && path != database->get_root()) return E_META_FILE_NOT_FOUND; } else { - Path meta_path = path; - meta_path.append_extension("meta"); - lulet(meta_file, internal_load_asset_meta(meta_path)); - AssetMetaUpdateInfo info; - info.path = path; - info.meta_file = move(meta_file); - update_assets.push_back(move(info)); - } - - for(auto& info : update_assets) - { - asset_t asset = get_asset(info.meta_file.guid); - AssetEntry* entry = (AssetEntry*)asset.handle; - LockGuard entry_guard(entry->lock); - if(!entry->type.empty() && !allow_overwrite) continue; - auto mapped = g_asset_path_mapping.find(info.path); - if(mapped != g_asset_path_mapping.end() && mapped->second != asset) + auto attr = VFS::get_file_attribute(path); + if(succeeded(attr) && test_flags(attr.get().attributes, FileAttributeFlag::directory)) { - luthrow(set_error(E_ALREADY_EXISTS, - "Another asset is already registered at %s.", info.path.encode().c_str())); - } - const bool path_changes = entry->path != info.path; - luexp(reconcile_asset_meta_file(entry, info.meta_file, path_changes)); - if(path_changes) - { - if(!entry->path.empty()) + luexp(scan_legacy_metadata(path, updates)); + for(auto& database : g_databases) { - auto old_mapping = g_asset_path_mapping.find(entry->path); - if(old_mapping != g_asset_path_mapping.end() && old_mapping->second == asset) - { - g_asset_path_mapping.erase(old_mapping); - } + if(!database_contains(path, database->get_root())) continue; + lulet(records, database->get_records()); + luexp(append_database_records(database, records, path, updates)); } - entry->path = info.path; } - g_asset_path_mapping.insert_or_assign(entry->path, asset); + else + { + lulet(file, read_metadata_at(path)); + AssetMetaUpdateInfo info; + info.path = path; + info.meta_file = move(file); + updates.push_back(move(info)); + } } + luexp(apply_metadata_batch(updates, allow_overwrite)); } lucatchret; return ok; @@ -453,36 +715,23 @@ namespace Luna LUNA_ASSET_API RV load_asset_meta(asset_t asset) { - lucheck_msg(asset.handle, "Asset handle must not be null!"); - AssetEntry* entry = (AssetEntry*)asset.handle; + MutexGuard metadata_guard(g_metadata_mutex); + if(!asset) return E_BAD_ARGUMENTS; + auto entry = (AssetEntry*)asset.handle; lutry { luexp(begin_asset_maintenance(entry)); - AssetMaintenanceGuard maintenance_guard{entry}; - Path path; - { - LockGuard guard(entry->lock); - path = entry->path; - } - if(path.empty()) - { - luthrow(set_error(E_BAD_ARGUMENTS, - "Asset::load_asset_meta: Asset path is not valid.")); - } - Path meta_path = path; - meta_path.append_extension("meta"); - lulet(meta_file, internal_load_asset_meta(meta_path)); + AssetMaintenanceGuard reservation{entry}; + Path path = get_asset_path(asset); + if(path.empty()) return E_EMPTY_ASSET_PATH; + lulet(file, read_metadata_at(path)); + if(file.guid != entry->guid) return E_BAD_DATA; + auto database = find_database(path); + if(entry->database != database && !entry->type.empty()) return E_ALREADY_EXISTS; LockGuard guard(entry->lock); - if(entry->guid != meta_file.guid) - { - luthrow(set_error(E_BAD_DATA, - "The asset GUID loaded from the metadata file does not match the registered asset GUID.")); - } - if(entry->path != path) luthrow(Asset::E_ASSET_DATA_UNIT_BUSY); - luexp(reconcile_asset_meta_file(entry, meta_file, false, true)); - guard.unlock(); - end_asset_maintenance(entry); - maintenance_guard.dismiss(); + luexp(reconcile_asset_meta_file(entry, file, false, true)); + entry->metadata_path = path; + entry->database = database; } lucatchret; return ok; @@ -490,29 +739,29 @@ namespace Luna LUNA_ASSET_API RV save_asset_meta(asset_t asset) { - lucheck_msg(asset.handle, "Asset handle must not be null!"); - AssetEntry* entry = (AssetEntry*)asset.handle; + MutexGuard metadata_guard(g_metadata_mutex); + if(!asset) return E_BAD_ARGUMENTS; + auto entry = (AssetEntry*)asset.handle; lutry { luexp(begin_asset_maintenance(entry)); - AssetMaintenanceGuard maintenance_guard{entry}; - Path path; + AssetMaintenanceGuard reservation{entry}; AssetMetaFile file; + Path path, previous; + IAssetDatabase* old_database; { LockGuard guard(entry->lock); - if(entry->path.empty()) - { - luthrow(set_error(E_BAD_ARGUMENTS, - "Asset::save_asset_meta: Asset path is not valid.")); - } - if(entry->type.empty()) luthrow(Asset::E_ASSET_NOT_REGISTERED); + if(entry->type.empty()) return E_ASSET_NOT_REGISTERED; + if(entry->path.empty()) return E_EMPTY_ASSET_PATH; path = entry->path; + previous = entry->metadata_path; + old_database = entry->database; file = get_asset_meta_file(entry); } - path.append_extension("meta"); - luexp(internal_save_asset_meta(file, path)); - end_asset_maintenance(entry); - maintenance_guard.dismiss(); + luexp(write_metadata_at(file, path, previous, old_database)); + LockGuard guard(entry->lock); + entry->metadata_path = path; + entry->database = find_database(path); } lucatchret; return ok; @@ -543,6 +792,9 @@ namespace Luna LUNA_ASSET_API RV set_asset_path(asset_t asset, const Path& path) { + MutexGuard metadata_guard(g_metadata_mutex); + auto valid = validate_database_path(path, false, true); + if(failed(valid)) return valid; lucheck_msg(asset.handle, "Asset handle must not be null!"); AssetEntry* entry = (AssetEntry*)asset.handle; MutexGuard registry_guard(g_assets_mutex); @@ -579,6 +831,7 @@ namespace Luna LUNA_ASSET_API void set_asset_type(asset_t asset, const Name& type) { + MutexGuard metadata_guard(g_metadata_mutex); lucheck_msg(asset.handle, "Asset handle must not be null!"); if(type.empty()) { @@ -601,30 +854,28 @@ namespace Luna LUNA_ASSET_API RV get_asset_files(asset_t asset, Vector& filenames) { - lucheck_msg(asset.handle, "Asset handle must not be null!"); + MutexGuard metadata_guard(g_metadata_mutex); + if(!asset) return E_BAD_ARGUMENTS; Path path = get_asset_path(asset); - if(path.empty()) return Asset::E_ASSET_NOT_REGISTERED; + if(path.empty()) return E_ASSET_NOT_REGISTERED; Name filename = path.back(); + auto database = find_database(path); path.pop_back(); lutry { lulet(iter, VFS::open_dir(path)); - while(iter->is_valid()) + for(; iter->is_valid(); iter->move_next()) { - if(!test_flags(iter->get_attributes(), FileAttributeFlag::directory)) - { - const c8* name = iter->get_filename(); - const usize name_size = strlen(name); - if(name_size >= filename.size() && !memcmp(name, filename.c_str(), filename.size())) - { - if(name_size == filename.size() || - (name_size > filename.size() + 1 && name[filename.size()] == '.')) - { - filenames.push_back(name); - } - } - } - iter->move_next(); + if(test_flags(iter->get_attributes(), FileAttributeFlag::directory)) continue; + const c8* name = iter->get_filename(); + Path file_path(path); + file_path.push_back(name); + if(file_path.extension() == "meta" || !strncmp(name, ".luna-asset-", 12)) continue; + if(database && database->is_metadata_file(database_relative_path(database->get_root(), file_path))) continue; + const usize size = strlen(name); + if(size >= filename.size() && !memcmp(name, filename.c_str(), filename.size()) && + (size == filename.size() || (size > filename.size() + 1 && name[filename.size()] == '.'))) + filenames.push_back(name); } } lucatchret; @@ -633,45 +884,33 @@ namespace Luna LUNA_ASSET_API RV delete_asset(asset_t asset) { - lucheck_msg(asset.handle, "Asset handle must not be null!"); - AssetEntry* entry = (AssetEntry*)asset.handle; + MutexGuard metadata_guard(g_metadata_mutex); + if(!asset) return E_BAD_ARGUMENTS; + auto entry = (AssetEntry*)asset.handle; + Vector old_data; lutry { luexp(begin_asset_maintenance(entry)); - AssetMaintenanceGuard maintenance_guard{entry}; + AssetMaintenanceGuard reservation{entry}; + if(entry->database && entry->database->is_read_only()) return E_NOT_SUPPORTED; + auto current_database = find_database(entry->path); + if(current_database && current_database->is_read_only()) return E_NOT_SUPPORTED; Vector files; luexp(get_asset_files(asset, files)); - Path asset_path = get_asset_path(asset); - Path directory = asset_path; + Path directory = get_asset_path(asset); directory.pop_back(); - for(auto& filename : files) - { - directory.push_back(filename); - luexp(VFS::delete_file(directory)); - directory.pop_back(); - } - + for(const auto& name : files) { - MutexGuard registry_guard(g_assets_mutex); - g_asset_path_mapping.erase(asset_path); + Path path(directory); + path.push_back(name); + luexp(VFS::delete_file(path)); } - - Vector old_data; - { - LockGuard entry_guard(entry->lock); - if(entry->main_data_unit.data) old_data.push_back(move(entry->main_data_unit.data)); - for(auto& data_unit : entry->data_units) - { - if(data_unit.second.data) old_data.push_back(move(data_unit.second.data)); - } - entry->data_units.clear(); - entry->main_data_unit.operation = AssetDataUnitOperation::none; - ++entry->main_data_unit.revision; - entry->type.reset(); - entry->path.clear(); - entry->maintenance = false; - } - maintenance_guard.dismiss(); + luexp(remove_metadata_at(entry->metadata_path.empty() ? entry->path : entry->metadata_path, entry->database)); + MutexGuard guard(g_assets_mutex); + LockGuard entry_guard(entry->lock); + if(entry->main_data_unit.data) old_data.push_back(move(entry->main_data_unit.data)); + for(auto& unit : entry->data_units) if(unit.second.data) old_data.push_back(move(unit.second.data)); + clear_asset_registration(entry); } lucatchret; return ok; @@ -684,69 +923,104 @@ namespace Luna destination.push_back(destination_filename); const usize extension_begin = source_base_name_size + 1; if(source_filename.size() > extension_begin) - { destination.append_extension(source_filename.c_str() + extension_begin); - } return destination; } - LUNA_ASSET_API RV move_asset(asset_t asset, const Path& new_path) + static R>> prepare_asset_file_transfer(asset_t source, const Path& destination) { - lucheck_msg(asset.handle, "Asset handle must not be null!"); - if(new_path.empty()) return Asset::E_EMPTY_ASSET_PATH; - AssetEntry* entry = (AssetEntry*)asset.handle; - MutexGuard registry_guard(g_assets_mutex); - auto mapped = g_asset_path_mapping.find(new_path); - if(mapped != g_asset_path_mapping.end() && mapped->second != asset) return E_ALREADY_EXISTS; - Path old_path = get_asset_path(asset); - if(old_path == new_path) return ok; - + Vector> result; lutry { - luexp(begin_asset_maintenance(entry)); - AssetMaintenanceGuard maintenance_guard{entry}; Vector files; - luexp(get_asset_files(asset, files)); - Name old_filename = old_path.back(); - Name new_filename = new_path.back(); - Path old_directory = old_path; - Path new_directory = new_path; - old_directory.pop_back(); - new_directory.pop_back(); - - for(auto& filename : files) + luexp(get_asset_files(source, files)); + Path source_path = get_asset_path(source); + Name source_name = source_path.back(); + Name destination_name = destination.back(); + source_path.pop_back(); + Path destination_directory(destination); + destination_directory.pop_back(); + auto target_database = find_database(destination); + for(const auto& name : files) { - Path destination = get_destination_asset_file_path( - new_directory, new_filename, filename, old_filename.size()); - auto attributes = VFS::get_file_attribute(destination); - if(succeeded(attributes)) - { - luthrow(set_error(E_ALREADY_EXISTS, - "Cannot move asset file to %s because it already exists.", destination.encode().c_str())); - } + Path from(source_path); + from.push_back(name); + Path to = get_destination_asset_file_path(destination_directory, destination_name, name, source_name.size()); + if(target_database && target_database->is_metadata_file(database_relative_path(target_database->get_root(), to))) + return E_ALREADY_EXISTS; + auto attr = VFS::get_file_attribute(to); + if(succeeded(attr)) return set_error(E_ALREADY_EXISTS, "Asset destination %s already exists.", to.encode().c_str()); + if(unwrap_errcode(attr.errcode()) != E_NOT_FOUND) return attr.errcode(); + result.push_back(make_pair(move(from), move(to))); } - for(auto& filename : files) + } + lucatchret; + return result; + } + + static RV rollback_asset_transfer(RV failure, const Vector>& files, usize completed, bool moving) + { + Error error = failure.errcode() == E_ERROR_OBJECT ? get_error() : Error(failure.errcode(), String(explain(failure.errcode()))); + while(completed) + { + const auto& paths = files[--completed]; + auto result = moving ? VFS::move_file(paths.second, paths.first) : VFS::delete_file(paths.second); + if(failed(result)) { - Path source = old_directory; - source.push_back(filename); - Path destination = get_destination_asset_file_path( - new_directory, new_filename, filename, old_filename.size()); - luexp(VFS::move_file(source, destination)); + error.message.append("; failed to restore asset file at "); + error.message.append(paths.second.encode()); } + } + get_error() = move(error); + return E_ERROR_OBJECT; + } + LUNA_ASSET_API RV move_asset(asset_t asset, const Path& new_path) + { + MutexGuard metadata_guard(g_metadata_mutex); + if(!asset) return E_BAD_ARGUMENTS; + if(new_path.empty()) return E_EMPTY_ASSET_PATH; + auto valid = validate_database_path(new_path, false); + if(failed(valid)) return valid; + auto entry = (AssetEntry*)asset.handle; + lutry + { + luexp(begin_asset_maintenance(entry)); + AssetMaintenanceGuard reservation{entry}; + Path old_path = get_asset_path(asset); + if(old_path == new_path) return ok; + if(old_path.empty() || entry->type.empty()) return E_ASSET_NOT_REGISTERED; + if(entry->database && entry->database->is_read_only()) return E_NOT_SUPPORTED; + auto current_database = find_database(old_path); + if(current_database && current_database->is_read_only()) return E_NOT_SUPPORTED; { - LockGuard entry_guard(entry->lock); - g_asset_path_mapping.erase(entry->path); - entry->path = new_path; - g_asset_path_mapping.insert_or_assign(entry->path, asset); - AssetMetaFile file = get_asset_meta_file(entry); - Path meta_path = new_path; - meta_path.append_extension("meta"); - entry_guard.unlock(); - luexp(internal_save_asset_meta(file, meta_path)); + MutexGuard guard(g_assets_mutex); + auto found = g_asset_path_mapping.find(new_path); + if(found != g_asset_path_mapping.end() && found->second != asset) return E_ALREADY_EXISTS; + } + luexp(check_metadata_destination(new_path, entry->guid)); + lulet(files, prepare_asset_file_transfer(asset, new_path)); + usize completed = 0; + for(const auto& paths : files) + { + auto result = VFS::move_file(paths.first, paths.second); + if(failed(result)) return rollback_asset_transfer(result, files, completed, true); + ++completed; + } + AssetMetaFile metadata; + { + LockGuard guard(entry->lock); + metadata = get_asset_meta_file(entry); } - end_asset_maintenance(entry); - maintenance_guard.dismiss(); + auto saved = write_metadata_at(metadata, new_path, entry->metadata_path, entry->database); + if(failed(saved)) return rollback_asset_transfer(saved, files, completed, true); + MutexGuard guard(g_assets_mutex); + LockGuard entry_guard(entry->lock); + g_asset_path_mapping.erase(old_path); + entry->path = new_path; + entry->metadata_path = new_path; + entry->database = find_database(new_path); + g_asset_path_mapping.insert_or_assign(new_path, asset); } lucatchret; return ok; @@ -754,88 +1028,63 @@ namespace Luna LUNA_ASSET_API R copy_asset(asset_t asset, const Path& new_path, const Guid& guid) { - lucheck_msg(asset.handle, "Asset handle must not be null!"); - if(new_path.empty()) return Asset::E_EMPTY_ASSET_PATH; - if(guid != Guid(0, 0) && guid == get_asset_guid(asset)) return E_ALREADY_EXISTS; - AssetEntry* source_entry = (AssetEntry*)asset.handle; - MutexGuard registry_guard(g_assets_mutex); - if(g_asset_path_mapping.find(new_path) != g_asset_path_mapping.end()) return E_ALREADY_EXISTS; - - asset_t new_asset_handle; + MutexGuard metadata_guard(g_metadata_mutex); + if(!asset) return E_BAD_ARGUMENTS; + if(new_path.empty()) return E_EMPTY_ASSET_PATH; + auto source_entry = (AssetEntry*)asset.handle; + asset_t copied; lutry { + luexp(validate_database_path(new_path, false)); luexp(begin_asset_maintenance(source_entry)); - AssetMaintenanceGuard source_maintenance_guard{source_entry}; - Path old_path = get_asset_path(asset); - Vector files; - luexp(get_asset_files(asset, files)); - Name old_filename = old_path.back(); - Name new_filename = new_path.back(); - Path old_directory = old_path; - Path new_directory = new_path; - old_directory.pop_back(); - new_directory.pop_back(); - - for(auto& filename : files) + AssetMaintenanceGuard source_reservation{source_entry}; + if(source_entry->path.empty() || source_entry->type.empty()) return E_ASSET_NOT_REGISTERED; + if(guid == source_entry->guid) return E_ALREADY_EXISTS; { - Path destination = get_destination_asset_file_path( - new_directory, new_filename, filename, old_filename.size()); - auto attributes = VFS::get_file_attribute(destination); - if(succeeded(attributes)) - { - luthrow(set_error(E_ALREADY_EXISTS, - "Cannot copy asset file to %s because it already exists.", destination.encode().c_str())); - } + MutexGuard guard(g_assets_mutex); + if(g_asset_path_mapping.find(new_path) != g_asset_path_mapping.end()) return E_ALREADY_EXISTS; } - for(auto& filename : files) + copied = get_asset(guid); + auto target = (AssetEntry*)copied.handle; + luexp(begin_asset_maintenance(target)); + AssetMaintenanceGuard target_reservation{target}; { - Path source = old_directory; - source.push_back(filename); - Path destination = get_destination_asset_file_path( - new_directory, new_filename, filename, old_filename.size()); - luexp(VFS::copy_file(source, destination)); + LockGuard guard(target->lock); + if(!target->type.empty() || has_loaded_data(target)) return E_ALREADY_EXISTS; } - - AssetMetaFile source_meta; + luexp(check_metadata_destination(new_path, target->guid)); + lulet(files, prepare_asset_file_transfer(asset, new_path)); + usize completed = 0; + for(const auto& paths : files) { - LockGuard source_guard(source_entry->lock); - source_meta = get_asset_meta_file(source_entry); + auto result = VFS::copy_file(paths.first, paths.second); + if(failed(result)) return rollback_asset_transfer(result, files, completed, false).errcode(); + ++completed; } - new_asset_handle = get_asset(guid); - AssetEntry* destination_entry = (AssetEntry*)new_asset_handle.handle; - luexp(begin_asset_maintenance(destination_entry)); - AssetMaintenanceGuard destination_maintenance_guard{destination_entry}; + AssetMetaFile metadata; { - LockGuard destination_guard(destination_entry->lock); - if(destination_entry->main_data_unit.data) - { - luthrow(Asset::E_ASSET_DATA_UNIT_BUSY); - } - for(auto& data_unit : destination_entry->data_units) - { - if(data_unit.second.data) luthrow(Asset::E_ASSET_DATA_UNIT_BUSY); - } - luexp(reconcile_asset_meta_file(destination_entry, source_meta, false, true)); - if(!destination_entry->path.empty()) g_asset_path_mapping.erase(destination_entry->path); - destination_entry->path = new_path; - g_asset_path_mapping.insert_or_assign(new_path, new_asset_handle); - AssetMetaFile destination_meta = get_asset_meta_file(destination_entry); - Path meta_path = new_path; - meta_path.append_extension("meta"); - destination_guard.unlock(); - luexp(internal_save_asset_meta(destination_meta, meta_path)); + LockGuard guard(source_entry->lock); + metadata = get_asset_meta_file(source_entry); } - end_asset_maintenance(destination_entry); - destination_maintenance_guard.dismiss(); - end_asset_maintenance(source_entry); - source_maintenance_guard.dismiss(); + metadata.guid = target->guid; + auto saved = write_metadata_at(metadata, new_path); + if(failed(saved)) return rollback_asset_transfer(saved, files, completed, false).errcode(); + MutexGuard guard(g_assets_mutex); + LockGuard target_guard(target->lock); + luassert_always(succeeded(reconcile_asset_meta_file(target, metadata, true, true))); + if(!target->path.empty()) g_asset_path_mapping.erase(target->path); + target->path = new_path; + target->metadata_path = new_path; + target->database = find_database(new_path); + g_asset_path_mapping.insert_or_assign(new_path, copied); } lucatchret; - return new_asset_handle; + return copied; } LUNA_ASSET_API RV add_asset_data_unit(asset_t asset, const AssetDataUnitDesc& desc) { + MutexGuard metadata_guard(g_metadata_mutex); lucheck_msg(asset.handle, "Asset handle must not be null!"); if(desc.id.empty() || desc.loader.empty()) return E_BAD_ARGUMENTS; AssetEntry* entry = (AssetEntry*)asset.handle; @@ -854,6 +1103,7 @@ namespace Luna LUNA_ASSET_API RV remove_asset_data_unit(asset_t asset, const Name& data_unit) { + MutexGuard metadata_guard(g_metadata_mutex); lucheck_msg(asset.handle, "Asset handle must not be null!"); if(data_unit.empty()) return E_BAD_ARGUMENTS; AssetEntry* entry = (AssetEntry*)asset.handle; @@ -1174,22 +1424,23 @@ namespace Luna { while(true) { - bool has_active_operations = false; + bool active = false; { - MutexGuard assets_guard(g_assets_mutex); - for(auto& asset : g_assets) + MutexGuard metadata_guard(g_metadata_mutex); { - AssetEntry* entry = asset.get(); - LockGuard entry_guard(entry->lock); - if(entry->maintenance || has_asset_data_unit_operation(entry)) + MutexGuard guard(g_assets_mutex); + for(auto& asset : g_assets) { - has_active_operations = true; - break; + LockGuard entry_guard(asset->lock); + if(asset->maintenance || has_asset_data_unit_operation(asset.get())) { active = true; break; } } + if(!active) g_asset_registry_closing = true; } - if(!has_active_operations) + if(!active) { - g_asset_registry_closing = true; + auto saved = flush_asset_databases(); + if(failed(saved)) log_error("Asset", "Failed to flush metadata databases during shutdown: %s", explain(saved.errcode())); + MutexGuard registry_guard(g_assets_mutex); MutexGuard types_guard(g_asset_types_mutex); MutexGuard loaders_guard(g_asset_loaders_mutex); close_asset_registry(); @@ -1256,6 +1507,7 @@ namespace Luna { close(); g_assets_mutex.reset(); + g_metadata_mutex.reset(); g_asset_types_mutex.reset(); g_asset_loaders_mutex.reset(); } diff --git a/Modules/Luna/Asset/Source/AssetMetaFile.hpp b/Modules/Luna/Asset/Source/AssetMetaFile.hpp index 91e62402..0eb51f43 100644 --- a/Modules/Luna/Asset/Source/AssetMetaFile.hpp +++ b/Modules/Luna/Asset/Source/AssetMetaFile.hpp @@ -16,6 +16,7 @@ namespace Luna { namespace Asset { + struct IAssetDatabase; struct [[luna::struct("{93C04F6C-BC6C-4586-8CB2-7DF1B249DA21}")]] AssetMetaFile { [[Luna::property]] u32 format_version = 1; @@ -44,6 +45,9 @@ namespace Luna Guid guid; Name type; Path path; + // The last saved metadata location can differ from an unsaved runtime path. + Path metadata_path; + IAssetDatabase* database = nullptr; // Retained by the database registry. AssetDataUnitEntry main_data_unit; HashMap data_units; bool maintenance = false; diff --git a/Modules/Luna/Asset/Source/Database.cpp b/Modules/Luna/Asset/Source/Database.cpp new file mode 100644 index 00000000..a66bb573 --- /dev/null +++ b/Modules/Luna/Asset/Source/Database.cpp @@ -0,0 +1,522 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Database.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include +#define LUNA_ASSET_API LUNA_EXPORT +#include "DatabaseImpl.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace Luna::Asset +{ + bool database_contains(const Path& root, const Path& path) + { + return root.root() == path.root() && root.flags() == path.flags() && path.is_subpath_of(root); + } + RV validate_database_path(const Path& path, bool relative, bool allow_empty) + { + if(!allow_empty && path.empty()) return E_BAD_ARGUMENTS; + if(relative && (!path.root().empty() || path.flags() != PathFlag::none)) return E_BAD_ARGUMENTS; + if((u32)path.flags() & ~(u32)PathFlag::absolute) return E_BAD_ARGUMENTS; + for(const auto& node : path) + { + const c8* s = node.c_str(); + if(node.empty() || strlen(s) != node.size() || !strcmp(s, ".") || !strcmp(s, "..") || + strchr(s, '/') || strchr(s, '\\') || strchr(s, ':')) return E_BAD_ARGUMENTS; + } + return ok; + } + Path database_relative_path(const Path& root, const Path& path) + { + Path result; + result.assign_relative(root, path); + result.root().reset(); + return result; + } + Path database_absolute_path(const Path& root, const Path& path) + { + Path result(root); + result.append(path); + return result; + } + RV validate_metadata(const AssetMetadata& record) + { + if(failed(validate_database_path(record.path, true)) || record.guid == Guid(0, 0) || record.type.empty()) + return set_error(E_BAD_DATA, "Invalid asset metadata GUID, path or type."); + HashSet ids; + for(const auto& unit : record.data_units) + { + if(unit.id.empty() || unit.loader.empty() || ids.contains(unit.id)) + return set_error(E_BAD_DATA, "Invalid or duplicate named data unit."); + ids.insert(unit.id); + } + return ok; + } + RV validate_records(Span records) + { + HashSet guids; + HashSet paths; + for(const auto& record : records) + { + auto valid = validate_metadata(record); + if(failed(valid)) return valid; + if(guids.contains(record.guid) || paths.contains(record.path)) + return set_error(E_ALREADY_EXISTS, "Duplicate database GUID or asset path %s.", record.path.encode().c_str()); + guids.insert(record.guid); + paths.insert(record.path); + } + return ok; + } + AssetMetadata metadata_from_file(const AssetMetaFile& file, const Path& path) + { + AssetMetadata record; + record.guid = file.guid; + record.path = path; + record.type = file.type; + record.data_units = file.data_units; + return record; + } + AssetMetaFile metadata_to_file(const AssetMetadata& record) + { + AssetMetaFile file; + file.format_version = 2; + file.guid = record.guid; + file.type = record.type; + file.data_units = record.data_units; + sort(file.data_units.begin(), file.data_units.end(), [](const auto& a, const auto& b) + { return strcmp(a.id.c_str(), b.id.c_str()) < 0; }); + return file; + } + static Path sidecar_path(const Path& path) + { + Path result(path); + result.append_extension("meta"); + return result; + } + static bool is_unsigned_integer(const Variant& value) + { + return value.number_type() == VariantNumberType::number_u64 || + (value.number_type() == VariantNumberType::number_i64 && value.inum() >= 0); + } + static bool is_metadata_name(const Variant& value) + { + return value.type() == VariantType::string && !value.str().empty() && + strlen(value.str().c_str()) == value.str().size(); + } + static RV validate_metadata_json(const Variant& data) + { + if(data.type() != VariantType::object || data["guid"].type() != VariantType::array || data["guid"].size() != 2 || + !is_unsigned_integer(data["guid"][(usize)0]) || !is_unsigned_integer(data["guid"][(usize)1]) || + !is_metadata_name(data["type"])) return E_BAD_DATA; + if(data.contains("format_version") && (!is_unsigned_integer(data["format_version"]) || + (data["format_version"].unum() != 1 && data["format_version"].unum() != 2))) return E_BAD_DATA; + if(data.contains("data_units")) + { + if(data["data_units"].type() != VariantType::array) return E_BAD_DATA; + for(const auto& unit : data["data_units"].values()) + if(unit.type() != VariantType::object || !is_metadata_name(unit["id"]) || + !is_metadata_name(unit["loader"])) return E_BAD_DATA; + } + return ok; + } + R read_sidecar(const Path& asset_path) + { + AssetMetaFile result; + lutry + { + lulet(file, VFS::open_file(sidecar_path(asset_path), FileOpenFlag::read, FileCreationMode::open_existing)); + lulet(data, VariantUtils::read_json(file)); + luexp(validate_metadata_json(data)); + luexp(deserialize(result, data)); + if(result.format_version < 1 || result.format_version > 2) return E_BAD_DATA; + luexp(validate_metadata(metadata_from_file(result, Path("record")))); + } + lucatch + { + if(unwrap_errcode(luerr) == E_NOT_FOUND) return E_META_FILE_NOT_FOUND; + return luerr; + } + return result; + } + RV write_metadata_json(const Path& path, const Variant& data, bool overwrite) + { + // Serialize before creating any output; unbuffered writes report short writes and I/O errors. + auto encoded = VariantUtils::write_json(data, VariantUtils::JSONWriteOptions()); + if(failed(encoded)) return encoded.errcode(); + Path temporary; + Ref output; + for(usize attempt = 0; attempt < 16; ++attempt) + { + c8 guid[GUID_STRING_LENGTH + 1]{}; + lupanic_if_failed(encode_guid(random_guid(), guid, GUID_STRING_LENGTH)); + String name(".luna-asset-"); + name.append(guid); + name.append(".tmp"); + temporary = path; + temporary.pop_back(); + temporary.push_back(name.c_str()); + auto opened = VFS::open_file(temporary, FileOpenFlag::write, FileCreationMode::create_new); + if(succeeded(opened)) { output = move(opened.get()); break; } + if(unwrap_errcode(opened.errcode()) != E_ALREADY_EXISTS) return opened.errcode(); + } + if(!output) return E_ALREADY_EXISTS; + RV result = ok; + const auto& bytes = encoded.get(); + usize position = 0; + while(position < bytes.size()) + { + usize count = 0; + result = output->write(bytes.data() + position, bytes.size() - position, &count); + if(failed(result)) break; + if(!count || count > bytes.size() - position) { result = E_IO_ERROR; break; } + position += count; + } + output = nullptr; + if(succeeded(result)) result = VFS::move_file(temporary, path, + overwrite ? FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy : FileMoveFlag::no_copy); + if(failed(result)) + { + Error error = result.errcode() == E_ERROR_OBJECT ? get_error() : Error(result.errcode(), String(explain(result.errcode()))); + auto cleanup = VFS::delete_file(temporary); + if(failed(cleanup)) + { + error.message.append("; metadata output retained at "); + error.message.append(temporary.encode()); + } + get_error() = move(error); + return E_ERROR_OBJECT; + } + return ok; + } + static bool reserved_metadata_path(const Path& path) + { + return !path.empty() && (path.extension() == "meta" || !strncmp(path.back().c_str(), ".luna-asset-", 12)); + } + bool SidecarDatabase::is_metadata_file(const Path& path) { return reserved_metadata_path(path); } + R SidecarDatabase::read_record(const Path& path) + { + MutexGuard guard(m_mutex); + auto valid = validate_database_path(path, true); + if(failed(valid)) return valid.errcode(); + auto file = read_sidecar(database_absolute_path(m_root, path)); + if(failed(file)) return file.errcode(); + return metadata_from_file(file.get(), path); + } + static RV scan_sidecars(const Path& root, const Path& relative, Vector& records) + { + lutry + { + lulet(iter, VFS::open_dir(database_absolute_path(root, relative))); + for(; iter->is_valid(); iter->move_next()) + { + const c8* name = iter->get_filename(); + if(!strcmp(name, ".") || !strcmp(name, "..")) continue; + Path path(relative); + path.push_back(name); + if(test_flags(iter->get_attributes(), FileAttributeFlag::directory)) + { + luexp(scan_sidecars(root, path, records)); + } + else if(path.extension() == "meta") + { + path.remove_extension(); + lulet(file, read_sidecar(database_absolute_path(root, path))); + records.push_back(metadata_from_file(file, path)); + } + } + } + lucatchret; + return ok; + } + R> SidecarDatabase::read_snapshot() + { + MutexGuard guard(m_mutex); + Vector records; + lutry + { + luexp(scan_sidecars(m_root, Path(), records)); + luexp(validate_records({records.data(), records.size()})); + } + lucatchret; + return records; + } + R> SidecarDatabase::get_records() { return read_snapshot(); } + RV SidecarDatabase::accept_snapshot(Span records) { return validate_records({records.data(), records.size()}); } + RV SidecarDatabase::write_record(const AssetMetadata& record, const Path& previous_path) + { + MutexGuard guard(m_mutex); + if(is_read_only()) return E_NOT_SUPPORTED; + lutry + { + luexp(validate_metadata(record)); + luexp(validate_database_path(previous_path, true, true)); + if(is_metadata_file(record.path)) return E_BAD_ARGUMENTS; + auto existing = read_record(record.path); + if(succeeded(existing) && existing.get().guid != record.guid) return E_ALREADY_EXISTS; + if(failed(existing) && unwrap_errcode(existing.errcode()) != E_META_FILE_NOT_FOUND) return existing.errcode(); + const bool relocating = !previous_path.empty() && previous_path != record.path; + if(relocating) + { + auto old = read_record(previous_path); + if(succeeded(old) && old.get().guid != record.guid) return E_ALREADY_EXISTS; + if(failed(old) && unwrap_errcode(old.errcode()) != E_META_FILE_NOT_FOUND) return old.errcode(); + } + lulet(data, serialize(metadata_to_file(record))); + const Path target = sidecar_path(database_absolute_path(m_root, record.path)); + luexp(write_metadata_json(target, data, succeeded(existing))); + if(relocating) + { + auto removed = remove_record(previous_path); + if(failed(removed) && unwrap_errcode(removed.errcode()) != E_META_FILE_NOT_FOUND) + { + Error error = removed.errcode() == E_ERROR_OBJECT ? get_error() : Error(removed.errcode(), String(explain(removed.errcode()))); + RV restored = ok; + if(succeeded(existing)) + { + auto old_data = serialize(metadata_to_file(existing.get())); + restored = failed(old_data) ? RV(old_data.errcode()) : write_metadata_json(target, old_data.get(), true); + } + else restored = VFS::delete_file(target); + if(failed(restored)) error.message.append("; failed to roll back destination sidecar"); + get_error() = move(error); + return E_ERROR_OBJECT; + } + } + } + lucatchret; + return ok; + } + RV SidecarDatabase::remove_record(const Path& path) + { + MutexGuard guard(m_mutex); + if(is_read_only()) return E_NOT_SUPPORTED; + auto valid = validate_database_path(path, true); + if(failed(valid)) return valid; + auto result = VFS::delete_file(sidecar_path(database_absolute_path(m_root, path))); + return failed(result) && unwrap_errcode(result.errcode()) == E_NOT_FOUND ? RV(E_META_FILE_NOT_FOUND) : result; + } + static R encode_database(Span records) + { + auto valid = validate_records({records.data(), records.size()}); + if(failed(valid)) return valid.errcode(); + Vector ordered(records.begin(), records.end()); + sort(ordered.begin(), ordered.end(), [](const auto& a, const auto& b) + { return a.path.encode().compare(b.path.encode()) < 0; }); + Variant result(VariantType::object); + result["format_version"] = Variant((u64)1); + Variant assets(VariantType::array); + lutry + { + for(const auto& record : ordered) + { + lulet(item, serialize(metadata_to_file(record))); + item.erase("format_version"); + item["path"] = record.path.encode().c_str(); + assets.push_back(move(item)); + } + } + lucatchret; + result["assets"] = move(assets); + return result; + } + static R> decode_database(const Path& path) + { + Vector result; + lutry + { + lulet(file, VFS::open_file(path, FileOpenFlag::read, FileCreationMode::open_existing)); + lulet(data, VariantUtils::read_json(file)); + if(data.type() != VariantType::object || !data.contains("format_version") || data["assets"].type() != VariantType::array) + return E_BAD_DATA; + if(!is_unsigned_integer(data["format_version"]) || data["format_version"].unum() != 1) return E_BAD_DATA; + for(const auto& item : data["assets"].values()) + { + if(item.type() != VariantType::object || item["path"].type() != VariantType::string || + !item.contains("guid") || !item.contains("type")) return E_BAD_DATA; + const auto& text = item["path"].str(); + if(strlen(text.c_str()) != text.size()) return E_BAD_DATA; + Path relative(text.c_str()); + // Require canonical relative paths before Path normalization can hide invalid components. + if(relative.encode().compare(text.c_str()) || failed(validate_database_path(relative, true))) return E_BAD_DATA; + AssetMetaFile metadata; + luexp(validate_metadata_json(item)); + luexp(deserialize(metadata, item)); + result.push_back(metadata_from_file(metadata, relative)); + } + luexp(validate_records({result.data(), result.size()})); + } + lucatchret; + return result; + } + bool FileDatabase::is_dirty() { MutexGuard guard(m_mutex); return m_dirty; } + bool FileDatabase::is_metadata_file(const Path& path) { return path == m_filename || reserved_metadata_path(path); } + R FileDatabase::read_record(const Path& path) + { + MutexGuard guard(m_mutex); + auto found = m_paths.find(path); + if(found != m_paths.end()) return m_records[found->second]; + return E_META_FILE_NOT_FOUND; + } + R> FileDatabase::get_records() { MutexGuard guard(m_mutex); return m_records; } + R> FileDatabase::read_snapshot() + { + MutexGuard guard(m_mutex); + return decode_database(database_absolute_path(m_root, m_filename)); + } + RV FileDatabase::accept_snapshot(Span records) + { + MutexGuard guard(m_mutex); + if(m_dirty) return E_BUSY; + auto valid = validate_records({records.data(), records.size()}); + if(failed(valid)) return valid; + for(const auto& record : records) if(is_metadata_file(record.path)) return E_BAD_DATA; + m_records.assign(records.begin(), records.end()); + m_paths.clear(); + m_guids.clear(); + for(usize i = 0; i < m_records.size(); ++i) + { + m_paths.insert(make_pair(m_records[i].path, i)); + m_guids.insert(make_pair(m_records[i].guid, i)); + } + return ok; + } + RV FileDatabase::write_record(const AssetMetadata& record, const Path& previous_path) + { + MutexGuard guard(m_mutex); + if(is_read_only()) return E_NOT_SUPPORTED; + auto valid = validate_metadata(record); + if(failed(valid)) return valid; + valid = validate_database_path(previous_path, true, true); + if(failed(valid)) return valid; + if(is_metadata_file(record.path)) return E_BAD_ARGUMENTS; + auto destination = m_paths.find(record.path); + if(destination != m_paths.end() && m_records[destination->second].guid != record.guid) return E_ALREADY_EXISTS; + auto previous = m_paths.find(previous_path); + if(!previous_path.empty() && previous != m_paths.end() && m_records[previous->second].guid != record.guid) return E_ALREADY_EXISTS; + auto existing = m_guids.find(record.guid); + usize index; + if(existing == m_guids.end()) + { + index = m_records.size(); + m_records.push_back(record); + m_guids.insert(make_pair(record.guid, index)); + } + else + { + index = existing->second; + const auto& old_path = m_records[index].path; + if(old_path != record.path && old_path != previous_path) return E_ALREADY_EXISTS; + m_paths.erase(old_path); + m_records[index] = record; + } + m_paths.insert_or_assign(record.path, index); + m_dirty = true; + return ok; + } + RV FileDatabase::remove_record(const Path& path) + { + MutexGuard guard(m_mutex); + if(is_read_only()) return E_NOT_SUPPORTED; + auto found = m_paths.find(path); + if(found == m_paths.end()) return E_META_FILE_NOT_FOUND; + usize index = found->second; + m_guids.erase(m_records[index].guid); + m_paths.erase(found); + if(index != m_records.size() - 1) + { + m_records[index] = move(m_records.back()); + m_paths.insert_or_assign(m_records[index].path, index); + m_guids.insert_or_assign(m_records[index].guid, index); + } + m_records.pop_back(); + m_dirty = true; + return ok; + } + RV FileDatabase::flush() + { + MutexGuard guard(m_mutex); + if(!m_dirty) return ok; + lutry + { + lulet(data, encode_database({m_records.data(), m_records.size()})); + luexp(write_metadata_json(database_absolute_path(m_root, m_filename), data, true)); + m_dirty = false; + } + lucatchret; + return ok; + } + LUNA_ASSET_API R> new_sidecar_database(const Path& root, DatabaseMode mode) + { + auto valid = validate_database_path(root, false, true); + if(failed(valid)) return valid.errcode(); + if(mode != DatabaseMode::read && mode != DatabaseMode::read_write) return E_BAD_ARGUMENTS; + auto database = new_object(); + database->m_root = root; + database->m_mode = mode; + database->m_mutex = new_mutex(); + return Ref(database); + } + static R> make_file_database(const Path& root, const Path& filename, DatabaseMode mode) + { + auto valid = validate_database_path(root, false, true); + if(failed(valid)) return valid.errcode(); + valid = validate_database_path(filename, true); + if(failed(valid) || reserved_metadata_path(filename)) return E_BAD_ARGUMENTS; + if(mode != DatabaseMode::read && mode != DatabaseMode::read_write) return E_BAD_ARGUMENTS; + auto database = new_object(); + database->m_root = root; + database->m_filename = filename; + database->m_mode = mode; + database->m_mutex = new_mutex(); + return database; + } + LUNA_ASSET_API R> open_file_database(const Path& root, const Path& filename, DatabaseMode mode) + { + Ref database; + lutry + { + luset(database, make_file_database(root, filename, mode)); + lulet(records, database->read_snapshot()); + luexp(database->accept_snapshot({records.data(), records.size()})); + } + lucatchret; + return Ref(database); + } + LUNA_ASSET_API R> create_file_database(const Path& root, const Path& filename) + { + Ref database; + lutry + { + luset(database, make_file_database(root, filename, DatabaseMode::read_write)); + lulet(data, encode_database({})); + luexp(write_metadata_json(database_absolute_path(root, filename), data, false)); + } + lucatchret; + return Ref(database); + } + LUNA_ASSET_API RV export_asset_database(IAssetDatabase* source, const Path& root, const Path& filename) + { + if(!source) return E_BAD_ARGUMENTS; + lutry + { + lulet(database, make_file_database(root, filename, DatabaseMode::read_write)); + lulet(records, source->get_records()); + luexp(database->accept_snapshot({records.data(), records.size()})); + lulet(data, encode_database({records.data(), records.size()})); + luexp(write_metadata_json(database_absolute_path(root, filename), data, false)); + } + lucatchret; + return ok; + } +} diff --git a/Modules/Luna/Asset/Source/DatabaseImpl.hpp b/Modules/Luna/Asset/Source/DatabaseImpl.hpp new file mode 100644 index 00000000..8d97dce2 --- /dev/null +++ b/Modules/Luna/Asset/Source/DatabaseImpl.hpp @@ -0,0 +1,71 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file DatabaseImpl.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include "../Database.hpp" +#include "AssetMetaFile.hpp" +#include +#include +#include "DatabaseImpl.generated.hpp" + +namespace Luna::Asset +{ + bool database_contains(const Path& root, const Path& path); + RV validate_database_path(const Path& path, bool relative, bool allow_empty = false); + RV validate_metadata(const AssetMetadata& record); + RV validate_records(Span records); + Path database_relative_path(const Path& root, const Path& path); + Path database_absolute_path(const Path& root, const Path& path); + AssetMetadata metadata_from_file(const AssetMetaFile& file, const Path& path); + AssetMetaFile metadata_to_file(const AssetMetadata& record); + R read_sidecar(const Path& asset_path); + RV write_metadata_json(const Path& path, const Variant& data, bool overwrite); + + struct [[Luna::struct("{5966C1D1-FF7F-4505-8280-BBABC28EF0F3}")]] SidecarDatabase : IAssetDatabase + { + luiimpl(); + Path m_root; + DatabaseMode m_mode; + Ref m_mutex; + Path get_root() override { return m_root; } + bool is_read_only() override { return m_mode == DatabaseMode::read; } + bool is_dirty() override { return false; } + bool is_metadata_file(const Path& path) override; + R read_record(const Path& path) override; + R> get_records() override; + R> read_snapshot() override; + RV accept_snapshot(Span records) override; + RV write_record(const AssetMetadata& record, const Path& previous_path) override; + RV remove_record(const Path& path) override; + RV flush() override { return ok; } + }; + struct [[Luna::struct("{8B9DBFC5-23E9-47E9-8BB3-B0E1EBC99868}")]] FileDatabase : IAssetDatabase + { + luiimpl(); + Path m_root; + Path m_filename; + DatabaseMode m_mode; + Ref m_mutex; + Vector m_records; + HashMap m_paths; + HashMap m_guids; + bool m_dirty = false; + Path get_root() override { return m_root; } + bool is_read_only() override { return m_mode == DatabaseMode::read; } + bool is_dirty() override; + bool is_metadata_file(const Path& path) override; + R read_record(const Path& path) override; + R> get_records() override; + R> read_snapshot() override; + RV accept_snapshot(Span records) override; + RV write_record(const AssetMetadata& record, const Path& previous_path) override; + RV remove_record(const Path& path) override; + RV flush() override; + }; +} diff --git a/Modules/Luna/EditorGUI/Base.hpp b/Modules/Luna/EditorGUI/Base.hpp index e7c9443c..513f213d 100644 --- a/Modules/Luna/EditorGUI/Base.hpp +++ b/Modules/Luna/EditorGUI/Base.hpp @@ -161,6 +161,8 @@ namespace Luna }; //! Describes a draggable numeric editor. + //! @remark Drag editors switch to direct text entry when double-clicked. Enter or focus loss accepts a + //! valid value, while Escape restores the value from before direct entry began. struct DragDesc { //! Whether the editor accepts input. diff --git a/Modules/Luna/EditorGUI/IconNames.inl b/Modules/Luna/EditorGUI/IconNames.inl index 8c6840d6..5c562716 100644 --- a/Modules/Luna/EditorGUI/IconNames.inl +++ b/Modules/Luna/EditorGUI/IconNames.inl @@ -110,6 +110,7 @@ crop, crosshair, camera, palette, +martini, house, calendar, clock, diff --git a/Modules/Luna/EditorGUI/Res/PhosphorCore.bin b/Modules/Luna/EditorGUI/Res/PhosphorCore.bin index 521f1976..0bd1a3d5 100644 --- a/Modules/Luna/EditorGUI/Res/PhosphorCore.bin +++ b/Modules/Luna/EditorGUI/Res/PhosphorCore.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79fbe8ae4988715c15af3ce30a73a756da60c9ce2ccccbf434edc4673498d16d -size 97028 +oid sha256:8631096fb674ea47f7bf4f7becde63e29ab193b584e8d05569c2b04429b38d72 +size 97916 diff --git a/Modules/Luna/EditorGUI/Res/PhosphorIcons.json b/Modules/Luna/EditorGUI/Res/PhosphorIcons.json index 3056de8a..e7fbc2f7 100644 --- a/Modules/Luna/EditorGUI/Res/PhosphorIcons.json +++ b/Modules/Luna/EditorGUI/Res/PhosphorIcons.json @@ -23,7 +23,7 @@ "bell-slash", "lightning", "spinner-gap", "play", "pause", "stop", "record", "power", "prohibit", "sun", "moon", "speaker-high", "speaker-slash", "heart", "star", "user", "users", "cursor", "cursor-click", "cursor-text", "hand-tap", "mouse", "selection", "bounding-box", - "frame-corners", "crop", "crosshair", "camera", "palette", "house", "calendar", "clock", + "frame-corners", "crop", "crosshair", "camera", "palette", "martini", "house", "calendar", "clock", "globe", "wifi-high", "bluetooth", "game-controller", "map-trifold" ], "weights": { @@ -35,7 +35,7 @@ "fill": [ "check-circle", "x-circle", "warning-circle", "bell", "bell-slash", "play", "pause", "stop", "record", "heart", "star", "eye", "eye-slash", "folder", "folder-open", "lock", "lock-open", - "lightning", "sun", "moon" + "lightning", "sun", "moon", "martini" ], "duotone": ["package"] } diff --git a/Modules/Luna/EditorGUI/Source/Button.cpp b/Modules/Luna/EditorGUI/Source/Button.cpp index e3ffd23a..467032b6 100644 --- a/Modules/Luna/EditorGUI/Source/Button.cpp +++ b/Modules/Luna/EditorGUI/Source/Button.cpp @@ -28,11 +28,12 @@ namespace Luna return a + (b - a) * clamp(factor, 0.0f, 1.0f); } - static RV draw_button(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_button(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { ButtonData* data = (ButtonData*)userdata; - if(!data) return ok; + if(!data) return paint_order_id; const f32 active = data->enabled && data->state ? data->state->active : 0.0f; const f32 radius = style_scalar(context, element, "gui.button.radius", 4.0f); const Float2U shadow_offset = style_vector2(context, element, "gui.shadow.offset", Float2U(3.0f)); @@ -71,9 +72,9 @@ namespace Luna style_color(context, element, "gui.button.focus", Float4U(0.45f, 0.60f, 0.85f, 1.0f)) : style_color(context, element, "gui.button.border", Float4U(0.24f, 0.29f, 0.35f, 1.0f)); if(RV result = draw_rounded_rect_effects(context, element, RectF(), Float4U(), radius, - Span(outer_effects, num_outer_effects)); failed(result)) + Span(outer_effects, num_outer_effects), paint_order_id); failed(result)) { - return result; + return result.errcode(); } RoundedRectEffect inner_effects[2]; @@ -93,11 +94,12 @@ namespace Luna if(RV result = draw_rounded_rect_effects(context, element, RectF(1.0f, 1.0f, -2.0f, -2.0f), Float4U(), max(radius - 1.0f, 0.0f), - Span(inner_effects, num_inner_effects)); failed(result)) + Span(inner_effects, num_inner_effects), + paint_order_id + 1); failed(result)) { - return result; + return result.errcode(); } - return ok; + return paint_order_id + 1; } static GUI::FlexLayoutDesc* configure_button_layout(GUI::IContext* context, diff --git a/Modules/Luna/EditorGUI/Source/ButtonGroup.cpp b/Modules/Luna/EditorGUI/Source/ButtonGroup.cpp index ba8c433f..d72e1120 100644 --- a/Modules/Luna/EditorGUI/Source/ButtonGroup.cpp +++ b/Modules/Luna/EditorGUI/Source/ButtonGroup.cpp @@ -38,7 +38,8 @@ namespace Luna static RV draw_button_group_item_surface(GUI::IContext* context, const GUI::ElementHandle& element, const RectF& rect, const Float4U& rect_layout_scale, - f32 radius, const Float4U& color, bool selected) + f32 radius, const Float4U& color, bool selected, + GUI::paint_order_id_t paint_order_id) { RoundedRectEffect effects[3]; u32 num_effects = 0; @@ -63,14 +64,15 @@ namespace Luna highlight.shadow_desc.mode = GUI::ShadowMode::inner; } return draw_rounded_rect_effects(context, element, rect, rect_layout_scale, - radius, Span(effects, num_effects)); + radius, Span(effects, num_effects), paint_order_id); } - static RV draw_button_group(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_button_group(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { ButtonGroupData* data = (ButtonGroupData*)userdata; - if(!data || !data->item_count) return ok; + if(!data || !data->item_count) return paint_order_id; f32 radius = style_scalar(context, element, "gui.group.radius", 5.0f); f32 item_inset = style_scalar(context, element, "gui.group.padding", 2.0f) + 1.0f; f32 item_radius = style_scalar(context, element, "gui.group.selected_radius", @@ -80,9 +82,9 @@ namespace Luna border.color = style_color(context, element, "gui.group.border", Float4U(0.24f, 0.30f, 0.38f, 1.0f)); if(RV result = draw_rounded_rect_effects(context, element, RectF(), Float4U(), radius, - Span(&border, 1)); failed(result)) + Span(&border, 1), paint_order_id); failed(result)) { - return result; + return result.errcode(); } RoundedRectEffect inner_effects[2]; @@ -97,9 +99,9 @@ namespace Luna if(RV result = draw_rounded_rect_effects(context, element, RectF(1.0f, 1.0f, -2.0f, -2.0f), Float4U(), max(radius - 1.0f, 0.0f), - Span(inner_effects, 2)); failed(result)) + Span(inner_effects, 2), paint_order_id + 1); failed(result)) { - return result; + return result.errcode(); } auto draw_item_surface = [&](f32 position, const Float4U& color, bool selected) -> RV @@ -109,7 +111,7 @@ namespace Luna -item_inset * item_width * 2.0f, -item_inset * 2.0f); Float4U item_scale(position * item_width, 0.0f, item_width, 0.0f); return draw_button_group_item_surface(context, element, item_rect, item_scale, - item_radius, color, selected); + item_radius, color, selected, paint_order_id + 2); }; i32 hovered_item = -1; @@ -133,7 +135,7 @@ namespace Luna style_color(context, element, "gui.group.hovered", Float4U(0.13f, 0.19f, 0.27f, 1.0f)), false); failed(result)) { - return result; + return result.errcode(); } } @@ -144,7 +146,7 @@ namespace Luna if(RV result = draw_item_surface(data->state->animated_index, selected_color, true); failed(result)) { - return result; + return result.errcode(); } } else if(data->selected_items) @@ -155,19 +157,20 @@ namespace Luna { if(RV result = draw_item_surface((f32)i, selected_color, true); failed(result)) { - return result; + return result.errcode(); } } } } - return ok; + return paint_order_id + 2; } - static RV draw_button_group_item(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_button_group_item(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { ButtonGroupItemData* data = (ButtonGroupItemData*)userdata; - if(!data) return ok; + if(!data) return paint_order_id; bool selected = data->selected ? *data->selected : (data->selected_index && *data->selected_index == (i32)data->item_index); GUI::DrawCommand text; @@ -183,8 +186,8 @@ namespace Luna style_color(context, element, "gui.button.text", Float4U(1.0f))); text.horizontal_alignment = VG::TextAlignment::center; text.vertical_alignment = VG::TextAlignment::center; - context->draw(text); - return ok; + context->draw(text, paint_order_id); + return paint_order_id; } bool resolve_button_group_multi_action(GUI::IContext* context, ButtonGroupMultiAction& action) @@ -220,12 +223,15 @@ namespace Luna GUI::FlexLayoutDesc* flex = Internal::allocate_frame(context); flex->axis = GUI::LayoutAxis::x; flex->cross_alignment = GUI::FlexAlignment::stretch; + flex->clip_children = true; GUI::LayoutCallbackConfig layout_callbacks; layout_callbacks.algorithm = Name("gui.button_group"); layout_callbacks.measure_callback = GUI::measure_flex; layout_callbacks.callback = GUI::layout_flex; layout_callbacks.userdata = flex; context->set_layout_callback_config(group, layout_callbacks); + // Every item is clipped to its non-overlapping flex cell. + context->set_child_paint_order_mode(group, GUI::ChildPaintOrderMode::shared); Ref state = Internal::widget_state(context, id); Internal::ButtonGroupData* group_data = Internal::allocate_frame(context); @@ -287,12 +293,15 @@ namespace Luna GUI::FlexLayoutDesc* flex = Internal::allocate_frame(context); flex->axis = GUI::LayoutAxis::x; flex->cross_alignment = GUI::FlexAlignment::stretch; + flex->clip_children = true; GUI::LayoutCallbackConfig callbacks; callbacks.algorithm = Name("gui.button_group.multi"); callbacks.measure_callback = GUI::measure_flex; callbacks.callback = GUI::layout_flex; callbacks.userdata = flex; context->set_layout_callback_config(group, callbacks); + // Every item is clipped to its non-overlapping flex cell. + context->set_child_paint_order_mode(group, GUI::ChildPaintOrderMode::shared); Internal::ButtonGroupData* group_data = Internal::allocate_frame(context); group_data->item_count = items.size(); diff --git a/Modules/Luna/EditorGUI/Source/Choice.cpp b/Modules/Luna/EditorGUI/Source/Choice.cpp index 154ddab6..e2937352 100644 --- a/Modules/Luna/EditorGUI/Source/Choice.cpp +++ b/Modules/Luna/EditorGUI/Source/Choice.cpp @@ -52,7 +52,7 @@ namespace Luna } static void draw_rect(GUI::IContext* context, const RectF& rect, const Float4U& scale, - const Float4U& color, f32 radius) + const Float4U& color, f32 radius, GUI::paint_order_id_t paint_order_id) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::rounded_rect; @@ -61,11 +61,12 @@ namespace Luna command.rect_layout_scale = scale; command.color = color; command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); } static void draw_line(GUI::IContext* context, const Float2U& begin, const Float2U& end, - const Float4U& scale, const Float4U& color, f32 width) + const Float4U& scale, const Float4U& color, f32 width, + GUI::paint_order_id_t paint_order_id) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::line; @@ -75,14 +76,15 @@ namespace Luna command.rect_layout_scale = scale; command.color = color; command.line_width = width; - context->draw(command); + context->draw(command, paint_order_id); } - static RV draw_choice(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_choice(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { ChoiceData* data = (ChoiceData*)userdata; - if(!data || !data->state) return ok; + if(!data || !data->state) return paint_order_id; f32 selected = data->state->selected; Float4U text_color = data->enabled ? style_color(context, element, "gui.text.color", Float4U(0.86f, 0.88f, 0.92f, 1.0f)) : style_color(context, element, "gui.text.disabled", @@ -99,7 +101,10 @@ namespace Luna transparent_hovered.w = 0.0f; Float4U color = mix_color(transparent_hovered, hovered, data->state->hovered); color = mix_color(color, background, selected); - if(color.w > 0.001f) draw_rect(context, RectF(), Float4U(), color, 4.0f); + if(color.w > 0.001f) + { + draw_rect(context, RectF(), Float4U(), color, 4.0f, paint_order_id); + } label_offset = 8.0f; } else if(data->kind == ChoiceKind::checkbox) @@ -113,12 +118,13 @@ namespace Luna const f32 half = indicator_size * 0.5f; const f32 inset = indicator_size >= 20.0f ? 3.0f : 2.0f; draw_rect(context, RectF(0.0f, -half, indicator_size, indicator_size), - Float4U(0.0f, 0.5f, 0.0f, 0.0f), border, 3.0f); + Float4U(0.0f, 0.5f, 0.0f, 0.0f), border, 3.0f, paint_order_id); draw_rect(context, RectF(inset, -half + inset, indicator_size - inset * 2.0f, indicator_size - inset * 2.0f), Float4U(0.0f, 0.5f, 0.0f, 0.0f), mix_color(style_color(context, element, "gui.choice.background", - Float4U(0.08f, 0.10f, 0.13f, 1.0f)), fill, selected), 2.0f); + Float4U(0.08f, 0.10f, 0.13f, 1.0f)), fill, selected), 2.0f, + paint_order_id + 1); if(selected > 0.02f) { Float4U mark = style_color(context, element, "gui.choice.mark", Float4U(1.0f)); @@ -126,10 +132,12 @@ namespace Luna const f32 scale = indicator_size / 16.0f; draw_line(context, Float2U(4.0f * scale, -1.0f * scale), Float2U(7.0f * scale, 3.0f * scale), - Float4U(0.0f, 0.5f, 0.0f, 0.5f), mark, 2.0f); + Float4U(0.0f, 0.5f, 0.0f, 0.5f), mark, 2.0f, + paint_order_id + 2); draw_line(context, Float2U(7.0f * scale, 3.0f * scale), Float2U(13.0f * scale, -4.0f * scale), - Float4U(0.0f, 0.5f, 0.0f, 0.5f), mark, 2.0f); + Float4U(0.0f, 0.5f, 0.0f, 0.5f), mark, 2.0f, + paint_order_id + 2); } } else if(data->kind == ChoiceKind::radio) @@ -140,11 +148,12 @@ namespace Luna const f32 half = indicator_size * 0.5f; const f32 inset = indicator_size >= 20.0f ? 3.0f : 2.0f; draw_rect(context, RectF(0.0f, -half, indicator_size, indicator_size), - Float4U(0.0f, 0.5f, 0.0f, 0.0f), border, half); + Float4U(0.0f, 0.5f, 0.0f, 0.0f), border, half, paint_order_id); draw_rect(context, RectF(inset, -half + inset, indicator_size - inset * 2.0f, indicator_size - inset * 2.0f), Float4U(0.0f, 0.5f, 0.0f, 0.0f), style_color(context, element, - "gui.choice.background", Float4U(0.08f, 0.10f, 0.13f, 1.0f)), half - inset); + "gui.choice.background", Float4U(0.08f, 0.10f, 0.13f, 1.0f)), half - inset, + paint_order_id + 1); if(selected > 0.02f) { Float4U fill = style_color(context, element, "gui.choice.accent", @@ -152,7 +161,8 @@ namespace Luna fill.w *= selected; const f32 dot = indicator_size * 0.38f; draw_rect(context, RectF((indicator_size - dot) * 0.5f, -dot * 0.5f, dot, dot), - Float4U(0.0f, 0.5f, 0.0f, 0.0f), fill, dot * 0.5f); + Float4U(0.0f, 0.5f, 0.0f, 0.0f), fill, dot * 0.5f, + paint_order_id + 2); } } else @@ -181,7 +191,7 @@ namespace Luna const f32 inner_track_radius = switch_size.y * 0.5f - 1.0f; draw_rect(context, track_rect, track_scale, style_color(context, element, "gui.choice.border", Float4U(0.55f, 0.64f, 0.76f, 1.0f)), - switch_size.y * 0.5f); + switch_size.y * 0.5f, paint_order_id); RoundedRectEffect track_effects[2]; track_effects[0].color = mix_color(off, on, selected); u32 num_track_effects = 1; @@ -198,9 +208,9 @@ namespace Luna } if(RV result = draw_rounded_rect_effects(context, element, inner_track_rect, track_scale, inner_track_radius, Span(track_effects, - num_track_effects)); failed(result)) + num_track_effects), paint_order_id + 1); failed(result)) { - return result; + return result.errcode(); } const RectF knob_rect(4.0f + knob_travel * selected, knob_size * -0.5f, knob_size, knob_size); @@ -222,9 +232,9 @@ namespace Luna knob_effects[num_knob_effects++].color = knob; if(RV result = draw_rounded_rect_effects(context, element, knob_rect, knob_scale, knob_size * 0.5f, Span(knob_effects, - num_knob_effects)); failed(result)) + num_knob_effects), paint_order_id + 2); failed(result)) { - return result; + return result.errcode(); } } GUI::DrawCommand text; @@ -237,8 +247,8 @@ namespace Luna text.color = text_color; text.horizontal_alignment = VG::TextAlignment::begin; text.vertical_alignment = VG::TextAlignment::center; - context->draw(text); - return ok; + context->draw(text, paint_order_id + 3); + return paint_order_id + 3; } bool resolve_choice_action(GUI::IContext* context, ChoiceAction& action) diff --git a/Modules/Luna/EditorGUI/Source/ColorEdit.cpp b/Modules/Luna/EditorGUI/Source/ColorEdit.cpp index 812478f0..e889258b 100644 --- a/Modules/Luna/EditorGUI/Source/ColorEdit.cpp +++ b/Modules/Luna/EditorGUI/Source/ColorEdit.cpp @@ -214,7 +214,7 @@ namespace Luna } static void draw_rect(GUI::IContext* context, GUI::DrawCommandType type, const RectF& rect, - const Float4U& color, f32 radius = 0.0f) + const Float4U& color, GUI::paint_order_id_t paint_order_id, f32 radius = 0.0f) { GUI::DrawCommand command; command.type = type; @@ -222,11 +222,12 @@ namespace Luna command.rect = rect; command.color = color; command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); } static void draw_gradient(GUI::IContext* context, const RectF& rect, const Float4U& top_left, - const Float4U& top_right, const Float4U& bottom_right, const Float4U& bottom_left) + const Float4U& top_right, const Float4U& bottom_right, const Float4U& bottom_left, + GUI::paint_order_id_t paint_order_id) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::gradient_rect; @@ -236,10 +237,11 @@ namespace Luna command.color_top_right = top_right; command.color_bottom_right = bottom_right; command.color_bottom_left = bottom_left; - context->draw(command); + context->draw(command, paint_order_id); } - static void draw_outline(GUI::IContext* context, const RectF& rect, const Float4U& color) + static void draw_outline(GUI::IContext* context, const RectF& rect, const Float4U& color, + GUI::paint_order_id_t paint_order_id) { const Float2U points[] = { @@ -256,12 +258,13 @@ namespace Luna command.point1 = points[(i + 1) & 3]; command.color = color; command.line_width = 1.0f; - context->draw(command); + context->draw(command, paint_order_id); } } static void draw_label(GUI::IContext* context, const RectF& rect, const c8* text, - const Float4U& color, f32 font_size, VG::TextAlignment alignment = VG::TextAlignment::begin) + const Float4U& color, f32 font_size, GUI::paint_order_id_t paint_order_id, + VG::TextAlignment alignment = VG::TextAlignment::begin) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::text; @@ -273,13 +276,13 @@ namespace Luna command.color = color; command.horizontal_alignment = alignment; command.vertical_alignment = VG::TextAlignment::center; - context->draw(command); + context->draw(command, paint_order_id); } static void draw_swatch(GUI::IContext* context, const RectF& rect, const Float4U& color, - const Float4U& border) + const Float4U& border, GUI::paint_order_id_t paint_order_id) { - draw_rect(context, GUI::DrawCommandType::rounded_rect, rect, border, 3.0f); + draw_rect(context, GUI::DrawCommandType::rounded_rect, rect, border, paint_order_id, 3.0f); RectF inner(rect.offset_x + 1.0f, rect.offset_y + 1.0f, max(rect.width - 2.0f, 1.0f), max(rect.height - 2.0f, 1.0f)); constexpr f32 cell_size = 8.0f; @@ -294,10 +297,11 @@ namespace Luna min(cell_size, inner.height - (f32)y * cell_size)); draw_rect(context, GUI::DrawCommandType::rect, cell, (x + y) & 1 ? Float4U(0.20f, 0.23f, 0.28f, 1.0f) : - Float4U(0.40f, 0.44f, 0.50f, 1.0f)); + Float4U(0.40f, 0.44f, 0.50f, 1.0f), paint_order_id + 1); } } - draw_rect(context, GUI::DrawCommandType::rounded_rect, inner, color, 2.0f); + draw_rect(context, GUI::DrawCommandType::rounded_rect, inner, color, + paint_order_id + 2, 2.0f); } static bool rect_contains(const RectF& rect, const Float2U& point) @@ -317,33 +321,37 @@ namespace Luna return result; } - static RV draw_color_preview(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_color_preview(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { PreviewData* data = (PreviewData*)userdata; - if(!data) return ok; + if(!data) return paint_order_id; GUI::InteractionState interaction = context->get_interaction_state(element.id); Float4U border = style_color(context, element, "gui.color_edit.border", Float4U(0.24f, 0.30f, 0.38f, 1.0f)); Float4U background = style_color(context, element, data->enabled && interaction.hovered ? "gui.color_edit.background_hovered" : "gui.color_edit.background", data->enabled && interaction.hovered ? Float4U(0.13f, 0.19f, 0.27f, 1.0f) : Float4U(0.10f, 0.13f, 0.18f, 1.0f)); f32 radius = style_scalar(context, element, "gui.color_edit.radius", 4.0f); - draw_rect(context, GUI::DrawCommandType::rounded_rect, RectF(), border, radius); + draw_rect(context, GUI::DrawCommandType::rounded_rect, RectF(), border, + paint_order_id, radius); draw_rect(context, GUI::DrawCommandType::rounded_rect, RectF(1.0f, 1.0f, -2.0f, -2.0f), background, - max(radius - 1.0f, 0.0f)); - draw_swatch(context, RectF(6.0f, 4.0f, 22.0f, 22.0f), read_color(data->binding), border); + paint_order_id + 1, max(radius - 1.0f, 0.0f)); + draw_swatch(context, RectF(6.0f, 4.0f, 22.0f, 22.0f), read_color(data->binding), border, + paint_order_id + 2); draw_label(context, RectF(36.0f, 0.0f, -44.0f, 0.0f), data->hex, style_color(context, element, data->enabled ? "gui.text.color" : "gui.text.disabled", data->enabled ? Float4U(0.86f, 0.88f, 0.92f, 1.0f) : Float4U(0.48f, 0.52f, 0.58f, 1.0f)), - style_scalar(context, element, "gui.text.font_size", 15.0f)); - return ok; + style_scalar(context, element, "gui.text.font_size", 15.0f), paint_order_id + 2); + return paint_order_id + 4; } - static RV draw_color_picker(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_color_picker(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { PickerData* data = (PickerData*)userdata; - if(!data || !data->state) return ok; + if(!data || !data->state) return paint_order_id; PickerGeometry geometry; Float4U color = read_color(data->binding); i32 axis = clamp(data->state->axis, 0, 5); @@ -352,9 +360,11 @@ namespace Luna if(axis == 0) { Float4U hue = hsv_to_rgb(bar, 1.0f, 1.0f, 1.0f); - draw_gradient(context, geometry.square, Float4U(1.0f), hue, hue, Float4U(1.0f)); + draw_gradient(context, geometry.square, Float4U(1.0f), hue, hue, Float4U(1.0f), + paint_order_id); draw_gradient(context, geometry.square, Float4U(0.0f), Float4U(0.0f), - Float4U(0.0f, 0.0f, 0.0f, 1.0f), Float4U(0.0f, 0.0f, 0.0f, 1.0f)); + Float4U(0.0f, 0.0f, 0.0f, 1.0f), Float4U(0.0f, 0.0f, 0.0f, 1.0f), + paint_order_id + 1); } else if(axis < 3) { @@ -366,17 +376,19 @@ namespace Luna geometry.square.width * (x1 - x0) + 0.5f, geometry.square.height); Float4U left = color_from_picker(axis, x0, 1.0f, bar, 1.0f); Float4U right = color_from_picker(axis, x1, 1.0f, bar, 1.0f); - draw_gradient(context, segment, left, right, right, left); + draw_gradient(context, segment, left, right, right, left, paint_order_id); if(axis == 1) { draw_gradient(context, segment, Float4U(0.0f), Float4U(0.0f), - Float4U(0.0f, 0.0f, 0.0f, 1.0f), Float4U(0.0f, 0.0f, 0.0f, 1.0f)); + Float4U(0.0f, 0.0f, 0.0f, 1.0f), Float4U(0.0f, 0.0f, 0.0f, 1.0f), + paint_order_id + 1); } else { Float4U gray(bar, bar, bar, 1.0f); draw_gradient(context, segment, Float4U(gray.x, gray.y, gray.z, 0.0f), - Float4U(gray.x, gray.y, gray.z, 0.0f), gray, gray); + Float4U(gray.x, gray.y, gray.z, 0.0f), gray, gray, + paint_order_id + 1); } } } @@ -384,10 +396,10 @@ namespace Luna { draw_gradient(context, geometry.square, color_from_picker(axis, 0.0f, 1.0f, bar, 1.0f), color_from_picker(axis, 1.0f, 1.0f, bar, 1.0f), color_from_picker(axis, 1.0f, 0.0f, bar, 1.0f), - color_from_picker(axis, 0.0f, 0.0f, bar, 1.0f)); + color_from_picker(axis, 0.0f, 0.0f, bar, 1.0f), paint_order_id); } Float4U border = style_color(context, element, "gui.color_picker.border", Float4U(0.24f, 0.30f, 0.38f, 1.0f)); - draw_outline(context, geometry.square, border); + draw_outline(context, geometry.square, border, paint_order_id + 2); if(axis == 0) { for(u32 i = 0; i < 6; ++i) @@ -398,23 +410,23 @@ namespace Luna geometry.bar.width, geometry.bar.height * (y1 - y0) + 0.5f); Float4U top = hsv_to_rgb(y0, 1.0f, 1.0f, 1.0f); Float4U bottom = hsv_to_rgb(y1, 1.0f, 1.0f, 1.0f); - draw_gradient(context, segment, top, top, bottom, bottom); + draw_gradient(context, segment, top, top, bottom, bottom, paint_order_id); } } else { Float4U top = color_from_picker(axis, x, y, 1.0f, 1.0f); Float4U bottom = color_from_picker(axis, x, y, 0.0f, 1.0f); - draw_gradient(context, geometry.bar, top, top, bottom, bottom); + draw_gradient(context, geometry.bar, top, top, bottom, bottom, paint_order_id); } - draw_outline(context, geometry.bar, border); + draw_outline(context, geometry.bar, border, paint_order_id + 2); f32 cursor_x = geometry.square.offset_x + x * geometry.square.width; f32 cursor_y = geometry.square.offset_y + (1.0f - y) * geometry.square.height; Float4U cursor = style_color(context, element, "gui.color_picker.cursor", Float4U(1.0f)); draw_rect(context, GUI::DrawCommandType::rounded_rect, RectF(cursor_x - 8.0f, cursor_y - 8.0f, 16.0f, 16.0f), - cursor, 8.0f); + cursor, paint_order_id + 3, 8.0f); draw_rect(context, GUI::DrawCommandType::rounded_rect, RectF(cursor_x - 5.0f, cursor_y - 5.0f, 10.0f, 10.0f), - color, 5.0f); + color, paint_order_id + 4, 5.0f); f32 bar_y = geometry.bar.offset_y + (axis == 0 ? bar : (1.0f - bar)) * geometry.bar.height; GUI::DrawCommand marker; marker.type = GUI::DrawCommandType::line; @@ -423,16 +435,17 @@ namespace Luna marker.point1 = Float2U(geometry.bar.offset_x + geometry.bar.width + 5.0f, bar_y); marker.color = cursor; marker.line_width = 2.0f; - context->draw(marker); + context->draw(marker, paint_order_id + 3); Float4U text_color = style_color(context, element, "gui.text.color", Float4U(0.86f, 0.88f, 0.92f, 1.0f)); f32 text_size = style_scalar(context, element, "gui.text.font_size", 15.0f); draw_label(context, RectF(geometry.current.offset_x, geometry.current.offset_y - 26.0f, - geometry.current.width, 22.0f), "Current", text_color, text_size); - draw_swatch(context, geometry.current, color, border); + geometry.current.width, 22.0f), "Current", text_color, text_size, paint_order_id + 3); + draw_swatch(context, geometry.current, color, border, paint_order_id + 2); draw_label(context, RectF(geometry.original.offset_x, geometry.original.offset_y - 26.0f, - geometry.original.width, 22.0f), "Original", text_color, text_size); - draw_swatch(context, geometry.original, data->state->original_valid ? data->state->original : color, border); - return ok; + geometry.original.width, 22.0f), "Original", text_color, text_size, paint_order_id + 3); + draw_swatch(context, geometry.original, data->state->original_valid ? data->state->original : color, + border, paint_order_id + 2); + return paint_order_id + 4; } static void build_channel_row(GUI::IContext* context, id_t id, const c8* const* labels, @@ -444,6 +457,8 @@ namespace Luna row_layout.height.kind = GUI::SizeKind::fixed; row_layout.height.value = 30.0f; GUI::ElementHandle row = begin_h_layout(context, id, "Color Channels", row_layout); + // Channel pairs occupy fixed, disjoint cells and are clipped to those cells below. + context->set_child_paint_order_mode(row, GUI::ChildPaintOrderMode::shared); for(u32 i = 0; i < count; ++i) { GUI::LayoutConfig pair_layout; @@ -453,6 +468,8 @@ namespace Luna pair_layout.height.value = 30.0f; GUI::ElementHandle pair = begin_h_layout(context, GUI::make_scoped_id(id, (u64)i + 1), "Color Channel", pair_layout); + // The label and drag control occupy clipped, non-overlapping cells. + context->set_child_paint_order_mode(pair, GUI::ChildPaintOrderMode::shared); GUI::LayoutConfig label_layout; label_layout.width.kind = GUI::SizeKind::fixed; label_layout.width.value = 20.0f; @@ -471,12 +488,14 @@ namespace Luna flex.axis = GUI::LayoutAxis::x; flex.cross_alignment = GUI::FlexAlignment::stretch; flex.main_axis_gap = 4.0f; + flex.clip_children = true; end_h_layout(context, pair, flex); } GUI::FlexLayoutDesc flex; flex.axis = GUI::LayoutAxis::x; flex.main_axis_gap = count == 1 ? 0.0f : 9.0f; flex.cross_alignment = GUI::FlexAlignment::stretch; + flex.clip_children = true; end_h_layout(context, row, flex); } diff --git a/Modules/Luna/EditorGUI/Source/Combo.cpp b/Modules/Luna/EditorGUI/Source/Combo.cpp index c4ad10db..b832d985 100644 --- a/Modules/Luna/EditorGUI/Source/Combo.cpp +++ b/Modules/Luna/EditorGUI/Source/Combo.cpp @@ -15,7 +15,19 @@ namespace Luna { namespace EditorGUI { + struct ComboDrawData + { + c8* selected = nullptr; + Float4U background; + Float4U border; + Float4U text_color; + f32 radius = 0.0f; + f32 font_size = 0.0f; + bool open = false; + }; + static void draw_combo_rect(GUI::IContext* context, const Float4U& color, f32 radius, + GUI::paint_order_id_t paint_order_id, const RectF& rect = RectF(0.0f, 0.0f, 0.0f, 0.0f)) { GUI::DrawCommand command; @@ -24,25 +36,29 @@ namespace Luna command.rect = rect; command.color = color; command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); } static void draw_combo_text(GUI::IContext* context, const RectF& rect, const c8* value, - const Float4U& color, f32 font_size, VG::TextAlignment alignment = VG::TextAlignment::begin) + const Name& font, const Float4U& color, f32 font_size, + GUI::paint_order_id_t paint_order_id, + VG::TextAlignment alignment = VG::TextAlignment::begin) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::text; command.rect_reference = GUI::DrawCommandRectReference::element; command.rect = rect; command.color = color; + command.font = font; command.font_size = font_size; command.horizontal_alignment = alignment; command.vertical_alignment = VG::TextAlignment::center; command.text = value ? value : ""; - context->draw(command); + context->draw(command, paint_order_id); } - static void draw_combo_chevron(GUI::IContext* context, const Float4U& color, bool open) + static void draw_combo_chevron(GUI::IContext* context, const Float4U& color, bool open, + GUI::paint_order_id_t paint_order_id) { const f32 outer_y = open ? 2.5f : -2.5f; const f32 center_y = -outer_y; @@ -54,10 +70,26 @@ namespace Luna line.rect_layout_scale = Float4U(1.0f, 0.5f, 1.0f, 0.5f); line.color = color; line.line_width = 1.6f; - context->draw(line); + context->draw(line, paint_order_id); line.rect = RectF(-18.0f, center_y, 0.0f, 0.0f); line.point1 = Float2U(-13.0f, outer_y); - context->draw(line); + context->draw(line, paint_order_id); + } + + static R draw_combo(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, GUI::paint_order_id_t paint_order_id, + void* userdata) + { + ComboDrawData* data = (ComboDrawData*)userdata; + if(!data) return paint_order_id; + draw_combo_rect(context, data->border, data->radius, paint_order_id); + draw_combo_rect(context, data->background, max(data->radius - 1.0f, 0.0f), + paint_order_id + 1, RectF(1.0f, 1.0f, -2.0f, -2.0f)); + Name font = Internal::style_name(context, element, "gui.font"); + draw_combo_text(context, RectF(12.0f, 0.0f, -44.0f, 0.0f), data->selected, + font, data->text_color, data->font_size, paint_order_id + 2); + draw_combo_chevron(context, data->text_color, data->open, paint_order_id + 2); + return paint_order_id + 2; } LUNA_EDITOR_GUI_API GUI::ElementHandle combo(GUI::IContext* context, id_t id, const c8* label, @@ -101,12 +133,21 @@ namespace Luna Float4U(0.95f, 0.96f, 0.98f, 1.0f)); f32 radius = Internal::style_scalar(context, preview, "gui.combo.radius", 4.0f); f32 font_size = Internal::style_scalar(context, preview, "gui.combo.font_size", 15.0f); - draw_combo_rect(context, border, radius); - draw_combo_rect(context, background, max(radius - 1.0f, 0.0f), RectF(1.0f, 1.0f, -2.0f, -2.0f)); const c8* selected = ""; if(current_item && *current_item >= 0 && (usize)*current_item < items.size()) selected = items[(usize)*current_item]; - draw_combo_text(context, RectF(12.0f, 0.0f, -44.0f, 0.0f), selected, text_color, font_size); - draw_combo_chevron(context, text_color, open); + ComboDrawData* draw_data = Internal::allocate_frame(context); + draw_data->selected = Internal::copy_frame_string(context, selected); + draw_data->background = background; + draw_data->border = border; + draw_data->text_color = text_color; + draw_data->radius = radius; + draw_data->font_size = font_size; + draw_data->open = open; + GUI::DrawConfig draw; + draw.name = Name("gui.combo"); + draw.callback = draw_combo; + draw.userdata = draw_data; + context->set_draw_config(preview, draw); context->end_element(); if(open) @@ -123,6 +164,13 @@ namespace Luna GUI::ElementHandle popup; if(begin_popup(context, popup_id, popup_desc, &popup)) { + GUI::LayoutConfig list_layout; + list_layout.width.kind = GUI::SizeKind::percent; + list_layout.width.value = 1.0f; + GUI::ElementHandle list = begin_v_layout(context, + Internal::derived_id(id, "combo.items"), "Combo Items", list_layout); + // Every package-owned item is clipped to one non-overlapping list row. + context->set_child_paint_order_mode(list, GUI::ChildPaintOrderMode::shared); GUI::LayoutConfig item_layout; item_layout.width.kind = GUI::SizeKind::percent; item_layout.width.value = 1.0f; @@ -134,6 +182,11 @@ namespace Luna selectable(context, item_id, items[i] ? items[i] : "", current_item && *current_item == (i32)i, item_layout); } + GUI::FlexLayoutDesc list_flex; + list_flex.main_axis_gap = max(Internal::style_scalar(context, popup, + "gui.popup.gap", 4.0f), 0.0f); + list_flex.clip_children = true; + end_v_layout(context, list, list_flex); lupanic_if_failed(end_popup(context, popup, RectF(0.0f, 0.0f, width, height))); } } diff --git a/Modules/Luna/EditorGUI/Source/Disclosure.cpp b/Modules/Luna/EditorGUI/Source/Disclosure.cpp index 96d7ef51..fd251f88 100644 --- a/Modules/Luna/EditorGUI/Source/Disclosure.cpp +++ b/Modules/Luna/EditorGUI/Source/Disclosure.cpp @@ -40,7 +40,7 @@ namespace Luna } static void disclosure_line(GUI::IContext* context, const Float2U& begin, const Float2U& end, - const Float4U& color, f32 width) + const Float4U& color, f32 width, GUI::paint_order_id_t paint_order_id) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::line; @@ -50,14 +50,15 @@ namespace Luna command.rect_layout_scale = Float4U(0.0f, 0.5f, 0.0f, 0.5f); command.color = color; command.line_width = width; - context->draw(command); + context->draw(command, paint_order_id); } - static RV draw_disclosure(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_disclosure(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { DisclosureData* data = (DisclosureData*)userdata; - if(!data || !data->state) return ok; + if(!data || !data->state) return paint_order_id; GUI::InteractionState interaction = context->get_interaction_state(element.id); if(data->header || data->selected || (data->enabled && interaction.hovered)) { @@ -71,7 +72,7 @@ namespace Luna Float4U(0.16f, 0.19f, 0.24f, 1.0f)) : style_color(context, element, "gui.choice.hovered", Float4U(0.13f, 0.19f, 0.27f, 1.0f)); background.radius = 4.0f; - context->draw(background); + context->draw(background, paint_order_id); } f32 x = data->header ? 8.0f : 4.0f + 18.0f * (f32)data->indent_depth; Float4U icon = data->enabled ? style_color(context, element, "gui.disclosure.icon", @@ -86,17 +87,21 @@ namespace Luna dot.rect_layout_scale = Float4U(0.0f, 0.5f, 0.0f, 0.0f); dot.color = icon; dot.radius = 2.5f; - context->draw(dot); + context->draw(dot, paint_order_id + 1); } else if(data->state->animation > 0.5f) { - disclosure_line(context, Float2U(x + 3.0f, -2.0f), Float2U(x + 8.0f, 3.0f), icon, 1.8f); - disclosure_line(context, Float2U(x + 8.0f, 3.0f), Float2U(x + 13.0f, -2.0f), icon, 1.8f); + disclosure_line(context, Float2U(x + 3.0f, -2.0f), Float2U(x + 8.0f, 3.0f), icon, 1.8f, + paint_order_id + 1); + disclosure_line(context, Float2U(x + 8.0f, 3.0f), Float2U(x + 13.0f, -2.0f), icon, 1.8f, + paint_order_id + 1); } else { - disclosure_line(context, Float2U(x + 5.0f, -5.0f), Float2U(x + 11.0f, 0.0f), icon, 1.8f); - disclosure_line(context, Float2U(x + 11.0f, 0.0f), Float2U(x + 5.0f, 5.0f), icon, 1.8f); + disclosure_line(context, Float2U(x + 5.0f, -5.0f), Float2U(x + 11.0f, 0.0f), icon, 1.8f, + paint_order_id + 1); + disclosure_line(context, Float2U(x + 11.0f, 0.0f), Float2U(x + 5.0f, 5.0f), icon, 1.8f, + paint_order_id + 1); } GUI::DrawCommand text; text.type = GUI::DrawCommandType::text; @@ -110,8 +115,8 @@ namespace Luna "gui.text.disabled", Float4U(0.48f, 0.52f, 0.58f, 1.0f)); text.horizontal_alignment = VG::TextAlignment::begin; text.vertical_alignment = VG::TextAlignment::center; - context->draw(text); - return ok; + context->draw(text, paint_order_id + 1); + return paint_order_id + 1; } bool resolve_disclosure_action(GUI::IContext* context, DisclosureAction& action) diff --git a/Modules/Luna/EditorGUI/Source/Drag.cpp b/Modules/Luna/EditorGUI/Source/Drag.cpp index b0127ff1..a2d29dd7 100644 --- a/Modules/Luna/EditorGUI/Source/Drag.cpp +++ b/Modules/Luna/EditorGUI/Source/Drag.cpp @@ -10,6 +10,7 @@ #include #define LUNA_EDITOR_GUI_API LUNA_EXPORT #include "Internal.hpp" +#include #include #include @@ -37,11 +38,12 @@ namespace Luna return result; } - static RV draw_drag(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_drag(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { DragData* data = (DragData*)userdata; - if(!data) return ok; + if(!data) return paint_order_id; GUI::InteractionState interaction = context->get_interaction_state(element.id); GUI::DrawCommand command; command.type = GUI::DrawCommandType::rounded_rect; @@ -53,7 +55,7 @@ namespace Luna (interaction.hovered ? Float4U(0.14f, 0.21f, 0.30f, 1.0f) : Float4U(0.11f, 0.15f, 0.21f, 1.0f)))); command.radius = 4.0f; - context->draw(command); + context->draw(command, paint_order_id); c8 value[64]; if(data->float_value) snprintf(value, sizeof(value), "%.3f", *data->float_value); else snprintf(value, sizeof(value), "%d", data->int_value ? *data->int_value : 0); @@ -67,8 +69,95 @@ namespace Luna "gui.text.disabled", Float4U(0.48f, 0.52f, 0.58f, 1.0f)); command.horizontal_alignment = VG::TextAlignment::begin; command.vertical_alignment = VG::TextAlignment::center; - context->draw(command); - return ok; + context->draw(command, paint_order_id + 1); + return paint_order_id + 1; + } + + template + static bool parse_drag_text(const String& text, T& value) + { + c8* end = nullptr; + if constexpr(is_same_v) + { + f32 parsed = strtof32(text.c_str(), &end); + if(end == text.c_str() || *end || !isfinite(parsed)) return false; + value = parsed; + } + else + { + i64 parsed = strtoi64(text.c_str(), &end, 10); + if(end == text.c_str() || *end || parsed < I32_MIN || parsed > I32_MAX) + return false; + value = (i32)parsed; + } + return true; + } + + template + static void begin_drag_text_edit(GUI::IContext* context, id_t id, T value, + DragState& state) + { + c8 buffer[64]; + if constexpr(is_same_v) + { + snprintf(buffer, sizeof(buffer), "%.9g", value); + state.edit_original_float = value; + } + else + { + snprintf(buffer, sizeof(buffer), "%d", value); + state.edit_original_int = value; + } + state.edit_text = buffer; + state.dragging = false; + state.editing = true; + state.select_all = true; + context->focus_element(id); + } + + template + static bool resolve_drag_text_edit(GUI::IContext* context, id_t id, T* value, + T minimum, T maximum, DragState& state) + { + bool cancel = false; + bool accept = false; + for(const GUI::RoutedInputEvent& routed : context->get_routed_input_events(id)) + { + if(routed.event.type != GUI::InputEventType::key_down) continue; + if(routed.event.key == KeyCode::esc) cancel = true; + else if(routed.event.key == KeyCode::enter) accept = true; + } + bool changed = false; + if(cancel) + { + T original; + if constexpr(is_same_v) original = state.edit_original_float; + else original = state.edit_original_int; + if(*value != original) + { + *value = original; + changed = true; + } + } + else + { + T parsed; + if(parse_drag_text(state.edit_text, parsed)) + { + if(maximum > minimum) parsed = clamp(parsed, minimum, maximum); + if(*value != parsed) + { + *value = parsed; + changed = true; + } + } + } + if(cancel || accept || context->focused_element() != id) + { + state.editing = false; + state.select_all = false; + } + return changed; } template @@ -76,6 +165,8 @@ namespace Luna f32 speed, bool enabled, DragState* state) { if(!enabled || !value || !state) return false; + if(state->editing) + return resolve_drag_text_edit(context, id, value, minimum, maximum, *state); bool changed = false; for(const GUI::RoutedInputEvent& routed : context->get_routed_input_events(id)) { @@ -104,9 +195,22 @@ namespace Luna state->dragging = false; } } + if(context->get_interaction_state(id).double_clicked) + begin_drag_text_edit(context, id, *value, *state); return changed; } + static void select_drag_text(GUI::IContext* context, id_t id, DragState& state) + { + if(!state.select_all) return; + Ref text_state = widget_state(context, id); + text_state->selection_anchor = 0; + text_state->cursor = state.edit_text.size(); + text_state->selecting = false; + text_state->blink_time = 0.0f; + state.select_all = false; + } + bool resolve_drag_float_action(GUI::IContext* context, DragFloatAction& action) { return resolve_drag(context, action.id, action.value, action.minimum, action.maximum, @@ -122,22 +226,32 @@ namespace Luna static GUI::ElementHandle drag_float_scalar(GUI::IContext* context, id_t id, f32* value, f32 minimum, f32 maximum, const GUI::LayoutConfig& layout, const DragDesc& desc) { - GUI::ElementHandle element = begin_element(context, id, "Float Drag", layout); - set_interactable(context, element, desc.enabled); - GUI::LayoutCallbackConfig callbacks; - callbacks.algorithm = Name("gui.drag"); - callbacks.measure_callback = measure_drag; - context->set_layout_callback_config(element, callbacks); - DragData* data = allocate_frame(context); - data->float_value = value; - data->enabled = desc.enabled; - GUI::DrawConfig draw; - draw.name = Name("gui.drag"); - draw.callback = draw_drag; - draw.userdata = data; - context->set_draw_config(element, draw); - context->end_element(); Ref state = widget_state(context, id); + if(!desc.enabled) state->editing = false; + GUI::ElementHandle element; + if(state->editing) + { + element = input_text(context, id, state->edit_text, layout); + select_drag_text(context, id, *state); + } + else + { + element = begin_element(context, id, "Float Drag", layout); + set_interactable(context, element, desc.enabled); + GUI::LayoutCallbackConfig callbacks; + callbacks.algorithm = Name("gui.drag"); + callbacks.measure_callback = measure_drag; + context->set_layout_callback_config(element, callbacks); + DragData* data = allocate_frame(context); + data->float_value = value; + data->enabled = desc.enabled; + GUI::DrawConfig draw; + draw.name = Name("gui.drag"); + draw.callback = draw_drag; + draw.userdata = data; + context->set_draw_config(element, draw); + context->end_element(); + } DragFloatAction* action = allocate_frame(context); action->id = id; action->value = value; action->minimum = minimum; action->maximum = maximum; action->speed = desc.speed; action->enabled = desc.enabled; action->state = state.get(); @@ -148,22 +262,32 @@ namespace Luna static GUI::ElementHandle drag_int_scalar(GUI::IContext* context, id_t id, i32* value, i32 minimum, i32 maximum, const GUI::LayoutConfig& layout, const DragDesc& desc) { - GUI::ElementHandle element = begin_element(context, id, "Integer Drag", layout); - set_interactable(context, element, desc.enabled); - GUI::LayoutCallbackConfig callbacks; - callbacks.algorithm = Name("gui.drag"); - callbacks.measure_callback = measure_drag; - context->set_layout_callback_config(element, callbacks); - DragData* data = allocate_frame(context); - data->int_value = value; - data->enabled = desc.enabled; - GUI::DrawConfig draw; - draw.name = Name("gui.drag"); - draw.callback = draw_drag; - draw.userdata = data; - context->set_draw_config(element, draw); - context->end_element(); Ref state = widget_state(context, id); + if(!desc.enabled) state->editing = false; + GUI::ElementHandle element; + if(state->editing) + { + element = input_text(context, id, state->edit_text, layout); + select_drag_text(context, id, *state); + } + else + { + element = begin_element(context, id, "Integer Drag", layout); + set_interactable(context, element, desc.enabled); + GUI::LayoutCallbackConfig callbacks; + callbacks.algorithm = Name("gui.drag"); + callbacks.measure_callback = measure_drag; + context->set_layout_callback_config(element, callbacks); + DragData* data = allocate_frame(context); + data->int_value = value; + data->enabled = desc.enabled; + GUI::DrawConfig draw; + draw.name = Name("gui.drag"); + draw.callback = draw_drag; + draw.userdata = data; + context->set_draw_config(element, draw); + context->end_element(); + } DragIntAction* action = allocate_frame(context); action->id = id; action->value = value; action->minimum = minimum; action->maximum = maximum; action->speed = desc.speed; action->enabled = desc.enabled; action->state = state.get(); @@ -176,6 +300,8 @@ namespace Luna const GUI::LayoutConfig& layout, Function&& function) { GUI::ElementHandle group = begin_h_layout(context, id, "Vector Drag", layout); + // Child clipping below bounds every scalar control to its non-overlapping flex cell. + context->set_child_paint_order_mode(group, GUI::ChildPaintOrderMode::shared); for(u32 i = 0; i < count; ++i) { GUI::LayoutConfig child; @@ -188,6 +314,7 @@ namespace Luna } GUI::FlexLayoutDesc flex; flex.main_axis_gap = 6.0f; + flex.clip_children = true; end_h_layout(context, group, flex); return group; } diff --git a/Modules/Luna/EditorGUI/Source/Icons.cpp b/Modules/Luna/EditorGUI/Source/Icons.cpp index abace395..5112e536 100644 --- a/Modules/Luna/EditorGUI/Source/Icons.cpp +++ b/Modules/Luna/EditorGUI/Source/Icons.cpp @@ -166,13 +166,14 @@ namespace Luna return result; } - static RV draw_icon(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_icon(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { IconData* data = (IconData*)userdata; - if(!data || !g_icon_shape_buffer) return ok; + if(!data || !g_icon_shape_buffer) return paint_order_id; const IconVariantData* variant = find_variant(data->value, data->desc.weight, true); - if(!variant) return ok; + if(!variant) return paint_order_id; Float4U tint = data->desc.tint; if(tint.w < 0.0f) @@ -182,7 +183,7 @@ namespace Luna } RectF rect = get_item_rect(context, element); f32 side = min(rect.width, rect.height); - if(side <= 0.0f || tint.w <= 0.0f) return ok; + if(side <= 0.0f || tint.w <= 0.0f) return paint_order_id; RectF local_rect((rect.width - side) * 0.5f, (rect.height - side) * 0.5f, side, side); for(u32 i = 0; i < variant->num_layers; ++i) { @@ -198,9 +199,9 @@ namespace Luna command.shape.first_command = layer.first_float; command.shape.num_commands = layer.num_floats; command.shape.bounds = RectF(0.0f, 0.0f, 256.0f, 256.0f); - context->draw(command); + context->draw(command, paint_order_id); } - return ok; + return paint_order_id; } } diff --git a/Modules/Luna/EditorGUI/Source/InputText.cpp b/Modules/Luna/EditorGUI/Source/InputText.cpp index 9c19fcb2..0bc241dc 100644 --- a/Modules/Luna/EditorGUI/Source/InputText.cpp +++ b/Modules/Luna/EditorGUI/Source/InputText.cpp @@ -134,6 +134,47 @@ namespace Luna return test_flags(value, flag); } + static bool navigate_input_text(GUI::IContext*, + const GUI::NavigationRequest& request, void* userdata) + { + TextInputData* data = (TextInputData*)userdata; + if(!data || !data->value || !data->state || + request.event_type != GUI::InputEventType::navigation_dpad) + { + return false; + } + String& value = *data->value; + TextInputState& state = *data->state; + state.cursor = clamp_cursor(value, state.cursor); + if(!has_modifier(request.event.modifiers, GUI::KeyModifierFlag::shift)) + { + state.selection_anchor = USIZE_MAX; + } + else if(state.selection_anchor == USIZE_MAX) + { + state.selection_anchor = state.cursor; + } + switch(request.direction) + { + case GUI::NavigationDirection::left: + state.cursor = previous_cursor(value, state.cursor); + break; + case GUI::NavigationDirection::right: + state.cursor = next_cursor(value, state.cursor); + break; + case GUI::NavigationDirection::up: + state.cursor = 0; + break; + case GUI::NavigationDirection::down: + state.cursor = value.size(); + break; + default: + return false; + } + state.blink_time = 0.0f; + return true; + } + static String filter_single_line_text(const String& value) { String result; @@ -169,11 +210,12 @@ namespace Luna return result; } - static RV draw_input_text(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_input_text(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { TextInputData* data = (TextInputData*)userdata; - if(!data || !data->value || !data->state) return ok; + if(!data || !data->value || !data->state) return paint_order_id; bool focused = context->focused_element() == element.id; f32 radius = style_scalar(context, element, "gui.input.radius", 4.0f); f32 padding = style_scalar(context, element, "gui.input.padding_x", 8.0f); @@ -185,13 +227,13 @@ namespace Luna command.color = style_color(context, element, focused ? "gui.input.border_focused" : "gui.input.border", focused ? Float4U(0.12f, 0.55f, 0.86f, 1.0f) : Float4U(0.20f, 0.27f, 0.36f, 1.0f)); command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); command.rect = RectF(1.0f, 1.0f, -2.0f, -2.0f); command.color = style_color(context, element, focused ? "gui.input.background_focused" : "gui.input.background", focused ? Float4U(0.11f, 0.15f, 0.21f, 1.0f) : Float4U(0.08f, 0.10f, 0.13f, 1.0f)); command.radius = max(radius - 1.0f, 0.0f); - context->draw(command); + context->draw(command, paint_order_id + 1); command = GUI::DrawCommand(); command.type = GUI::DrawCommandType::shadow; command.rect_reference = GUI::DrawCommandRectReference::element; @@ -202,7 +244,7 @@ namespace Luna command.shadow.offset = Float2U(1.5f, 1.5f); command.shadow.softness = 2.5f; command.shadow.mode = GUI::ShadowMode::inner; - context->draw(command); + context->draw(command, paint_order_id + 2); usize selection_begin = 0; usize selection_end = 0; @@ -215,7 +257,7 @@ namespace Luna command.rect = RectF(begin_x, 4.0f, end_x - begin_x, -8.0f); command.color = style_color(context, element, "gui.input.selection", Float4U(0.16f, 0.42f, 0.70f, 0.75f)); - context->draw(command); + context->draw(command, paint_order_id + 3); } command = GUI::DrawCommand(); @@ -229,7 +271,7 @@ namespace Luna style_color(context, element, "gui.text.disabled", Float4U(0.48f, 0.52f, 0.58f, 1.0f)); if(data->value->empty() && data->placeholder) command.color.w *= 0.55f; command.vertical_alignment = VG::TextAlignment::center; - context->draw(command); + context->draw(command, paint_order_id + 4); if(focused && !data->read_only && fmod(data->state->blink_time, 1.0f) < 0.55f) { @@ -241,9 +283,10 @@ namespace Luna command.point1 = Float2U(x, -5.0f); command.rect_layout_scale = Float4U(0.0f, 0.0f, 0.0f, 1.0f); command.color = style_color(context, element, "gui.input.cursor", Float4U(0.80f, 0.92f, 1.0f, 1.0f)); - context->draw(command); + context->draw(command, paint_order_id + 5); + return paint_order_id + 5; } - return ok; + return paint_order_id + 4; } bool resolve_input_text_action(GUI::IContext* context, TextInputAction& action) @@ -358,13 +401,6 @@ namespace Luna } else changed = true; } - else if(event.key == KeyCode::left || event.key == KeyCode::right) - { - if(!has_modifier(event.modifiers, GUI::KeyModifierFlag::shift)) state.selection_anchor = USIZE_MAX; - else if(state.selection_anchor == USIZE_MAX) state.selection_anchor = state.cursor; - state.cursor = event.key == KeyCode::left ? previous_cursor(value, state.cursor) : - next_cursor(value, state.cursor); - } else if(event.key == KeyCode::enter || event.key == KeyCode::esc) { context->focus_element(0); @@ -407,6 +443,14 @@ namespace Luna data->read_only = desc.read_only; data->font = Internal::resolve_font(context, element); data->state = state.get(); + GUI::NavigationConfig navigation; + navigation.left = GUI::NavigationMode::callback; + navigation.right = GUI::NavigationMode::callback; + navigation.up = GUI::NavigationMode::callback; + navigation.down = GUI::NavigationMode::callback; + navigation.callback = Internal::navigate_input_text; + navigation.userdata = data; + context->set_navigation_config(element, navigation); GUI::LayoutCallbackConfig callbacks; callbacks.algorithm = Name("gui.input_text"); callbacks.measure_callback = Internal::measure_input_text; diff --git a/Modules/Luna/EditorGUI/Source/Internal.cpp b/Modules/Luna/EditorGUI/Source/Internal.cpp index c99798b6..0b16fd85 100644 --- a/Modules/Luna/EditorGUI/Source/Internal.cpp +++ b/Modules/Luna/EditorGUI/Source/Internal.cpp @@ -161,7 +161,8 @@ namespace Luna RV draw_rounded_rect_effects(GUI::IContext* context, const GUI::ElementHandle& element, const RectF& rect, - const Float4U& rect_layout_scale, f32 radius, Span effects) + const Float4U& rect_layout_scale, f32 radius, Span effects, + GUI::paint_order_id_t paint_order_id) { if(!context || effects.empty()) return E_BAD_ARGUMENTS; const GUI::Element* element_data = context->get_element(element.index); @@ -213,7 +214,7 @@ namespace Luna command.rect_layout_scale = rect_layout_scale; command.sdf.shape = shape.get(); command.sdf.color = color.get(); - context->draw(command); + context->draw(command, paint_order_id); return ok; } diff --git a/Modules/Luna/EditorGUI/Source/Internal.hpp b/Modules/Luna/EditorGUI/Source/Internal.hpp index 751d7fd5..ae858f0d 100644 --- a/Modules/Luna/EditorGUI/Source/Internal.hpp +++ b/Modules/Luna/EditorGUI/Source/Internal.hpp @@ -95,7 +95,8 @@ namespace Luna RV draw_rounded_rect_effects(GUI::IContext* context, const GUI::ElementHandle& element, const RectF& rect, - const Float4U& rect_layout_scale, f32 radius, Span effects); + const Float4U& rect_layout_scale, f32 radius, Span effects, + GUI::paint_order_id_t paint_order_id); VG::TextAlignment text_alignment(TextAlignment alignment); f32 smooth_step(f32 current, f32 target, f32 speed, f32 delta_time); diff --git a/Modules/Luna/EditorGUI/Source/Layouts.cpp b/Modules/Luna/EditorGUI/Source/Layouts.cpp index f91d3b74..63aac4e0 100644 --- a/Modules/Luna/EditorGUI/Source/Layouts.cpp +++ b/Modules/Luna/EditorGUI/Source/Layouts.cpp @@ -21,7 +21,9 @@ namespace Luna const GUI::LayoutConfig& layout) { luassert(context && id); - return begin_element(context, id, label ? label : "Layout", layout); + GUI::ElementHandle element = begin_element(context, id, label ? label : "Layout", layout); + context->set_child_paint_order_mode(element, GUI::ChildPaintOrderMode::shared); + return element; } void set_flex_layout(GUI::IContext* context, const GUI::ElementHandle& element, diff --git a/Modules/Luna/EditorGUI/Source/Menus.cpp b/Modules/Luna/EditorGUI/Source/Menus.cpp index f99e20d0..801ec23e 100644 --- a/Modules/Luna/EditorGUI/Source/Menus.cpp +++ b/Modules/Luna/EditorGUI/Source/Menus.cpp @@ -16,18 +16,34 @@ namespace Luna { namespace EditorGUI { - static void draw_menu_rect(GUI::IContext* context, const Float4U& color, f32 radius = 0.0f) + struct MenuItemDrawData + { + c8* label = nullptr; + c8* shortcut = nullptr; + bool selected = false; + bool enabled = true; + bool top_level = false; + bool submenu = false; + bool open = false; + bool hovered = false; + bool active = false; + bool draw_text = true; + }; + + static void draw_menu_rect(GUI::IContext* context, const Float4U& color, + GUI::paint_order_id_t paint_order_id, f32 radius = 0.0f) { GUI::DrawCommand command; command.type = radius > 0.0f ? GUI::DrawCommandType::rounded_rect : GUI::DrawCommandType::rect; command.rect_reference = GUI::DrawCommandRectReference::element; command.color = color; command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); } static void draw_menu_text(GUI::IContext* context, const RectF& rect, const c8* value, - const Float4U& color, f32 size, VG::TextAlignment alignment = VG::TextAlignment::begin) + const Float4U& color, f32 size, GUI::paint_order_id_t paint_order_id, + VG::TextAlignment alignment = VG::TextAlignment::begin) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::text; @@ -38,7 +54,7 @@ namespace Luna command.horizontal_alignment = alignment; command.vertical_alignment = VG::TextAlignment::center; command.text = value ? value : ""; - context->draw(command); + context->draw(command, paint_order_id); } static GUI::LayoutConfig menu_item_layout(GUI::IContext* context, @@ -64,30 +80,60 @@ namespace Luna return result; } - static void draw_menu_item(GUI::IContext* context, const GUI::ElementHandle& element, - const c8* label, const c8* shortcut, bool selected, bool enabled, bool top_level, - bool submenu, bool open, const GUI::InteractionState& interaction) + static R draw_menu_item(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, GUI::paint_order_id_t paint_order_id, + void* userdata) { - if(enabled && (open || interaction.hovered || interaction.active)) + MenuItemDrawData* data = (MenuItemDrawData*)userdata; + if(!data) return paint_order_id; + if(data->enabled && (data->open || data->hovered || data->active)) { draw_menu_rect(context, Internal::style_color(context, element, - interaction.active || open ? "gui.menu_item.active" : "gui.menu_item.hovered", - interaction.active || open ? Float4U(0.18f, 0.36f, 0.62f, 1.0f) : + data->active || data->open ? "gui.menu_item.active" : "gui.menu_item.hovered", + data->active || data->open ? Float4U(0.18f, 0.36f, 0.62f, 1.0f) : Float4U(0.14f, 0.22f, 0.32f, 1.0f)), - Internal::style_scalar(context, element, "gui.menu_item.radius", 3.0f)); + paint_order_id, Internal::style_scalar(context, element, "gui.menu_item.radius", 3.0f)); } + if(!data->draw_text) return paint_order_id; Float4U color = Internal::style_color(context, element, - enabled ? "gui.menu_item.text" : "gui.menu_item.text_disabled", - enabled ? Float4U(0.95f, 0.96f, 0.98f, 1.0f) : Float4U(0.50f, 0.56f, 0.64f, 1.0f)); + data->enabled ? "gui.menu_item.text" : "gui.menu_item.text_disabled", + data->enabled ? Float4U(0.95f, 0.96f, 0.98f, 1.0f) : Float4U(0.50f, 0.56f, 0.64f, 1.0f)); f32 size = Internal::style_scalar(context, element, "gui.menu_item.font_size", 15.0f); - f32 left = top_level ? 10.0f : 30.0f; - draw_menu_text(context, RectF(left, 0.0f, top_level ? -20.0f : -94.0f, 0.0f), label, color, size); - if(selected && !top_level) draw_menu_text(context, RectF(6.0f, 0.0f, 20.0f, 0.0f), "v", color, size, + f32 left = data->top_level ? 10.0f : 30.0f; + draw_menu_text(context, RectF(left, 0.0f, data->top_level ? -20.0f : -94.0f, 0.0f), + data->label, color, size, paint_order_id + 1); + if(data->selected && !data->top_level) draw_menu_text(context, + RectF(6.0f, 0.0f, 20.0f, 0.0f), "v", color, size, paint_order_id + 1, VG::TextAlignment::center); - if(submenu && !top_level) draw_menu_text(context, RectF(-26.0f, 0.0f, 20.0f, 0.0f), ">", color, size, + if(data->submenu && !data->top_level) draw_menu_text(context, + RectF(-26.0f, 0.0f, 20.0f, 0.0f), ">", color, size, paint_order_id + 1, VG::TextAlignment::center); - else if(shortcut && shortcut[0] && !top_level) draw_menu_text(context, - RectF(-88.0f, 0.0f, 78.0f, 0.0f), shortcut, color, size - 1.0f, VG::TextAlignment::end); + else if(data->shortcut && data->shortcut[0] && !data->top_level) draw_menu_text(context, + RectF(-88.0f, 0.0f, 78.0f, 0.0f), data->shortcut, color, size - 1.0f, + paint_order_id + 1, VG::TextAlignment::end); + return paint_order_id + 1; + } + + static void configure_menu_item_draw(GUI::IContext* context, const GUI::ElementHandle& element, + const c8* label, const c8* shortcut, bool selected, bool enabled, bool top_level, + bool submenu, bool open, const GUI::InteractionState& interaction, bool draw_text = true) + { + MenuItemDrawData* data = Internal::allocate_frame(context); + data->label = Internal::copy_frame_string(context, label); + data->shortcut = shortcut ? Internal::copy_frame_string(context, shortcut) : nullptr; + data->selected = selected; + data->enabled = enabled; + data->top_level = top_level; + data->submenu = submenu; + data->open = open; + data->hovered = interaction.hovered; + data->active = interaction.active; + data->draw_text = draw_text; + GUI::DrawConfig draw; + draw.name = Name("gui.menu_item"); + draw.callback = draw_menu_item; + draw.userdata = data; + context->set_draw_config(element, draw); } static void close_menu_stack(GUI::IContext* context) @@ -118,14 +164,8 @@ namespace Luna GUI::ElementHandle item = Internal::begin_element(context, id, label ? label : "Menu Item", resolved_layout); Internal::set_interactable(context, item, desc.enabled); - if(desc.enabled && (interaction.hovered || interaction.active)) - { - draw_menu_rect(context, Internal::style_color(context, item, - interaction.active ? "gui.menu_item.active" : "gui.menu_item.hovered", - interaction.active ? Float4U(0.18f, 0.36f, 0.62f, 1.0f) : - Float4U(0.14f, 0.22f, 0.32f, 1.0f)), - Internal::style_scalar(context, item, "gui.menu_item.radius", 3.0f)); - } + configure_menu_item_draw(context, item, nullptr, nullptr, false, desc.enabled, + false, false, false, interaction, false); GUI::FlexLayoutDesc* flex = Internal::allocate_frame(context); flex->axis = GUI::LayoutAxis::x; flex->main_axis_gap = Internal::style_scalar(context, item, "gui.control.content_gap", 8.0f); @@ -151,8 +191,15 @@ namespace Luna } GUI::ElementHandle bar = Internal::begin_element(context, id, label ? label : "Menu Bar", resolved_layout); - draw_menu_rect(context, Internal::style_color(context, bar, "gui.menu_bar.background", - Float4U(0.08f, 0.10f, 0.13f, 1.0f))); + context->set_child_paint_order_mode(bar, GUI::ChildPaintOrderMode::shared); + GUI::ElementVisualEffect visual; + visual.command.type = GUI::DrawCommandType::rect; + visual.command.rect_reference = GUI::DrawCommandRectReference::element; + visual.command.color = Internal::style_color(context, bar, "gui.menu_bar.background", + Float4U(0.08f, 0.10f, 0.13f, 1.0f)); + GUI::ElementVisualConfig visual_config; + visual_config.before_children = Span(&visual, 1); + lupanic_if_failed(context->set_element_visual_config(bar, visual_config)); Internal::MenuBarBuildScope scope; scope.root = bar; scope.gap = desc.gap >= 0.0f ? desc.gap : @@ -236,7 +283,8 @@ namespace Luna GUI::ElementHandle item = Internal::begin_element(context, id, label ? label : "Menu", menu_item_layout(context, layout, top_level, label)); Internal::set_interactable(context, item, desc.enabled); - draw_menu_item(context, item, label, nullptr, false, desc.enabled, top_level, true, open, interaction); + configure_menu_item_draw(context, item, label, nullptr, false, desc.enabled, top_level, + true, open, interaction); context->end_element(); if(out_handle) *out_handle = item; if(!open) return false; @@ -283,7 +331,8 @@ namespace Luna GUI::ElementHandle item = Internal::begin_element(context, id, label ? label : "Menu Item", menu_item_layout(context, layout, false, label)); Internal::set_interactable(context, item, desc.enabled); - draw_menu_item(context, item, label, desc.shortcut, selected, desc.enabled, false, false, false, interaction); + configure_menu_item_draw(context, item, label, desc.shortcut, selected, desc.enabled, + false, false, false, interaction); context->end_element(); return item; } @@ -311,14 +360,16 @@ namespace Luna "gui.control.small_height", 28.0f) * 0.32f; } GUI::ElementHandle separator = Internal::begin_element(context, id, "Menu Separator", resolved); - GUI::DrawCommand line; - line.type = GUI::DrawCommandType::rect; - line.rect_reference = GUI::DrawCommandRectReference::element; - line.rect = RectF(8.0f, 0.0f, -16.0f, 1.0f); - line.rect_layout_scale = Float4U(0.0f, 0.5f, 1.0f, 0.0f); - line.color = Internal::style_color(context, separator, "gui.menu_separator.color", - Float4U(0.24f, 0.30f, 0.38f, 1.0f)); - context->draw(line); + GUI::ElementVisualEffect visual; + visual.command.type = GUI::DrawCommandType::rect; + visual.command.rect_reference = GUI::DrawCommandRectReference::element; + visual.command.rect = RectF(8.0f, 0.0f, -16.0f, 1.0f); + visual.command.rect_layout_scale = Float4U(0.0f, 0.5f, 1.0f, 0.0f); + visual.command.color = Internal::style_color(context, separator, + "gui.menu_separator.color", Float4U(0.24f, 0.30f, 0.38f, 1.0f)); + GUI::ElementVisualConfig visual_config; + visual_config.before_children = Span(&visual, 1); + lupanic_if_failed(context->set_element_visual_config(separator, visual_config)); context->end_element(); return separator; } diff --git a/Modules/Luna/EditorGUI/Source/Popups.cpp b/Modules/Luna/EditorGUI/Source/Popups.cpp index 24214571..6e5ba6e9 100644 --- a/Modules/Luna/EditorGUI/Source/Popups.cpp +++ b/Modules/Luna/EditorGUI/Source/Popups.cpp @@ -40,8 +40,9 @@ namespace Luna return result; } - static RV draw_panel(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_panel(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { PopupPanelDrawData* data = (PopupPanelDrawData*)userdata; if(!data || !data->prefix) return E_BAD_ARGUMENTS; @@ -65,10 +66,11 @@ namespace Luna outer_effects[0].shadow_desc.softness = shadow_softness * 3.0f; outer_effects[1].color = border; if(RV result = draw_rounded_rect_effects(context, element, RectF(), Float4U(), radius, - Span(outer_effects, 2)); failed(result)) + Span(outer_effects, 2), paint_order_id); failed(result)) { - return result; + return result.errcode(); } + GUI::paint_order_id_t fill_paint_order_id = paint_order_id + 1; if(draw_backdrop) { GUI::DrawCommand backdrop; @@ -77,14 +79,19 @@ namespace Luna backdrop.rect = RectF(1.0f, 1.0f, -2.0f, -2.0f); backdrop.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); backdrop.radius = max(radius - 1.0f, 0.0f); - context->draw(backdrop); + context->draw(backdrop, paint_order_id + 1); + fill_paint_order_id = paint_order_id + 2; } RoundedRectEffect fill; fill.color = background; - return draw_rounded_rect_effects(context, element, + if(RV result = draw_rounded_rect_effects(context, element, RectF(1.0f, 1.0f, -2.0f, -2.0f), Float4U(), max(radius - 1.0f, 0.0f), - Span(&fill, 1)); + Span(&fill, 1), fill_paint_order_id); failed(result)) + { + return result.errcode(); + } + return fill_paint_order_id; } static void set_panel_draw_config(GUI::IContext* context, diff --git a/Modules/Luna/EditorGUI/Source/Primitives.cpp b/Modules/Luna/EditorGUI/Source/Primitives.cpp index 19a4356d..f4ad78dd 100644 --- a/Modules/Luna/EditorGUI/Source/Primitives.cpp +++ b/Modules/Luna/EditorGUI/Source/Primitives.cpp @@ -122,32 +122,9 @@ namespace Luna return result; } - static RV draw_text(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) - { - TextData* data = (TextData*)userdata; - if(!data) - { - return ok; - } - GUI::DrawCommand command; - command.type = GUI::DrawCommandType::text; - command.rect_reference = GUI::DrawCommandRectReference::element; - command.rect = RectF(0.0f, 0.0f, 0.0f, 0.0f); - command.text = data->text ? data->text : ""; - command.font = resolve_font_id(context, element, data->desc); - command.font_size = resolve_font_size(context, element, data->desc); - command.color = resolve_text_color(context, element, data->desc); - command.horizontal_alignment = text_alignment(data->desc.horizontal_alignment); - command.vertical_alignment = text_alignment(data->desc.vertical_alignment); - context->draw(command); - return ok; - } - struct ImageData { RHI::ITexture* texture = nullptr; - ImageDesc desc; }; static GUI::MeasureResult measure_image(GUI::IContext*, const GUI::ElementHandle&, @@ -163,34 +140,9 @@ namespace Luna return result; } - static RV draw_image(GUI::IContext* context, const GUI::ElementHandle&, - GUI::DrawPhase, void* userdata) - { - ImageData* data = (ImageData*)userdata; - if(!data || !data->texture) - { - return ok; - } - GUI::DrawCommand command; - command.type = GUI::DrawCommandType::image; - command.rect_reference = GUI::DrawCommandRectReference::element; - command.texture = data->texture; - command.color = data->desc.tint; - command.min_texcoord = data->desc.min_texcoord; - command.max_texcoord = data->desc.max_texcoord; - if(test_flags(data->desc.flags, ImageFlag::flip_y)) - { - swap(command.min_texcoord.y, command.max_texcoord.y); - } - command.nearest_sampler = test_flags(data->desc.flags, ImageFlag::nearest); - context->draw(command); - return ok; - } - struct ShapeData { GUI::ShapeDesc shape; - ShapeWidgetDesc desc; }; static GUI::MeasureResult measure_shape(GUI::IContext*, const GUI::ElementHandle&, @@ -206,20 +158,6 @@ namespace Luna return result; } - static RV draw_shape(GUI::IContext* context, const GUI::ElementHandle&, - GUI::DrawPhase, void* userdata) - { - ShapeData* data = (ShapeData*)userdata; - if(!data || !data->shape.buffer || !data->shape.num_commands) return ok; - GUI::DrawCommand command; - command.type = GUI::DrawCommandType::shape; - command.rect_reference = GUI::DrawCommandRectReference::element; - command.color = data->desc.tint; - command.shape = data->shape; - context->draw(command); - return ok; - } - struct ProgressData { f32 fraction = 0.0f; @@ -238,13 +176,14 @@ namespace Luna return result; } - static RV draw_progress(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_progress(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { ProgressData* data = (ProgressData*)userdata; if(!data) { - return ok; + return paint_order_id; } f32 radius = style_scalar(context, element, "gui.progress.radius", 4.0f); GUI::DrawCommand command; @@ -252,7 +191,7 @@ namespace Luna command.rect_reference = GUI::DrawCommandRectReference::element; command.color = style_color(context, element, "gui.progress.border", Float4U(0.24f, 0.29f, 0.35f, 1.0f)); command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); const RectF inner_rect(1.0f, 1.0f, -2.0f, -2.0f); const f32 inner_radius = max(radius - 1.0f, 0.0f); const f32 softness = style_scalar(context, element, "gui.shadow.softness", 5.0f); @@ -272,10 +211,12 @@ namespace Luna track_effects[2].shadow_desc.softness = softness * 0.4f; track_effects[2].shadow_desc.mode = GUI::ShadowMode::inner; if(RV result = draw_rounded_rect_effects(context, element, inner_rect, Float4U(), - inner_radius, Span(track_effects, 3)); failed(result)) + inner_radius, Span(track_effects, 3), + paint_order_id + 1); failed(result)) { - return result; + return result.errcode(); } + GUI::paint_order_id_t max_paint_order_id = paint_order_id + 1; if(data->fraction > 0.0f) { RectF fill_rect(1.0f, 1.0f, -2.0f * data->fraction, -2.0f); @@ -292,10 +233,12 @@ namespace Luna fill_effects[1].shadow_desc.softness = 1.0f; fill_effects[1].shadow_desc.mode = GUI::ShadowMode::inner; if(RV result = draw_rounded_rect_effects(context, element, fill_rect, fill_scale, - inner_radius, Span(fill_effects, 2)); failed(result)) + inner_radius, Span(fill_effects, 2), + paint_order_id + 2); failed(result)) { - return result; + return result.errcode(); } + max_paint_order_id = paint_order_id + 2; } if(data->show_overlay) { @@ -308,9 +251,10 @@ namespace Luna text_command.horizontal_alignment = VG::TextAlignment::center; text_command.vertical_alignment = VG::TextAlignment::center; text_command.text = data->overlay ? data->overlay : ""; - context->draw(text_command); + context->draw(text_command, paint_order_id + 3); + max_paint_order_id = paint_order_id + 3; } - return ok; + return max_paint_order_id; } } @@ -326,11 +270,18 @@ namespace Luna layout_callbacks.measure_callback = Internal::measure_text; layout_callbacks.userdata = data; context->set_layout_callback_config(element, layout_callbacks); - GUI::DrawConfig draw; - draw.name = Name("gui.text"); - draw.callback = Internal::draw_text; - draw.userdata = data; - context->set_draw_config(element, draw); + GUI::ElementVisualEffect visual; + visual.command.type = GUI::DrawCommandType::text; + visual.command.rect_reference = GUI::DrawCommandRectReference::element; + visual.command.text = data->text ? data->text : ""; + visual.command.font = Internal::resolve_font_id(context, element, desc); + visual.command.font_size = Internal::resolve_font_size(context, element, desc); + visual.command.color = Internal::resolve_text_color(context, element, desc); + visual.command.horizontal_alignment = Internal::text_alignment(desc.horizontal_alignment); + visual.command.vertical_alignment = Internal::text_alignment(desc.vertical_alignment); + GUI::ElementVisualConfig visual_config; + visual_config.before_children = Span(&visual, 1); + lupanic_if_failed(context->set_element_visual_config(element, visual_config)); context->end_element(); return element; } @@ -341,17 +292,29 @@ namespace Luna GUI::ElementHandle element = Internal::begin_element(context, id, "Image", layout); Internal::ImageData* data = Internal::allocate_frame(context); data->texture = texture; - data->desc = desc; GUI::LayoutCallbackConfig callbacks; callbacks.algorithm = Name("gui.image"); callbacks.measure_callback = Internal::measure_image; callbacks.userdata = data; context->set_layout_callback_config(element, callbacks); - GUI::DrawConfig draw; - draw.name = Name("gui.image"); - draw.callback = Internal::draw_image; - draw.userdata = data; - context->set_draw_config(element, draw); + if(texture) + { + GUI::ElementVisualEffect visual; + visual.command.type = GUI::DrawCommandType::image; + visual.command.rect_reference = GUI::DrawCommandRectReference::element; + visual.command.texture = texture; + visual.command.color = desc.tint; + visual.command.min_texcoord = desc.min_texcoord; + visual.command.max_texcoord = desc.max_texcoord; + if(test_flags(desc.flags, ImageFlag::flip_y)) + { + swap(visual.command.min_texcoord.y, visual.command.max_texcoord.y); + } + visual.command.nearest_sampler = test_flags(desc.flags, ImageFlag::nearest); + GUI::ElementVisualConfig visual_config; + visual_config.before_children = Span(&visual, 1); + lupanic_if_failed(context->set_element_visual_config(element, visual_config)); + } context->end_element(); return element; } @@ -362,17 +325,22 @@ namespace Luna GUI::ElementHandle element = Internal::begin_element(context, id, "Shape", layout); Internal::ShapeData* data = Internal::allocate_frame(context); data->shape = value; - data->desc = desc; GUI::LayoutCallbackConfig callbacks; callbacks.algorithm = Name("gui.shape"); callbacks.measure_callback = Internal::measure_shape; callbacks.userdata = data; context->set_layout_callback_config(element, callbacks); - GUI::DrawConfig draw; - draw.name = Name("gui.shape"); - draw.callback = Internal::draw_shape; - draw.userdata = data; - context->set_draw_config(element, draw); + if(value.buffer && value.num_commands) + { + GUI::ElementVisualEffect visual; + visual.command.type = GUI::DrawCommandType::shape; + visual.command.rect_reference = GUI::DrawCommandRectReference::element; + visual.command.color = desc.tint; + visual.command.shape = value; + GUI::ElementVisualConfig visual_config; + visual_config.before_children = Span(&visual, 1); + lupanic_if_failed(context->set_element_visual_config(element, visual_config)); + } context->end_element(); return element; } diff --git a/Modules/Luna/EditorGUI/Source/ScrollView.cpp b/Modules/Luna/EditorGUI/Source/ScrollView.cpp index 3e871677..aea124f6 100644 --- a/Modules/Luna/EditorGUI/Source/ScrollView.cpp +++ b/Modules/Luna/EditorGUI/Source/ScrollView.cpp @@ -58,8 +58,16 @@ namespace Luna } f32 right_inset = action->desc.scrollbar_mode == ScrollBarMode::dynamic_overlay ? 2.0f : 0.0f; f32 bottom_inset = right_inset; - for(u32 child_index = content_index == GUI::INVALID_ELEMENT ? GUI::INVALID_ELEMENT : - context->get_element(content_index)->next_sibling; + const GUI::Element* scrollbar_group = context->find_element(action->scrollbar_group_id); + if(scrollbar_group) + { + GUI::LayoutResult group_result; + group_result.rect = rect; + group_result.clip_rect = viewport->layout_result.clip_rect; + group_result.content_size = Float2U(rect.width, rect.height); + context->set_layout_result(context->find_element_handle(action->scrollbar_group_id), group_result); + } + for(u32 child_index = scrollbar_group ? scrollbar_group->first_child : GUI::INVALID_ELEMENT; child_index != GUI::INVALID_ELEMENT;) { const GUI::Element* child = context->get_element(child_index); @@ -90,21 +98,22 @@ namespace Luna return ok; } - static RV draw_scrollbar(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_scrollbar(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { ScrollbarData* data = (ScrollbarData*)userdata; - if(!data || !data->state) return ok; + if(!data || !data->state) return paint_order_id; const GUI::Element* viewport = context->find_element(data->viewport_id); const GUI::Element* bar = context->find_element(element.id); - if(!viewport || !bar) return ok; + if(!viewport || !bar) return paint_order_id; f32 visibility = data->mode == ScrollBarMode::always_visible ? 1.0f : data->state->visibility; Float2U viewport_size(viewport->layout_result.rect.width - viewport->layout.padding.x - viewport->layout.padding.z, viewport->layout_result.rect.height - viewport->layout.padding.y - viewport->layout.padding.w); f32 content = data->vertical ? viewport->layout_result.content_size.y : viewport->layout_result.content_size.x; f32 visible = data->vertical ? viewport_size.y : viewport_size.x; - if(visibility <= 0.001f || content <= visible + 0.5f) return ok; + if(visibility <= 0.001f || content <= visible + 0.5f) return paint_order_id; GUI::InteractionState interaction = context->get_interaction_state(element.id); f32 thickness = interaction.hovered || interaction.active ? SCROLLBAR_SIZE - 2.0f : 4.0f; f32 track_length = data->vertical ? bar->layout_result.rect.height : bar->layout_result.rect.width; @@ -121,7 +130,7 @@ namespace Luna Float4U(0.04f, 0.05f, 0.07f, 0.72f)); command.color.w *= visibility; command.radius = SCROLLBAR_SIZE * 0.5f; - context->draw(command); + context->draw(command, paint_order_id); } command.type = GUI::DrawCommandType::rounded_rect; command.color = style_color(context, element, "gui.scrollbar.thumb", @@ -132,8 +141,8 @@ namespace Luna else command.rect = RectF(thumb_offset, (SCROLLBAR_SIZE - thickness) * 0.5f, thumb_length, thickness); command.radius = thickness * 0.5f; - context->draw(command); - return ok; + context->draw(command, paint_order_id + 1); + return paint_order_id + 1; } static bool contains(const RectF& rect, const Float2U& point) @@ -237,6 +246,7 @@ namespace Luna Ref state = Internal::widget_state(context, id); Internal::ScrollAction* action = Internal::allocate_frame(context); action->id = id; + action->scrollbar_group_id = GUI::make_scoped_id(id, "scrollbar_group"); action->horizontal_bar_id = GUI::make_scoped_id(id, "horizontal_scrollbar"); action->vertical_bar_id = GUI::make_scoped_id(id, "vertical_scrollbar"); action->desc = desc; @@ -274,6 +284,14 @@ namespace Luna content_flex.axis = GUI::LayoutAxis::y; content_flex.cross_alignment = GUI::FlexAlignment::stretch; Internal::set_flex_layout(context, scope.content, content_flex, GUI::LayoutAxis::y); + GUI::LayoutConfig group_layout; + group_layout.width.kind = GUI::SizeKind::percent; + group_layout.width.value = 1.0f; + group_layout.height.kind = GUI::SizeKind::percent; + group_layout.height.value = 1.0f; + GUI::ElementHandle scrollbar_group = Internal::begin_element(context, + scope.data->scrollbar_group_id, "Scrollbars", group_layout); + context->set_child_paint_order_mode(scrollbar_group, GUI::ChildPaintOrderMode::shared); auto add_bar = [&](id_t bar_id, bool vertical, bool enabled) { GUI::LayoutConfig layout; @@ -307,6 +325,7 @@ namespace Luna add_bar(scope.data->vertical_bar_id, true, visible); } context->end_element(); + context->end_element(); Internal::add_action(context, Internal::ActionType::scroll_view, scope.viewport.id, scope.data); } diff --git a/Modules/Luna/EditorGUI/Source/Slider.cpp b/Modules/Luna/EditorGUI/Source/Slider.cpp index bc05424d..0e0e1905 100644 --- a/Modules/Luna/EditorGUI/Source/Slider.cpp +++ b/Modules/Luna/EditorGUI/Source/Slider.cpp @@ -46,7 +46,8 @@ namespace Luna } static void draw_rounded_rect(GUI::IContext* context, const RectF& rect, - const Float4U& scale, const Float4U& color, f32 radius) + const Float4U& scale, const Float4U& color, f32 radius, + GUI::paint_order_id_t paint_order_id) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::rounded_rect; @@ -55,14 +56,15 @@ namespace Luna command.rect_layout_scale = scale; command.color = color; command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); } - static RV draw_slider(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_slider(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { SliderData* data = (SliderData*)userdata; - if(!data) return ok; + if(!data) return paint_order_id; f32 fraction = slider_fraction(*data); f32 knob_size = style_scalar(context, element, "gui.slider.knob_size", 12.0f); Float4U disabled = style_color(context, element, "gui.slider.disabled", @@ -71,7 +73,8 @@ namespace Luna const Float4U track_scale(0.0f, 0.5f, 0.0f, 0.0f); draw_rounded_rect(context, track_rect, track_scale, data->enabled ? style_color(context, element, "gui.slider.track", - Float4U(0.07f, 0.09f, 0.12f, 1.0f)) : disabled, 2.0f); + Float4U(0.07f, 0.09f, 0.12f, 1.0f)) : disabled, 2.0f, + paint_order_id); if(fraction > 0.0f) { const RectF fill_rect(knob_size * 0.5f, -2.0f, -knob_size * fraction, 4.0f); @@ -93,9 +96,10 @@ namespace Luna shadow.shadow_desc.mode = GUI::ShadowMode::inner; } if(RV result = draw_rounded_rect_effects(context, element, fill_rect, fill_scale, - 2.0f, Span(fill_effects, num_fill_effects)); failed(result)) + 2.0f, Span(fill_effects, num_fill_effects), + paint_order_id + 1); failed(result)) { - return result; + return result.errcode(); } } if(data->enabled) @@ -115,9 +119,10 @@ namespace Luna track_effects[1].shadow_desc.softness = softness * 0.35f; track_effects[1].shadow_desc.mode = GUI::ShadowMode::inner; if(RV result = draw_rounded_rect_effects(context, element, track_rect, track_scale, - 2.0f, Span(track_effects, 2)); failed(result)) + 2.0f, Span(track_effects, 2), + paint_order_id + 2); failed(result)) { - return result; + return result.errcode(); } } const RectF knob_rect(-knob_size * fraction, -knob_size * 0.5f, knob_size, knob_size); @@ -147,11 +152,11 @@ namespace Luna Float4U(0.32f, 0.58f, 0.90f, 1.0f)) : disabled; if(RV result = draw_rounded_rect_effects(context, element, knob_rect, knob_scale, knob_size * 0.5f, Span(knob_effects, - num_knob_effects)); failed(result)) + num_knob_effects), paint_order_id + 3); failed(result)) { - return result; + return result.errcode(); } - return ok; + return paint_order_id + 3; } static bool resolve_slider_position(GUI::IContext* context, id_t id, f32& fraction) diff --git a/Modules/Luna/EditorGUI/Source/State.hpp b/Modules/Luna/EditorGUI/Source/State.hpp index bbca2897..db848ffe 100644 --- a/Modules/Luna/EditorGUI/Source/State.hpp +++ b/Modules/Luna/EditorGUI/Source/State.hpp @@ -161,6 +161,7 @@ namespace Luna struct ScrollAction { GUI::id_t id = 0; + GUI::id_t scrollbar_group_id = 0; GUI::id_t horizontal_bar_id = 0; GUI::id_t vertical_bar_id = 0; ScrollViewDesc desc; @@ -431,9 +432,14 @@ namespace Luna struct [[Luna::struct("{29355D28-48C6-457F-A396-F0459D4CC029}")]] DragState { bool dragging = false; + bool editing = false; + bool select_all = false; f32 start_pointer_x = 0.0f; f32 start_float = 0.0f; i32 start_int = 0; + f32 edit_original_float = 0.0f; + i32 edit_original_int = 0; + String edit_text; }; struct [[Luna::struct("{47C7A918-1B06-4351-B5DE-14469AF613B3}")]] TextInputState diff --git a/Modules/Luna/EditorGUI/Source/Style.cpp b/Modules/Luna/EditorGUI/Source/Style.cpp index 3ad2b3b6..1887e802 100644 --- a/Modules/Luna/EditorGUI/Source/Style.cpp +++ b/Modules/Luna/EditorGUI/Source/Style.cpp @@ -331,6 +331,8 @@ namespace Luna add("gui.dock_panel.floating.background", GUI::StyleValueType::f32x4, GUI::style_f32x4(Float4U(0.973f, 0.973f, 0.961f, 0.96f)), "Dock Space", "Floating dock panel glass tint."); + add("gui.dock_panel.content_padding", GUI::StyleValueType::f32, + GUI::style_f32(10.0f), "Dock Space", "Uniform dock panel content padding."); add("gui.dock_panel.floating.backdrop_softness", GUI::StyleValueType::f32, GUI::style_f32(0.0f), "Dock Space", "Floating dock panel backdrop blur softness. Zero disables capture."); @@ -598,6 +600,7 @@ namespace Luna set_scalar("gui.tooltip.gap", touch ? 7.0f : 4.0f); set_color("gui.dock_panel.floating.background", Float4U(surface1.x, surface1.y, surface1.z, 0.96f)); + set_scalar("gui.dock_panel.content_padding", touch ? 12.0f : 10.0f); set_color("gui.combo.background", surface0); set_color("gui.combo.background_hovered", surface2); diff --git a/Modules/Luna/EditorGUI/Source/TabBar.cpp b/Modules/Luna/EditorGUI/Source/TabBar.cpp index a7cb2876..9f799461 100644 --- a/Modules/Luna/EditorGUI/Source/TabBar.cpp +++ b/Modules/Luna/EditorGUI/Source/TabBar.cpp @@ -115,11 +115,12 @@ namespace Luna return ok; } - static RV draw_tab_bar(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_tab_bar(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { TabAction* action = (TabAction*)userdata; - if(!action || !action->state || action->state->header_ids.empty()) return ok; + if(!action || !action->state || action->state->header_ids.empty()) return paint_order_id; f32 header_height = style_scalar(context, element, "gui.tab.height", 32.0f); const GUI::Element* tab_bar = context->get_element(element.index); f32 scale = tab_bar ? tab_width_scale(context, element, *action, tab_bar->layout_result.rect.width) : 1.0f; @@ -130,7 +131,7 @@ namespace Luna command.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 0.0f); command.color = style_color(context, element, "gui.tab_bar.background", Float4U(0.08f, 0.10f, 0.13f, 1.0f)); - context->draw(command); + context->draw(command, paint_order_id); i32 left_index = clamp((i32)floor(action->state->animated_index), 0, (i32)action->state->header_ids.size() - 1); @@ -153,15 +154,16 @@ namespace Luna command.color = style_color(context, element, "gui.tab.selected", Float4U(0.16f, 0.35f, 0.58f, 1.0f)); command.radius = 4.0f; - context->draw(command); - return ok; + context->draw(command, paint_order_id + 1); + return paint_order_id + 1; } - static RV draw_tab_header(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_tab_header(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { TabHeaderData* data = (TabHeaderData*)userdata; - if(!data) return ok; + if(!data) return paint_order_id; GUI::InteractionState interaction = context->get_interaction_state(element.id); bool selected = data->selected_index && *data->selected_index == data->index; if(data->enabled && interaction.hovered && !selected) @@ -171,7 +173,7 @@ namespace Luna background.rect_reference = GUI::DrawCommandRectReference::element; background.color = style_color(context, element, "gui.tab.hovered", Float4U(0.13f, 0.19f, 0.27f, 1.0f)); - context->draw(background); + context->draw(background, paint_order_id); } GUI::DrawCommand text; text.type = GUI::DrawCommandType::text; @@ -184,8 +186,8 @@ namespace Luna style_color(context, element, "gui.text.disabled", Float4U(0.48f, 0.52f, 0.58f, 1.0f)); text.horizontal_alignment = VG::TextAlignment::center; text.vertical_alignment = VG::TextAlignment::center; - context->draw(text); - return ok; + context->draw(text, paint_order_id + 1); + return paint_order_id + 1; } bool resolve_tab_action(GUI::IContext* context, TabAction& action) @@ -225,6 +227,8 @@ namespace Luna { luassert(context && id && selected_index); GUI::ElementHandle bar = Internal::begin_element(context, id, "Tab Bar", layout); + // The layout callback clips the header and content child subtrees to disjoint rectangles. + context->set_child_paint_order_mode(bar, GUI::ChildPaintOrderMode::shared); Ref state = Internal::widget_state(context, id); state->header_ids.clear(); state->header_labels.clear(); diff --git a/Modules/Luna/EditorGUI/Source/TableLayout.cpp b/Modules/Luna/EditorGUI/Source/TableLayout.cpp index ee23d808..2b2cf120 100644 --- a/Modules/Luna/EditorGUI/Source/TableLayout.cpp +++ b/Modules/Luna/EditorGUI/Source/TableLayout.cpp @@ -38,8 +38,9 @@ namespace Luna return RectF(left, top, max(right - left, 0.0f), max(bottom - top, 0.0f)); } - static RV draw_table_splitter(GUI::IContext* context, - const GUI::ElementHandle& element, GUI::DrawPhase, void*) + static R draw_table_splitter(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void*) { GUI::InteractionState interaction = context->get_interaction_state(element.id); GUI::DrawCommand command; @@ -51,8 +52,8 @@ namespace Luna interaction.hovered || interaction.active ? "gui.table.separator_hovered" : "gui.table.separator", interaction.hovered || interaction.active ? Float4U(0.10f, 0.55f, 0.90f, 1.0f) : Float4U(0.28f, 0.34f, 0.40f, 0.8f)); - context->draw(command); - return ok; + context->draw(command, paint_order_id); + return paint_order_id; } static RV layout_table(GUI::IContext* context, const GUI::ElementHandle& element, @@ -208,6 +209,7 @@ namespace Luna luassert(context && id); GUI::ElementHandle element = Internal::begin_element(context, id, label ? label : "Table Layout", layout); + context->set_child_paint_order_mode(element, GUI::ChildPaintOrderMode::shared); Ref state = Internal::widget_state(context, id); Internal::TableBuildScope scope; scope.table = element; diff --git a/Modules/Luna/EditorGUI/Source/Workspace.cpp b/Modules/Luna/EditorGUI/Source/Workspace.cpp index 783d3ed1..ad89693a 100644 --- a/Modules/Luna/EditorGUI/Source/Workspace.cpp +++ b/Modules/Luna/EditorGUI/Source/Workspace.cpp @@ -10,6 +10,7 @@ #include #define LUNA_EDITOR_GUI_API LUNA_EXPORT #include "Internal.hpp" +#include #include #include @@ -233,7 +234,8 @@ namespace Luna if(!leaf.selected_tab) leaf.selected_tab = panel; } - static void dock_panel(DockSpaceState& state, id_t panel, u32 target, DockDropDirection direction) + static void dock_panel(DockSpaceState& state, id_t panel, u32 target, + DockDropDirection direction, f32 panel_ratio) { DockPanelPersistentData& panel_state = get_or_add_panel(state, panel); if(panel_state.mode == DockPanelMode::floating) @@ -271,8 +273,9 @@ namespace Luna branch.parent = state.nodes[target].parent; branch.split_axis = direction == DockDropDirection::left || direction == DockDropDirection::right ? DockSplitAxis::x : DockSplitAxis::y; - branch.split_ratio = 0.5f; bool new_first = direction == DockDropDirection::left || direction == DockDropDirection::up; + panel_ratio = clamp(panel_ratio, 0.01f, 0.99f); + branch.split_ratio = new_first ? panel_ratio : 1.0f - panel_ratio; branch.child0 = new_first ? new_child : old_child; branch.child1 = new_first ? old_child : new_child; state.nodes[target] = move(branch); @@ -369,6 +372,45 @@ namespace Luna return dock_tab_label_width(context, element, label, font_size) + padding * 2.0f; } + static String dock_tab_display_label(GUI::IContext* context, + const GUI::ElementHandle& element, const c8* label, f32 font_size, f32 available_width) + { + String single_line = label ? label : ""; + for(usize i = 0; i < single_line.size(); ++i) + { + if(single_line[i] == '\r' || single_line[i] == '\n') single_line[i] = ' '; + } + if(single_line.empty() || available_width <= 0.0f) return String(); + if(dock_tab_label_width(context, element, single_line.c_str(), font_size) <= + available_width) + { + return single_line; + } + + const c8* ellipsis = "\xE2\x80\xA6"; + String result(ellipsis); + usize cursor = 0; + while(cursor < single_line.size()) + { + usize num_bytes = 0; + if(failed(utf8_decode_char(single_line.c_str() + cursor, + single_line.size() - cursor, &num_bytes)) || !num_bytes) + { + num_bytes = 1; + } + cursor += num_bytes; + String candidate(single_line.c_str(), cursor); + candidate.append(ellipsis); + if(dock_tab_label_width(context, element, candidate.c_str(), font_size) > + available_width) + { + break; + } + result = move(candidate); + } + return result; + } + static f32 dock_tab_width_scale(GUI::IContext* context, const GUI::ElementHandle& element, DockSpaceAction& action, const DockTreeNode& leaf, f32 font_size, f32 available_width) @@ -439,7 +481,8 @@ namespace Luna } static void draw_rect(GUI::IContext* context, GUI::DrawCommandType type, - const RectF& rect, const Float4U& color, f32 radius = 0.0f, + const RectF& rect, const Float4U& color, GUI::paint_order_id_t paint_order_id, + f32 radius = 0.0f, GUI::DrawCommandRectReference reference = GUI::DrawCommandRectReference::element) { GUI::DrawCommand command; @@ -448,11 +491,12 @@ namespace Luna command.rect = rect; command.color = color; command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); } static void draw_text(GUI::IContext* context, const GUI::ElementHandle& element, const RectF& rect, const c8* text, const Float4U& color, f32 size, + GUI::paint_order_id_t paint_order_id, GUI::DrawCommandRectReference reference) { GUI::DrawCommand command; @@ -465,7 +509,7 @@ namespace Luna command.horizontal_alignment = VG::TextAlignment::begin; command.vertical_alignment = VG::TextAlignment::center; command.text = text ? text : ""; - context->draw(command); + context->draw(command, paint_order_id); } static RectF drop_preview_rect(const RectF& rect, DockDropDirection direction) @@ -533,7 +577,7 @@ namespace Luna } static void draw_line(GUI::IContext* context, const Float2U& begin, const Float2U& end, - const Float4U& color, f32 width) + const Float4U& color, f32 width, GUI::paint_order_id_t paint_order_id) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::line; @@ -542,14 +586,15 @@ namespace Luna command.point1 = end; command.color = color; command.line_width = width; - context->draw(command); + context->draw(command, paint_order_id); } - static RV draw_dock_space(GUI::IContext* context, const GUI::ElementHandle&, - GUI::DrawPhase, void* userdata) + static R draw_dock_space(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { DockSpaceDrawData* data = (DockSpaceDrawData*)userdata; - if(!data || !data->action || !data->action->state) return ok; + if(!data || !data->action || !data->action->state) return paint_order_id; DockSpaceAction& action = *data->action; DockSpaceState& state = *action.state; for(const DockTreeNode& node : state.nodes) @@ -570,34 +615,39 @@ namespace Luna visual_rect.height = visual_size; } draw_rect(context, GUI::DrawCommandType::rect, visual_rect, - action.desc.splitter_color, 0.0f, GUI::DrawCommandRectReference::layer); + action.desc.splitter_color, paint_order_id, 0.0f, + GUI::DrawCommandRectReference::layer); } } - return ok; + return paint_order_id; } - static RV draw_dock_indicators(GUI::IContext* context, const GUI::ElementHandle&, - GUI::DrawPhase, void* userdata) + static R draw_dock_indicators(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { DockSpaceDrawData* data = (DockSpaceDrawData*)userdata; - if(!data || !data->action || !data->action->state) return ok; + if(!data || !data->action || !data->action->state) return paint_order_id; DockSpaceAction& action = *data->action; DockSpaceState& state = *action.state; - if(state.drag_mode != DockDragMode::floating_move || !state.drop_target_available) return ok; + if(state.drag_mode != DockDragMode::floating_move || !state.drop_target_available) + return paint_order_id; RectF target; bool empty_dock_space = state.root_node == U32_MAX; if(state.drop_target < state.nodes.size()) target = state.nodes[state.drop_target].rect; else if(empty_dock_space) target = state.dock_rect; - if(!rect_valid(target)) return ok; + if(!rect_valid(target)) return paint_order_id; + GUI::paint_order_id_t icon_order = paint_order_id; if(state.drop_direction != DockDropDirection::none) { RectF preview = drop_preview_rect(target, state.drop_direction); Float4U color = action.desc.docking_indicator_color; color.w *= 0.28f; - draw_rect(context, GUI::DrawCommandType::rect, preview, color, 0.0f, + draw_rect(context, GUI::DrawCommandType::rect, preview, color, paint_order_id, 0.0f, GUI::DrawCommandRectReference::layer); + icon_order = paint_order_id + 1; } static const DockDropDirection directions[] = { @@ -614,9 +664,15 @@ namespace Luna bool selected = direction == state.drop_direction; Float4U fill = action.desc.docking_indicator_color; fill.w = selected ? min(fill.w + 0.10f, 1.0f) : fill.w * 0.42f; - Float4U stroke = selected ? Float4U(0.74f, 0.87f, 1.0f, 1.0f) : - Float4U(0.46f, 0.56f, 0.68f, 0.95f); - draw_rect(context, GUI::DrawCommandType::rounded_rect, icon, fill, 5.0f, + f32 luminance = fill.x * 0.2126f + fill.y * 0.7152f + fill.z * 0.0722f; + Float4U stroke = action.desc.docking_indicator_color; + if(selected) + { + stroke = luminance > 0.40f ? Float4U(0.082f, 0.090f, 0.094f, 1.0f) : + Float4U(1.0f); + } + else stroke.w = 0.95f; + draw_rect(context, GUI::DrawCommandType::rounded_rect, icon, fill, icon_order, 5.0f, GUI::DrawCommandRectReference::layer); f32 left = icon.offset_x + 5.0f; f32 right = icon.offset_x + max(icon.width - 5.0f, 5.0f); @@ -626,35 +682,42 @@ namespace Luna f32 center_y = icon.offset_y + icon.height * 0.5f; if(direction == DockDropDirection::center) { - draw_line(context, Float2U(left, top), Float2U(right, top), stroke, 1.6f); - draw_line(context, Float2U(right, top), Float2U(right, bottom), stroke, 1.6f); - draw_line(context, Float2U(right, bottom), Float2U(left, bottom), stroke, 1.6f); - draw_line(context, Float2U(left, bottom), Float2U(left, top), stroke, 1.6f); + draw_line(context, Float2U(left, top), Float2U(right, top), stroke, 1.6f, + icon_order + 1); + draw_line(context, Float2U(right, top), Float2U(right, bottom), stroke, 1.6f, + icon_order + 1); + draw_line(context, Float2U(right, bottom), Float2U(left, bottom), stroke, 1.6f, + icon_order + 1); + draw_line(context, Float2U(left, bottom), Float2U(left, top), stroke, 1.6f, + icon_order + 1); } else if(direction == DockDropDirection::left || direction == DockDropDirection::right) { - draw_line(context, Float2U(center_x, top), Float2U(center_x, bottom), stroke, 2.0f); + draw_line(context, Float2U(center_x, top), Float2U(center_x, bottom), stroke, 2.0f, + icon_order + 1); } else { - draw_line(context, Float2U(left, center_y), Float2U(right, center_y), stroke, 2.0f); + draw_line(context, Float2U(left, center_y), Float2U(right, center_y), stroke, 2.0f, + icon_order + 1); } } - return ok; + return icon_order + 1; } - static RV draw_dock_panel(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + static R draw_dock_panel(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { DockPanelDrawData* data = (DockPanelDrawData*)userdata; - if(!data || !data->action || !data->panel || !data->action->state) return ok; + if(!data || !data->action || !data->panel || !data->action->state) return paint_order_id; DockSpaceAction& action = *data->action; DockPanelActionInfo& panel = *data->panel; DockSpaceState& state = *action.state; const DockPanelPersistentData* persistent = find_panel(state, panel.id); - if(!persistent) return ok; + if(!persistent) return paint_order_id; const GUI::Element* panel_element = context->get_element(element.index); - if(!panel_element) return ok; + if(!panel_element) return paint_order_id; const DockPanelDesc& desc = panel.desc; f32 panel_width = panel_element->layout_result.rect.width; f32 panel_height = panel_element->layout_result.rect.height; @@ -671,6 +734,7 @@ namespace Luna Float4U border_color = desc.border_color.w > 0.0f ? desc.border_color : style_color(context, element, "gui.border.strong", Float4U(0.24f, 0.30f, 0.38f, 1.0f)); f32 panel_radius = style_scalar(context, element, "gui.radius.medium", 9.0f); + GUI::paint_order_id_t surface_order = paint_order_id; if(floating) { if(style_scalar(context, element, @@ -682,7 +746,8 @@ namespace Luna backdrop.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); backdrop.radius = panel_radius; - context->draw(backdrop); + context->draw(backdrop, surface_order); + ++surface_order; } Float4U shadow_color = style_color(context, element, "gui.shadow.dark", Float4U(0.0f, 0.0f, 0.0f, 0.30f)); @@ -695,9 +760,9 @@ namespace Luna outer_effects[0].shadow_desc.softness = shadow_softness * 3.5f; outer_effects[1].color = border_color; if(RV result = draw_rounded_rect_effects(context, element, RectF(), Float4U(), panel_radius, - Span(outer_effects, 2)); failed(result)) + Span(outer_effects, 2), surface_order); failed(result)) { - return result; + return result.errcode(); } RoundedRectEffect inner_effects[2]; @@ -713,17 +778,22 @@ namespace Luna if(RV result = draw_rounded_rect_effects(context, element, RectF(1.0f, 1.0f, -2.0f, -2.0f), Float4U(), max(panel_radius - 1.0f, 0.0f), - Span(inner_effects, 2)); failed(result)) + Span(inner_effects, 2), surface_order + 1); failed(result)) { - return result; + return result.errcode(); } + ++surface_order; } else { - draw_rect(context, GUI::DrawCommandType::rect, RectF(), background_color); + draw_rect(context, GUI::DrawCommandType::rect, RectF(), background_color, surface_order); } + GUI::paint_order_id_t max_paint_order_id = surface_order; if(desc.title_bar) { + const GUI::paint_order_id_t title_surface_order = max_paint_order_id + 1; + const GUI::paint_order_id_t title_text_order = title_surface_order + 1; + const GUI::paint_order_id_t title_indicator_order = title_text_order + 1; const f32 title_height = desc.title_bar_height; const f32 text_padding = floating ? 12.0f : style_scalar(context, element, "gui.tab.padding_x", 14.0f); @@ -733,36 +803,45 @@ namespace Luna draw_rect(context, GUI::DrawCommandType::rect, RectF(floating ? 12.0f : 0.0f, max(title_height - 1.0f, 0.0f), floating ? max(panel_width - 24.0f, 0.0f) : panel_width, 1.0f), - style_color(context, element, "gui.border", border_color)); + style_color(context, element, "gui.border", border_color), title_surface_order); if(!floating) { draw_rect(context, GUI::DrawCommandType::rect, - RectF(0.0f, 0.0f, panel_width, max(title_height - 1.0f, 0.0f)), title_bar_color); + RectF(0.0f, 0.0f, panel_width, max(title_height - 1.0f, 0.0f)), + title_bar_color, title_surface_order); } - auto draw_tab = [&](const RectF& tab_rect, const c8* label, bool selected, bool reserve_close) + auto draw_tab = [&](const RectF& tab_rect, const c8* label, bool selected) { if(selected && desc.active_title_bar_color.w > 0.0f) { draw_rect(context, GUI::DrawCommandType::rect, tab_rect, - desc.active_title_bar_color); + desc.active_title_bar_color, title_surface_order); } - f32 close_reserve = reserve_close ? title_height * 0.72f : 0.0f; RectF text_rect(tab_rect.offset_x + text_padding, 0.0f, - max(tab_rect.width - text_padding * 2.0f - close_reserve, 0.0f), title_height); - draw_text(context, element, text_rect, label, style_color(context, element, + max(tab_rect.width - text_padding * 2.0f, 0.0f), title_height); + String display_label = dock_tab_display_label(context, element, label, + font_size, text_rect.width); + draw_rect(context, GUI::DrawCommandType::push_clip, text_rect, Float4U(), + title_text_order); + draw_text(context, element, text_rect, display_label.c_str(), + style_color(context, element, selected && !floating ? "gui.focus" : selected ? "gui.text.color" : "gui.text.secondary", selected ? Float4U(0.72f, 0.38f, 0.40f, 1.0f) : Float4U(0.65f, 0.68f, 0.72f, 1.0f)), font_size, + title_text_order, GUI::DrawCommandRectReference::element); + draw_rect(context, GUI::DrawCommandType::pop_clip, RectF(), Float4U(), + title_text_order); }; u32 leaf_index = floating ? U32_MAX : find_panel_leaf(state, panel.id); + f32 tab_bar_width = max(panel_width - (panel.close.id ? title_height : 0.0f), 0.0f); if(leaf_index < state.nodes.size() && !state.nodes[leaf_index].tabs.empty()) { DockTreeNode& leaf = state.nodes[leaf_index]; f32 width_scale = dock_tab_width_scale(context, element, action, leaf, - font_size, panel_width); + font_size, tab_bar_width); f32 x = 0.0f; f32 target_indicator_x = 0.0f; f32 target_indicator_width = 0.0f; @@ -773,8 +852,7 @@ namespace Luna f32 width = dock_tab_width(context, element, label, font_size) * width_scale; RectF tab_rect(x, 0.0f, width, desc.title_bar_height); bool selected = tab == leaf.selected_tab; - draw_tab(tab_rect, label, selected, - selected && panel.close.id && i + 1 == leaf.tabs.size()); + draw_tab(tab_rect, label, selected); if(selected) { f32 indicator_inset = min(10.0f, tab_rect.width * 0.25f); @@ -800,25 +878,31 @@ namespace Luna draw_rect(context, GUI::DrawCommandType::rounded_rect, RectF(leaf.tab_indicator_x, max(title_height - 2.0f, 0.0f), leaf.tab_indicator_width, 2.0f), style_color(context, element, "gui.accent", - Float4U(0.89f, 0.31f, 0.35f, 1.0f)), 1.0f); + Float4U(0.89f, 0.31f, 0.35f, 1.0f)), title_indicator_order, 1.0f); } else { - draw_tab(RectF(0.0f, 0.0f, panel_width, desc.title_bar_height), panel.label, - true, panel.close.id); + draw_tab(RectF(0.0f, 0.0f, tab_bar_width, desc.title_bar_height), + panel.label, true); } + max_paint_order_id = title_indicator_order; } f32 border = max(desc.border_size, 0.0f); + const GUI::paint_order_id_t decoration_order = max_paint_order_id + 1; + bool has_decoration = false; if(border > 0.0f && !floating) { draw_rect(context, GUI::DrawCommandType::rect, - RectF(0.0f, 0.0f, panel_width, border), border_color); + RectF(0.0f, 0.0f, panel_width, border), border_color, decoration_order); draw_rect(context, GUI::DrawCommandType::rect, - RectF(0.0f, max(panel_height - border, 0.0f), panel_width, border), border_color); + RectF(0.0f, max(panel_height - border, 0.0f), panel_width, border), border_color, + decoration_order); draw_rect(context, GUI::DrawCommandType::rect, - RectF(0.0f, 0.0f, border, panel_height), border_color); + RectF(0.0f, 0.0f, border, panel_height), border_color, decoration_order); draw_rect(context, GUI::DrawCommandType::rect, - RectF(max(panel_width - border, 0.0f), 0.0f, border, panel_height), border_color); + RectF(max(panel_width - border, 0.0f), 0.0f, border, panel_height), border_color, + decoration_order); + has_decoration = true; } if(panel.close.id) { @@ -835,10 +919,11 @@ namespace Luna context->get_interaction_state(panel.close.id).hovered ? "gui.accent" : "gui.text.secondary", Float4U(0.72f, 0.74f, 0.76f, 1.0f)); line.line_width = 1.5f; - context->draw(line); + context->draw(line, decoration_order); line.rect = RectF(x + icon_size, y, 0.0f, 0.0f); line.point1 = Float2U(x, y + icon_size); - context->draw(line); + context->draw(line, decoration_order); + has_decoration = true; } if(panel.resize.id && desc.resize_border) { @@ -851,9 +936,10 @@ namespace Luna max(panel_height - 13.0f, 0.0f)); line.color = border_color; line.line_width = 1.5f; - context->draw(line); + context->draw(line, decoration_order); + has_decoration = true; } - return ok; + return has_decoration ? decoration_order : max_paint_order_id; } static Float2U dock_space_pointer(const DockSpaceAction& action, GUI::IContext* context, @@ -919,23 +1005,13 @@ namespace Luna if(panel.close.id && context->get_interaction_state(panel.close.id).clicked) { if(panel.open) *panel.open = false; - remove_panel(state, panel.id); - relayout = true; continue; } if(panel.title.id && has_pointer_event(context, panel.title.id, GUI::InputEventType::pointer_down, &event)) { - raise_panel(context, state, panel); - state.drag_panel = panel.id; - state.drag_start_pointer = event.position; - state.drag_start_rect = persistent->floating_rect; - state.drop_target = U32_MAX; - state.drop_target_available = false; - state.drop_direction = DockDropDirection::none; - state.drag_mode = persistent->mode == DockPanelMode::floating ? - DockDragMode::floating_move : DockDragMode::docked_title; - + id_t drag_panel = panel.id; + DockPanelPersistentData* drag_persistent = persistent; if(persistent->mode == DockPanelMode::docking) { u32 leaf_index = find_panel_leaf(state, panel.id); @@ -944,10 +1020,12 @@ namespace Luna DockTreeNode& leaf = state.nodes[leaf_index]; const GUI::Element* panel_element = context->get_element(panel.root.index); f32 panel_width = panel_element ? panel_element->layout_result.rect.width : 0.0f; + f32 tab_bar_width = max(panel_width - + (panel.close.id ? panel.desc.title_bar_height : 0.0f), 0.0f); f32 font_size = style_scalar(context, panel.title, "gui.menu_item.font_size", 13.0f); f32 width_scale = dock_tab_width_scale(context, panel.title, - action, leaf, font_size, panel_width); + action, leaf, font_size, tab_bar_width); for(const GUI::RoutedInputEvent& routed : context->get_routed_input_events(panel.title.id)) { if(routed.event.type != GUI::InputEventType::pointer_down || !routed.has_element_position) continue; @@ -960,6 +1038,12 @@ namespace Luna routed.element_position.x < x + width) { leaf.selected_tab = tab; + DockPanelPersistentData* tab_persistent = find_panel(state, tab); + if(tab_persistent) + { + drag_panel = tab; + drag_persistent = tab_persistent; + } break; } x += width; @@ -968,6 +1052,15 @@ namespace Luna } } } + raise_panel(context, state, panel); + state.drag_panel = drag_panel; + state.drag_start_pointer = event.position; + state.drag_start_rect = drag_persistent->floating_rect; + state.drop_target = U32_MAX; + state.drop_target_available = false; + state.drop_direction = DockDropDirection::none; + state.drag_mode = drag_persistent->mode == DockPanelMode::floating ? + DockDragMode::floating_move : DockDragMode::docked_title; } if(panel.resize.id && has_pointer_event(context, panel.resize.id, GUI::InputEventType::pointer_down, &event)) @@ -1098,7 +1191,7 @@ namespace Luna if(state.drag_mode == DockDragMode::floating_move && state.drag_panel && state.drop_direction != DockDropDirection::none) { - dock_panel(state, state.drag_panel, state.drop_target, state.drop_direction); + dock_panel(state, state.drag_panel, state.drop_target, state.drop_direction, 0.5f); relayout = true; } state.drag_mode = DockDragMode::none; @@ -1122,6 +1215,8 @@ namespace Luna luassert(context && id); GUI::ElementHandle root = Internal::begin_element(context, id, label ? label : "Dock Space", layout); + // Docked panels are clipped to disjoint leaf rectangles; splitters draw in the parent overlay phase. + context->set_child_paint_order_mode(root, GUI::ChildPaintOrderMode::shared); GUI::Interactable interactable; interactable.pointer_hit_behavior = GUI::PointerHitBehavior::target; set_flags(interactable.flags, GUI::InteractableFlag::hoverable); @@ -1133,6 +1228,11 @@ namespace Luna scope.id = id; scope.root = root; scope.desc = desc; + if(scope.desc.docking_indicator_color.w <= 0.0f) + { + scope.desc.docking_indicator_color = Internal::style_color(context, root, + "gui.accent", Float4U(0.890f, 0.310f, 0.349f, 1.0f)); + } scope.state = state.get(); frame->dock_space_stack.push_back(move(scope)); return root; @@ -1190,6 +1290,49 @@ namespace Luna } } + LUNA_EDITOR_GUI_API bool dock_panel(GUI::IContext* context, id_t dock_space, id_t panel, + id_t target_panel, DockPanelPlacement placement, f32 panel_ratio) + { + luassert(context && dock_space && panel && target_panel); + if(panel == target_panel) return activate_dock_panel(context, dock_space, panel); + Ref state = Internal::dock_space_state(context, dock_space); + u32 target = Internal::find_panel_leaf(*state, target_panel); + if(target == U32_MAX) return false; + Internal::remove_panel_from_tree(*state, panel); + target = Internal::find_panel_leaf(*state, target_panel); + if(target == U32_MAX) return false; + Internal::DockDropDirection direction = Internal::DockDropDirection::center; + switch(placement) + { + case DockPanelPlacement::tab: direction = Internal::DockDropDirection::center; break; + case DockPanelPlacement::left: direction = Internal::DockDropDirection::left; break; + case DockPanelPlacement::right: direction = Internal::DockDropDirection::right; break; + case DockPanelPlacement::up: direction = Internal::DockDropDirection::up; break; + case DockPanelPlacement::down: direction = Internal::DockDropDirection::down; break; + default: return false; + } + Internal::dock_panel(*state, panel, target, direction, panel_ratio); + return true; + } + + LUNA_EDITOR_GUI_API bool activate_dock_panel(GUI::IContext* context, id_t dock_space, + id_t panel) + { + luassert(context && dock_space && panel); + Ref state = Internal::dock_space_state(context, dock_space); + Internal::DockPanelPersistentData* persistent = Internal::find_panel(*state, panel); + if(!persistent) return false; + if(persistent->mode == DockPanelMode::floating) + { + persistent->z_order = state->next_z_order++; + return true; + } + u32 leaf_index = Internal::find_panel_leaf(*state, panel); + if(leaf_index == U32_MAX) return false; + state->nodes[leaf_index].selected_tab = panel; + return true; + } + LUNA_EDITOR_GUI_API bool begin_dock_panel(GUI::IContext* context, id_t id, const c8* label, bool* open, const DockPanelDesc& desc) { @@ -1293,6 +1436,10 @@ namespace Luna content_layout.width.value = 1.0f; content_layout.height.kind = GUI::SizeKind::percent; content_layout.height.value = 1.0f; + f32 content_padding = info.desc.content_padding >= 0.0f ? + info.desc.content_padding : Internal::style_scalar(context, info.root, + "gui.dock_panel.content_padding", 10.0f); + content_layout.padding = Float4U(max(content_padding, 0.0f)); info.content = begin_v_layout(context, Internal::derived_id(id, "dock.panel.content"), "Dock Panel Content", content_layout); scope.panels.push_back(move(info)); diff --git a/Modules/Luna/EditorGUI/Workspace.hpp b/Modules/Luna/EditorGUI/Workspace.hpp index 78dc3eb1..a8195454 100644 --- a/Modules/Luna/EditorGUI/Workspace.hpp +++ b/Modules/Luna/EditorGUI/Workspace.hpp @@ -32,6 +32,21 @@ namespace Luna y }; + //! Identifies how a panel is placed relative to an existing docked panel. + enum class DockPanelPlacement : u8 + { + //! Adds the panel as a tab in the target panel's leaf. + tab, + //! Splits the target leaf and places the panel on its left side. + left, + //! Splits the target leaf and places the panel on its right side. + right, + //! Splits the target leaf and places the panel above it. + up, + //! Splits the target leaf and places the panel below it. + down + }; + //! Describes one node in a dock space split tree. struct DockSpaceLayoutNodeDesc { @@ -84,8 +99,8 @@ namespace Luna f32 minimum_split_ratio = 0.08f; //! Color used by splitters. Float4U splitter_color = Float4U(0.24f, 0.29f, 0.36f, 1.0f); - //! Color used by docking target indicators. - Float4U docking_indicator_color = Float4U(0.10f, 0.50f, 0.82f, 0.86f); + //! Color used by docking target indicators. Zero alpha uses `gui.accent` from the current Style. + Float4U docking_indicator_color = Float4U(0.0f); }; //! Configures one panel managed by a dock space. @@ -99,6 +114,9 @@ namespace Luna bool resize_border = true; //! Title bar height in logical units. A non-positive value uses `gui.control.height` from the current Style. f32 title_bar_height = 0.0f; + //! Uniform padding around panel content in logical units. A negative value uses + //! `gui.dock_panel.content_padding` from the current Style. + f32 content_padding = -1.0f; //! Border thickness in logical units. f32 border_size = 1.0f; //! Resize hit region thickness in logical units. @@ -135,6 +153,28 @@ namespace Luna LUNA_EDITOR_GUI_API void set_dockspace_layout(GUI::IContext* context, id_t dock_space, const DockSpaceLayoutDesc& desc); + //! Docks one panel relative to an existing docked panel. + //! @param[in] context GUI context. + //! @param[in] dock_space Stable dock space ID. + //! @param[in] panel Stable ID of the panel to place. The panel does not need to be submitted yet. + //! @param[in] target_panel Stable ID of an existing docked panel. + //! @param[in] placement Placement relative to `target_panel`. + //! @param[in] panel_ratio Fraction of the target leaf assigned to `panel` for split placements. + //! This value is ignored for @ref DockPanelPlacement::tab. + //! @return Returns `true` if `target_panel` exists in the dock tree and the panel was placed. + LUNA_EDITOR_GUI_API bool dock_panel(GUI::IContext* context, id_t dock_space, id_t panel, + id_t target_panel, DockPanelPlacement placement = DockPanelPlacement::tab, + f32 panel_ratio = 0.5f); + + //! Activates one panel in a dock space. + //! @param[in] context GUI context. + //! @param[in] dock_space Stable dock space ID. + //! @param[in] panel Stable panel ID. + //! @return Returns `true` if the panel exists. Docked panels become the selected tab of their leaf; + //! floating panels are raised above other floating panels on the next frame. + LUNA_EDITOR_GUI_API bool activate_dock_panel(GUI::IContext* context, id_t dock_space, + id_t panel); + //! Begins one panel in the current dock space. //! @param[in] context GUI context. //! @param[in] id Stable panel ID referenced by @ref DockSpaceLayoutDesc. @@ -143,6 +183,8 @@ namespace Luna //! @param[in] desc Panel behavior and drawing configuration. //! @return Returns `true` when this panel's content should be submitted. Call @ref end_dock_panel only //! when this function returns `true`. + //! @remark Panel content uses the Style-defined padding by default. Set + //! @ref DockPanelDesc::content_padding to zero for edge-to-edge content. LUNA_EDITOR_GUI_API bool begin_dock_panel(GUI::IContext* context, id_t id, const c8* label, bool* open = nullptr, const DockPanelDesc& desc = DockPanelDesc()); diff --git a/Modules/Luna/GUI/Base.hpp b/Modules/Luna/GUI/Base.hpp index a82a713c..a7986a1c 100644 --- a/Modules/Luna/GUI/Base.hpp +++ b/Modules/Luna/GUI/Base.hpp @@ -35,12 +35,21 @@ namespace Luna //! Stable identifier type used by GUI elements, layers and state objects. using id_t = u64; + //! Frame-local ordering coordinate used by GUI rendering. + //! @remark Commands with lower IDs execute first. Equal IDs permit cross-batch-key reordering while + //! preserving submission order within one complete batch key. IDs are regenerated with draw commands and + //! are not stable across frames. + using paint_order_id_t = u32; + //! The invalid element index value. constexpr u32 INVALID_ELEMENT = U32_MAX; //! The invalid layer index value. constexpr u32 INVALID_LAYER = U32_MAX; + //! The invalid Paint Order ID value. + constexpr paint_order_id_t INVALID_PAINT_ORDER_ID = U32_MAX; + //! Default data scope used when no explicit scope has been pushed. constexpr id_t DEFAULT_DATA_SCOPE = 14695981039346656037ull; diff --git a/Modules/Luna/GUI/Context.hpp b/Modules/Luna/GUI/Context.hpp index 1ecfdd91..f91511b6 100644 --- a/Modules/Luna/GUI/Context.hpp +++ b/Modules/Luna/GUI/Context.hpp @@ -41,7 +41,7 @@ namespace Luna //! Owns GUI frame data, layers, elements, input events, state objects, styles and draw commands. //! @remark GUI contexts are widget-free. High-level immediate API packages operate on this interface to //! submit typeless elements and primitive draw commands. - struct [[Luna::interface("{B5B4D4D6-EFAB-4EB8-9D6B-DA10722CB5FD}")]] IContext : virtual Interface + struct [[Luna::interface("{AB812697-F6D5-470B-B779-1EAD09F2723E}")]] IContext : virtual Interface { //! Begins a new frame and resets per-frame element, layer, input and draw command storage. //! @param[in] desc The frame description supplied by the host. @@ -274,11 +274,38 @@ namespace Luna //! elements without delayed draw behavior. virtual DrawConfig get_draw_config(const ElementHandle& element) const = 0; + //! Sets ordered static visual effects for an element. + //! @param[in] element The element handle returned by @ref begin_element. + //! @param[in] config The static effects to copy into context-owned frame storage. + //! @return Returns success, or @ref E_BAD_DATA if an effect contains a structural command. + //! @remark Effects are emitted at consecutive Paint Order IDs in list order. The configuration may be + //! replaced until draw-command generation starts. Invalid handles are ignored successfully. + virtual RV set_element_visual_config(const ElementHandle& element, + const ElementVisualConfig& config) = 0; + + //! Gets static visual effects attached to an element. + //! @param[in] element The element handle returned by @ref begin_element. + //! @return Returns the attached configuration, or an empty configuration for invalid handles and + //! elements without static effects. + //! @remark The returned spans are invalidated by the next visual configuration mutation or + //! @ref begin_frame call. + virtual ElementVisualConfig get_element_visual_config( + const ElementHandle& element) const = 0; + + //! Sets how Paint Order ranges are allocated to an element's direct child subtrees. + //! @param[in] element The element handle returned by @ref begin_element. + //! @param[in] mode The child Paint Order allocation mode. + //! @remark @ref ChildPaintOrderMode::shared may be used only when the complete painted extents of the + //! direct child subtrees are independent. Invalid handles are ignored. + virtual void set_child_paint_order_mode(const ElementHandle& element, + ChildPaintOrderMode mode) = 0; + //! Enables backdrop capture at one element's painter entry. //! @param[in] element The element handle returned by @ref begin_element. //! @param[in] desc The capture and filtering configuration. //! @remark Command generation inserts the capture before the element's @ref DrawPhase::before_children - //! callback. Invalid handles are ignored. + //! callback. The capture owns an exclusive Paint Order ID, so the callback receives the following ID. + //! Invalid handles are ignored. virtual void set_backdrop_blur_capture(const ElementHandle& element, const BackdropBlurCaptureDesc& desc) = 0; @@ -291,9 +318,9 @@ namespace Luna //! Generates the final draw command stream for the current element trees. //! @return Returns success or failure code. //! @remark Generation traverses layers from bottom to top and elements in painter order. Element draw - //! callbacks run at their configured phases, while commands recorded through @ref draw and - //! @ref draw_for_element retain their build-time positions. Call this after layout, input routing, and - //! higher-level package state resolution when complete commands must be inspected before compilation. + //! callbacks run at their configured phases and submit commands with explicit Paint Order IDs. Call this + //! after layout, input routing, and higher-level package state resolution when complete commands must be + //! inspected before compilation. virtual RV generate_draw_commands() = 0; //! Gets the latest generated draw commands in frame submission order. @@ -307,15 +334,15 @@ namespace Luna //! Validates and appends one standalone shape program to context-owned frame storage. //! @param[in] floats Complete prefix shape expression. //! @return Returns the stored program with its context-relative float range, or a format error. - //! @remark Programs appended during element construction remain available to recorded commands. Programs - //! appended from draw callbacks are regenerated with the final draw command stream. + //! @remark Programs appended during element construction remain available to later draw callbacks. + //! Programs appended from draw callbacks are regenerated with the final draw command stream. virtual R append_sdf_shape_program(Span floats) = 0; //! Validates and appends one standalone color program to context-owned frame storage. //! @param[in] floats Complete color program. //! @return Returns the stored program with its context-relative float range, or a format error. - //! @remark Programs appended during element construction remain available to recorded commands. Programs - //! appended from draw callbacks are regenerated with the final draw command stream. + //! @remark Programs appended during element construction remain available to later draw callbacks. + //! Programs appended from draw callbacks are regenerated with the final draw command stream. virtual R append_sdf_color_program(Span floats) = 0; //! Gets the shape program floats associated with the latest generated command stream. @@ -330,18 +357,13 @@ namespace Luna //! command generation or @ref begin_frame call. virtual Span get_sdf_color_floats() const = 0; - //! Records or emits one primitive draw command. - //! @param[in] command The command to append to the current layer and current element. - //! @remark During element construction this records a static command at the current painter-order - //! position. During a draw callback this emits a generated command for the callback's element. - virtual void draw(const DrawCommand& command) = 0; - - //! Records one primitive draw command for a specific element. - //! @param[in] element The element that owns the draw command. - //! @param[in] command The command to append to the element's layer. - //! @remark This is useful for layout-dependent commands that are emitted after the element build scope - //! has ended. Invalid handles are ignored. - virtual void draw_for_element(const ElementHandle& element, const DrawCommand& command) = 0; + //! Emits one primitive draw command at an explicit Paint Order ID. + //! @param[in] command The command to emit for the callback's current element. + //! @param[in] paint_order_id The frame-local Paint Order ID assigned to the command. + //! @remark This function may be called only during a draw callback. The ID must not be lower than the + //! first Paint Order ID passed to that callback phase. Equal IDs explicitly permit the renderer to group + //! otherwise compatible commands. Every draw command must declare its Paint Order explicitly. + virtual void draw(const DrawCommand& command, paint_order_id_t paint_order_id) = 0; //! Registers one font that can be referenced by text draw commands. //! @param[in] id The stable font ID used by @ref DrawCommand::font. diff --git a/Modules/Luna/GUI/DrawCommand.hpp b/Modules/Luna/GUI/DrawCommand.hpp index bbd046f9..10571212 100644 --- a/Modules/Luna/GUI/DrawCommand.hpp +++ b/Modules/Luna/GUI/DrawCommand.hpp @@ -52,7 +52,9 @@ namespace Luna //! does not draw pixels. backdrop_blur_capture, //! Draws the nearest self-or-ancestor backdrop capture through the command rectangle. - backdrop_blur + backdrop_blur, + //! Draws a centered analytic rounded-rectangle stroke. + rounded_rect_stroke }; //! Identifies the coordinate space used by a draw command rectangle. @@ -67,9 +69,9 @@ namespace Luna //! Identifies one point in an element's painter-order traversal. enum class DrawPhase : u8 { - //! Runs before the element's statically recorded commands and child elements. + //! Runs before the element's child elements. before_children, - //! Runs after the element's statically recorded commands and child elements. + //! Runs after the element's child elements. after_children }; @@ -89,12 +91,14 @@ namespace Luna //! commands for the element currently being generated. //! @param[in] element The element being generated. //! @param[in] phase The painter-order traversal phase being generated. + //! @param[in] paint_order_id The first Paint Order ID available to this callback phase. //! @param[in] userdata User data stored in @ref DrawConfig. - //! @return Returns success or failure code. + //! @return Returns the maximum Paint Order ID used or reserved by this callback, or a failure code. //! @remark Draw callbacks run after layout and input routing. They must not mutate the element tree, layout, //! interaction state, or application data. The callback and userdata must remain valid until draw command - //! generation finishes. - using DrawCallback = RV(*)(IContext* context, const ElementHandle& element, DrawPhase phase, void* userdata); + //! generation finishes. A callback that emits nothing and reserves no IDs returns @p paint_order_id unchanged. + using DrawCallback = R(*)(IContext* context, const ElementHandle& element, + DrawPhase phase, paint_order_id_t paint_order_id, void* userdata); //! Describes delayed draw behavior attached to one typeless element. struct DrawConfig @@ -171,6 +175,9 @@ namespace Luna u32 layer = INVALID_LAYER; //! Owning element index, or @ref INVALID_ELEMENT when the command is not element-scoped. u32 element = INVALID_ELEMENT; + //! Frame-local Paint Order ID used to order and batch this command, or @ref INVALID_PAINT_ORDER_ID before + //! command generation assigns an ID. + paint_order_id_t paint_order_id = INVALID_PAINT_ORDER_ID; //! Destination rectangle or clip rectangle in layer coordinates. RectF rect = RectF(0.0f, 0.0f, 0.0f, 0.0f); //! Coordinate reference used by @ref rect. @@ -194,7 +201,7 @@ namespace Luna //! @remark A positive value makes a @ref DrawCommandType::push_clip command establish an additional //! rounded clip rectangle until its matching @ref DrawCommandType::pop_clip command. f32 radius = 0.0f; - //! Line width for line commands. + //! Line width for line and rounded-rectangle stroke commands. f32 line_width = 1.0f; //! Font ID used by text commands. Name font; @@ -226,5 +233,24 @@ namespace Luna //! finite raster domain when the color program does not enable outer clipping. SDFDrawDesc sdf; }; + + //! Describes one static visual effect attached to an element. + //! @remark The command must be drawable. GUI Core overwrites its layer, element and Paint Order metadata + //! when draw commands are generated. + struct ElementVisualEffect + { + //! Drawable command template emitted for this effect. + DrawCommand command; + }; + + //! Describes ordered static visuals attached to an element. + //! @remark @ref IContext::set_element_visual_config copies both spans into context-owned frame storage. + struct ElementVisualConfig + { + //! Effects emitted before the element's child subtrees and before its before-children draw callback. + Span before_children; + //! Effects emitted after the element's child subtrees and before its after-children draw callback. + Span after_children; + }; } } diff --git a/Modules/Luna/GUI/Element.hpp b/Modules/Luna/GUI/Element.hpp index 4243bf83..7152e87f 100644 --- a/Modules/Luna/GUI/Element.hpp +++ b/Modules/Luna/GUI/Element.hpp @@ -513,6 +513,17 @@ namespace Luna bool subtree_double_clicked = false; }; + //! Specifies how Paint Order ranges are allocated to direct child subtrees. + enum class ChildPaintOrderMode : u8 + { + //! Assigns every child a non-overlapping Paint Order range in submission order. + sequential, + //! Lets direct child subtrees reuse one initial Paint Order ID. + //! @remark Select this only when the complete painted extents of the children are independent. A Paint + //! Order barrier ends the current shared run. + shared + }; + //! One typeless GUI element record. //! @remark Element behavior is defined by attached data and external algorithms, not by inheritance or virtual methods. struct Element @@ -533,6 +544,8 @@ namespace Luna u32 prev_sibling = INVALID_ELEMENT; //! Element depth in its layer tree. u32 depth = 0; + //! Paint Order allocation mode used for direct child subtrees. + ChildPaintOrderMode child_paint_order_mode = ChildPaintOrderMode::sequential; //! Style bound to this element. Name style; //! Human-readable debug name. @@ -549,7 +562,8 @@ namespace Luna u32 navigation_config = U32_MAX; //! Sparse custom hit-test configuration index owned by the context, or `U32_MAX` for rectangle hit testing. u32 hit_test_config = U32_MAX; - //! Sparse draw configuration index owned by the context, or `U32_MAX` when no callback is attached. + //! Sparse paint configuration index owned by the context, or `U32_MAX` when no callback or static + //! visual effects are attached. u32 draw_config = U32_MAX; //! Sparse backdrop blur capture index owned by the context, or `U32_MAX` when capture is disabled. u32 backdrop_blur_capture = U32_MAX; @@ -566,6 +580,12 @@ namespace Luna u32 root = INVALID_ELEMENT; //! Draw command indexes emitted by this layer. Vector draw_command_indices; + //! First frame-local Paint Order ID allocated to this layer, or @ref INVALID_PAINT_ORDER_ID before draw + //! command generation and when the generated layer is empty. + paint_order_id_t first_paint_order_id = INVALID_PAINT_ORDER_ID; + //! Maximum frame-local Paint Order ID allocated to this layer, or @ref INVALID_PAINT_ORDER_ID before draw + //! command generation and when the generated layer is empty. + paint_order_id_t max_paint_order_id = INVALID_PAINT_ORDER_ID; //! Human-readable debug name. Name debug_name; }; diff --git a/Modules/Luna/GUI/Source/Context.cpp b/Modules/Luna/GUI/Source/Context.cpp index 15d225d9..f8805064 100644 --- a/Modules/Luna/GUI/Source/Context.cpp +++ b/Modules/Luna/GUI/Source/Context.cpp @@ -58,6 +58,54 @@ namespace Luna destination.insert(destination.end(), floats.begin(), floats.end()); return first_float; } + + R next_paint_order_id(paint_order_id_t paint_order_id) + { + if(paint_order_id == INVALID_PAINT_ORDER_ID || + paint_order_id + 1 == INVALID_PAINT_ORDER_ID) + { + return set_error(E_OUT_OF_RANGE, "GUI Paint Order ID overflow."); + } + return paint_order_id + 1; + } + + bool is_paint_order_barrier(DrawCommandType type) + { + return type == DrawCommandType::backdrop_blur_capture; + } + + bool is_element_visual_command(DrawCommandType type) + { + switch(type) + { + case DrawCommandType::rect: + case DrawCommandType::gradient_rect: + case DrawCommandType::rounded_rect: + case DrawCommandType::rounded_rect_stroke: + case DrawCommandType::line: + case DrawCommandType::text: + case DrawCommandType::image: + case DrawCommandType::shape: + case DrawCommandType::shadow: + case DrawCommandType::sdf: + case DrawCommandType::backdrop_blur: + return true; + default: + return false; + } + } + + struct PaintFrame + { + u32 element = INVALID_ELEMENT; + paint_order_id_t base_paint_order_id = INVALID_PAINT_ORDER_ID; + paint_order_id_t first_barrier_paint_order_id = INVALID_PAINT_ORDER_ID; + paint_order_id_t max_paint_order_id = INVALID_PAINT_ORDER_ID; + paint_order_id_t shared_child_paint_order_id = INVALID_PAINT_ORDER_ID; + ChildPaintOrderMode child_mode = ChildPaintOrderMode::sequential; + bool has_output = false; + bool has_barrier = false; + }; } void Context::begin_frame(const FrameDesc& desc) @@ -74,9 +122,9 @@ namespace Luna m_layout_callback_configs.clear(); m_navigation_configs.clear(); m_hit_test_configs.clear(); - m_draw_configs.clear(); + m_paint_configs.clear(); + m_visual_effects.clear(); m_backdrop_blur_captures.clear(); - m_recorded_draw_commands.clear(); m_layer_draw_operations.clear(); m_draw_commands.clear(); m_recorded_sdf_shape_floats.clear(); @@ -95,6 +143,12 @@ namespace Luna m_hovered_elements.clear(); m_draw_generation_layer = INVALID_LAYER; m_draw_generation_element = INVALID_ELEMENT; + m_draw_generation_phase_first_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_first_barrier_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_max_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_has_output = false; + m_draw_generation_phase_has_barrier = false; + m_draw_generation_error = ResultCode(); m_generating_draw_commands = false; m_draw_commands_generated = false; m_counters = PerformanceCounters(); @@ -252,11 +306,6 @@ namespace Luna if(element.layer == source) element.layer = destination; else if(element.layer > source) --element.layer; } - for(DrawCommand& command : m_recorded_draw_commands) - { - if(command.layer == source) command.layer = destination; - else if(command.layer > source) --command.layer; - } for(u32& layer_index : m_layer_stack) { if(layer_index == source) layer_index = destination; @@ -738,13 +787,10 @@ namespace Luna } if(e->draw_config == U32_MAX) { - e->draw_config = (u32)m_draw_configs.size(); - m_draw_configs.push_back(config); - } - else - { - m_draw_configs[e->draw_config] = config; + e->draw_config = (u32)m_paint_configs.size(); + m_paint_configs.push_back(ElementPaintConfig()); } + m_paint_configs[e->draw_config].draw = config; m_draw_commands_generated = false; } @@ -756,11 +802,141 @@ namespace Luna return DrawConfig(); } const Element& e = m_elements[element.index]; - if(e.id != element.id || e.draw_config >= m_draw_configs.size()) + if(e.id != element.id || e.draw_config >= m_paint_configs.size()) { return DrawConfig(); } - return m_draw_configs[e.draw_config]; + return m_paint_configs[e.draw_config].draw; + } + + RV Context::set_element_visual_config(const ElementHandle& element, + const ElementVisualConfig& config) + { + lutsassert(); + luassert_msg(!m_generating_draw_commands, + "Element Visual Effects cannot change while draw commands are being generated."); + Element* e = mutable_element(element); + if(!e) + { + return ok; + } + if(e->draw_config == U32_MAX && config.before_children.empty() && + config.after_children.empty()) + { + return ok; + } + auto validate_effects = [](Span effects) -> RV + { + for(const ElementVisualEffect& effect : effects) + { + if(!is_element_visual_command(effect.command.type)) + { + return set_error(E_BAD_DATA, + "Element Visual Effects cannot contain structural draw commands."); + } + } + return ok; + }; + RV validation = validate_effects(config.before_children); + if(failed(validation)) return validation.errcode(); + validation = validate_effects(config.after_children); + if(failed(validation)) return validation.errcode(); + u64 num_new_effects = (u64)config.before_children.size() + + (u64)config.after_children.size(); + if(num_new_effects > U32_MAX || + (u64)m_visual_effects.size() + num_new_effects > U32_MAX) + { + return set_error(E_OUT_OF_RANGE, + "Element Visual Effect storage exceeds the 32-bit range."); + } + auto aliases_effect_storage = [&](Span effects) + { + if(effects.empty() || m_visual_effects.empty()) return false; + usize storage_begin = (usize)m_visual_effects.data(); + usize storage_end = storage_begin + + m_visual_effects.size() * sizeof(ElementVisualEffect); + usize effects_begin = (usize)effects.data(); + return effects_begin >= storage_begin && effects_begin < storage_end; + }; + Vector before_copy; + Vector after_copy; + Span before_effects = config.before_children; + Span after_effects = config.after_children; + if(aliases_effect_storage(before_effects)) + { + before_copy.insert(before_copy.end(), before_effects.begin(), before_effects.end()); + before_effects = before_copy.cspan(); + } + if(aliases_effect_storage(after_effects)) + { + after_copy.insert(after_copy.end(), after_effects.begin(), after_effects.end()); + after_effects = after_copy.cspan(); + } + if(e->draw_config == U32_MAX) + { + e->draw_config = (u32)m_paint_configs.size(); + m_paint_configs.push_back(ElementPaintConfig()); + } + ElementPaintConfig& paint = m_paint_configs[e->draw_config]; + auto copy_effects = [&](Span effects) + { + ElementVisualEffectRange range; + range.first_effect = (u32)m_visual_effects.size(); + range.num_effects = (u32)effects.size(); + for(const ElementVisualEffect& effect : effects) + { + m_visual_effects.push_back(effect); + } + return range; + }; + paint.before_children = copy_effects(before_effects); + paint.after_children = copy_effects(after_effects); + m_draw_commands_generated = false; + return ok; + } + + ElementVisualConfig Context::get_element_visual_config( + const ElementHandle& element) const + { + lutsassert(); + ElementVisualConfig config; + if(!element.id || element.generation != m_generation || + element.index >= m_elements.size()) + { + return config; + } + const Element& e = m_elements[element.index]; + if(e.id != element.id || e.draw_config >= m_paint_configs.size()) + { + return config; + } + auto get_effects = [&](const ElementVisualEffectRange& range) + { + if(!range.num_effects || + (u64)range.first_effect + range.num_effects > m_visual_effects.size()) + { + return Span(); + } + return Span( + m_visual_effects.data() + range.first_effect, range.num_effects); + }; + const ElementPaintConfig& paint = m_paint_configs[e.draw_config]; + config.before_children = get_effects(paint.before_children); + config.after_children = get_effects(paint.after_children); + return config; + } + + void Context::set_child_paint_order_mode(const ElementHandle& element, + ChildPaintOrderMode mode) + { + lutsassert(); + luassert_msg(!m_generating_draw_commands, + "Child Paint Order mode cannot change while callbacks are running."); + if(Element* e = mutable_element(element)) + { + e->child_paint_order_mode = mode; + m_draw_commands_generated = false; + } } void Context::set_backdrop_blur_capture(const ElementHandle& element, @@ -854,26 +1030,61 @@ namespace Luna return m_sdf_color_floats.cspan(); } - void Context::append_draw_command(u32 layer_index, u32 element_index, const DrawCommand& command) + void Context::append_draw_command(u32 layer_index, u32 element_index, const DrawCommand& command, + paint_order_id_t paint_order_id) { DrawCommand cmd = command; cmd.layer = layer_index; cmd.element = element_index; + cmd.paint_order_id = paint_order_id; u32 command_index = (u32)m_draw_commands.size(); Layer& layer = m_layers[layer_index]; layer.draw_command_indices.push_back(command_index); m_draw_commands.push_back(move(cmd)); } - void Context::record_static_draw_command(u32 layer_index, u32 element_index, const DrawCommand& command) + RV Context::emit_element_visual_effects(u32 layer_index, u32 element_index, DrawPhase phase, + paint_order_id_t paint_order_id, paint_order_id_t& max_paint_order_id, + bool& has_output) { - DrawCommand recorded = command; - recorded.layer = layer_index; - recorded.element = element_index; - u32 command_index = (u32)m_recorded_draw_commands.size(); - m_recorded_draw_commands.push_back(move(recorded)); - m_layer_draw_operations[layer_index].push_back(DrawOperation { DrawOperationType::static_command, command_index }); - m_draw_commands_generated = false; + max_paint_order_id = paint_order_id; + has_output = false; + if(element_index >= m_elements.size()) + { + return ok; + } + const Element& element = m_elements[element_index]; + if(element.draw_config >= m_paint_configs.size()) + { + return ok; + } + const ElementPaintConfig& paint = m_paint_configs[element.draw_config]; + const ElementVisualEffectRange& range = phase == DrawPhase::before_children ? + paint.before_children : paint.after_children; + if(!range.num_effects) + { + return ok; + } + if((u64)range.first_effect + range.num_effects > m_visual_effects.size()) + { + return set_error(E_BAD_DATA, "Element Visual Effect range is invalid."); + } + paint_order_id_t current_paint_order_id = paint_order_id; + for(u32 i = 0; i < range.num_effects; ++i) + { + const ElementVisualEffect& effect = m_visual_effects[range.first_effect + i]; + append_draw_command(layer_index, element_index, effect.command, + current_paint_order_id); + max_paint_order_id = current_paint_order_id; + has_output = true; + if(i + 1 < range.num_effects) + { + R next = next_paint_order_id(current_paint_order_id); + if(failed(next)) return next.errcode(); + current_paint_order_id = next.get(); + } + } + return ok; } void Context::reset_generated_draw_commands() @@ -884,21 +1095,28 @@ namespace Luna for(Layer& layer : m_layers) { layer.draw_command_indices.clear(); + layer.first_paint_order_id = INVALID_PAINT_ORDER_ID; + layer.max_paint_order_id = INVALID_PAINT_ORDER_ID; } } - RV Context::invoke_draw_callback(u32 layer_index, u32 element_index, DrawPhase phase) + RV Context::invoke_draw_callback(u32 layer_index, u32 element_index, DrawPhase phase, + paint_order_id_t paint_order_id, paint_order_id_t first_barrier_paint_order_id, + paint_order_id_t& max_paint_order_id, bool& has_output, bool& has_barrier) { + max_paint_order_id = paint_order_id; + has_output = false; + has_barrier = false; if(element_index >= m_elements.size()) { return ok; } const Element& element = m_elements[element_index]; - if(element.draw_config >= m_draw_configs.size()) + if(element.draw_config >= m_paint_configs.size()) { return ok; } - const DrawConfig& config = m_draw_configs[element.draw_config]; + const DrawConfig& config = m_paint_configs[element.draw_config].draw; DrawPhaseFlag required_phase = phase == DrawPhase::before_children ? DrawPhaseFlag::before_children : DrawPhaseFlag::after_children; if(!config.callback || !test_flags(config.phases, required_phase)) @@ -907,8 +1125,49 @@ namespace Luna } m_draw_generation_layer = layer_index; m_draw_generation_element = element_index; + m_draw_generation_phase_first_paint_order_id = paint_order_id; + m_draw_generation_phase_first_barrier_paint_order_id = first_barrier_paint_order_id; + m_draw_generation_phase_max_paint_order_id = paint_order_id; + m_draw_generation_phase_has_output = false; + m_draw_generation_phase_has_barrier = false; + m_draw_generation_error = ResultCode(); ++m_counters.draw_callback_count; - return config.callback(this, ElementHandle { element.id, element_index, m_generation }, phase, config.userdata); + R result = config.callback(this, + ElementHandle { element.id, element_index, m_generation }, phase, paint_order_id, + config.userdata); + const paint_order_id_t emitted_max = m_draw_generation_phase_max_paint_order_id; + const bool emitted = m_draw_generation_phase_has_output; + const bool emitted_barrier = m_draw_generation_phase_has_barrier; + const ResultCode generation_error = m_draw_generation_error; + m_draw_generation_phase_first_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_first_barrier_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_max_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_has_output = false; + m_draw_generation_phase_has_barrier = false; + m_draw_generation_error = ResultCode(); + if(failed(generation_error)) + { + return generation_error; + } + if(failed(result)) + { + return result.errcode(); + } + paint_order_id_t returned_max = result.get(); + if(returned_max == INVALID_PAINT_ORDER_ID || returned_max < paint_order_id) + { + return set_error(E_BAD_DATA, + "A GUI draw callback returned a Paint Order ID below its phase input."); + } + if(emitted && returned_max < emitted_max) + { + return set_error(E_BAD_DATA, + "A GUI draw callback returned a Paint Order ID below a command it emitted."); + } + max_paint_order_id = emitted ? max(returned_max, emitted_max) : returned_max; + has_output = emitted || returned_max > paint_order_id; + has_barrier = emitted_barrier; + return ok; } RV Context::generate_draw_commands() @@ -926,8 +1185,82 @@ namespace Luna m_counters.draw_callback_count = 0; m_generating_draw_commands = true; m_draw_commands_generated = false; + m_draw_generation_error = ResultCode(); + + Vector subtree_has_known_barrier; + subtree_has_known_barrier.resize(m_elements.size(), 0); + for(usize i = m_elements.size(); i > 0; --i) + { + u32 element_index = (u32)i - 1; + const Element& element = m_elements[element_index]; + if(element.backdrop_blur_capture < m_backdrop_blur_captures.size()) + { + subtree_has_known_barrier[element_index] = 1; + } + if(subtree_has_known_barrier[element_index] && + element.parent < m_elements.size()) + { + subtree_has_known_barrier[element.parent] = 1; + } + } + + auto finish_generation_failure = [&](ResultCode error) -> RV + { + m_generating_draw_commands = false; + m_draw_generation_layer = INVALID_LAYER; + m_draw_generation_element = INVALID_ELEMENT; + m_draw_generation_phase_first_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_first_barrier_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_max_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_has_output = false; + m_draw_generation_phase_has_barrier = false; + m_draw_generation_error = ResultCode(); + m_counters.draw_generate_ms = perf_elapsed_ms(generate_begin, get_ticks()); + return error; + }; + + auto first_free_paint_order_id = [&](const PaintFrame& frame) -> R + { + if(!frame.has_output) + { + return frame.base_paint_order_id; + } + return next_paint_order_id(frame.max_paint_order_id); + }; + + auto allocate_child_paint_order_id = [&](PaintFrame& parent, + bool child_has_known_barrier) -> R + { + if(child_has_known_barrier) + { + parent.shared_child_paint_order_id = INVALID_PAINT_ORDER_ID; + R first = first_free_paint_order_id(parent); + if(failed(first)) return first.errcode(); + return max(first.get(), parent.first_barrier_paint_order_id); + } + if(parent.child_mode == ChildPaintOrderMode::shared) + { + if(parent.shared_child_paint_order_id == INVALID_PAINT_ORDER_ID) + { + R first = first_free_paint_order_id(parent); + if(failed(first)) return first.errcode(); + parent.shared_child_paint_order_id = first.get(); + } + return parent.shared_child_paint_order_id; + } + return first_free_paint_order_id(parent); + }; + + paint_order_id_t next_layer_paint_order_id = 0; for(u32 layer_index = 0; layer_index < m_layer_draw_operations.size(); ++layer_index) { + Layer& layer = m_layers[layer_index]; + layer.first_paint_order_id = INVALID_PAINT_ORDER_ID; + PaintFrame layer_frame; + layer_frame.base_paint_order_id = next_layer_paint_order_id; + layer_frame.first_barrier_paint_order_id = next_layer_paint_order_id; + layer_frame.max_paint_order_id = next_layer_paint_order_id; + Vector paint_stack; for(const DrawOperation& operation : m_layer_draw_operations[layer_index]) { RV result = ok; @@ -937,6 +1270,28 @@ namespace Luna if(operation.index < m_elements.size()) { const Element& element = m_elements[operation.index]; + PaintFrame& parent = paint_stack.empty() ? layer_frame : paint_stack.back(); + R first_barrier = first_free_paint_order_id(parent); + if(failed(first_barrier)) + { + return finish_generation_failure(first_barrier.errcode()); + } + paint_order_id_t inherited_first_barrier = max(first_barrier.get(), + parent.first_barrier_paint_order_id); + R first = allocate_child_paint_order_id(parent, + subtree_has_known_barrier[operation.index] != 0); + if(failed(first)) + { + return finish_generation_failure(first.errcode()); + } + PaintFrame frame; + frame.element = operation.index; + frame.base_paint_order_id = first.get(); + frame.first_barrier_paint_order_id = inherited_first_barrier; + frame.max_paint_order_id = first.get(); + frame.child_mode = element.child_paint_order_mode; + paint_stack.push_back(frame); + PaintFrame& current = paint_stack.back(); if(element.backdrop_blur_capture < m_backdrop_blur_captures.size()) { DrawCommand marker; @@ -945,71 +1300,192 @@ namespace Luna marker.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); marker.backdrop_blur_capture = m_backdrop_blur_captures[element.backdrop_blur_capture]; - append_draw_command(layer_index, operation.index, marker); + append_draw_command(layer_index, operation.index, marker, + current.base_paint_order_id); + current.has_output = true; + current.has_barrier = true; + current.max_paint_order_id = current.base_paint_order_id; + } + R visual_first = first_free_paint_order_id(current); + if(failed(visual_first)) + { + return finish_generation_failure(visual_first.errcode()); + } + paint_order_id_t visual_max = visual_first.get(); + bool visual_has_output = false; + result = emit_element_visual_effects(layer_index, operation.index, + DrawPhase::before_children, visual_first.get(), visual_max, + visual_has_output); + if(failed(result)) break; + if(visual_has_output) + { + current.max_paint_order_id = max(current.max_paint_order_id, + visual_max); + current.has_output = true; + } + paint_order_id_t callback_order = current.base_paint_order_id; + if(current.has_output) + { + R next = next_paint_order_id(current.max_paint_order_id); + if(failed(next)) + { + return finish_generation_failure(next.errcode()); + } + callback_order = next.get(); + } + paint_order_id_t callback_max = callback_order; + bool callback_has_output = false; + bool callback_has_barrier = false; + paint_order_id_t first_barrier_callback_order = current.has_output ? + callback_order : current.first_barrier_paint_order_id; + result = invoke_draw_callback(layer_index, operation.index, + DrawPhase::before_children, callback_order, + first_barrier_callback_order, callback_max, + callback_has_output, callback_has_barrier); + if(succeeded(result) && callback_has_output) + { + current.max_paint_order_id = max(current.max_paint_order_id, callback_max); + current.has_output = true; + } + if(callback_has_barrier) + { + current.has_barrier = true; + current.shared_child_paint_order_id = INVALID_PAINT_ORDER_ID; } } - result = invoke_draw_callback(layer_index, operation.index, DrawPhase::before_children); break; - case DrawOperationType::static_command: - if(operation.index < m_recorded_draw_commands.size()) + case DrawOperationType::end_element: + if(operation.index < m_elements.size() && !paint_stack.empty()) { - const DrawCommand& command = m_recorded_draw_commands[operation.index]; - append_draw_command(command.layer, command.element, command); + PaintFrame& current = paint_stack.back(); + luassert(current.element == operation.index); + R visual_first = first_free_paint_order_id(current); + if(failed(visual_first)) + { + return finish_generation_failure(visual_first.errcode()); + } + paint_order_id_t visual_max = visual_first.get(); + bool visual_has_output = false; + result = emit_element_visual_effects(layer_index, operation.index, + DrawPhase::after_children, visual_first.get(), visual_max, + visual_has_output); + if(failed(result)) break; + if(visual_has_output) + { + current.max_paint_order_id = max(current.max_paint_order_id, + visual_max); + current.has_output = true; + } + R first = first_free_paint_order_id(current); + if(failed(first)) + { + return finish_generation_failure(first.errcode()); + } + paint_order_id_t callback_max = first.get(); + bool callback_has_output = false; + bool callback_has_barrier = false; + paint_order_id_t first_barrier_callback_order = max(first.get(), + current.first_barrier_paint_order_id); + result = invoke_draw_callback(layer_index, operation.index, + DrawPhase::after_children, first.get(), first_barrier_callback_order, callback_max, + callback_has_output, callback_has_barrier); + if(succeeded(result) && callback_has_output) + { + current.max_paint_order_id = max(current.max_paint_order_id, callback_max); + current.has_output = true; + } + if(callback_has_barrier) + { + current.has_barrier = true; + current.shared_child_paint_order_id = INVALID_PAINT_ORDER_ID; + } + PaintFrame finished = current; + paint_stack.pop_back(); + PaintFrame& parent = paint_stack.empty() ? layer_frame : paint_stack.back(); + if(finished.has_output) + { + parent.max_paint_order_id = max(parent.max_paint_order_id, + finished.max_paint_order_id); + parent.has_output = true; + } + if(finished.has_barrier) + { + parent.has_barrier = true; + parent.shared_child_paint_order_id = INVALID_PAINT_ORDER_ID; + } } break; - case DrawOperationType::end_element: - result = invoke_draw_callback(layer_index, operation.index, DrawPhase::after_children); - break; } if(failed(result)) { - m_generating_draw_commands = false; - m_draw_generation_layer = INVALID_LAYER; - m_draw_generation_element = INVALID_ELEMENT; - m_counters.draw_generate_ms = perf_elapsed_ms(generate_begin, get_ticks()); - return result; + return finish_generation_failure(result.errcode()); + } + } + luassert(paint_stack.empty()); + if(layer_frame.has_output) + { + layer.first_paint_order_id = layer_frame.base_paint_order_id; + layer.max_paint_order_id = layer_frame.max_paint_order_id; + if(layer_index + 1 < m_layer_draw_operations.size()) + { + R next = next_paint_order_id(layer.max_paint_order_id); + if(failed(next)) + { + return finish_generation_failure(next.errcode()); + } + next_layer_paint_order_id = next.get(); } } + else + { + layer.max_paint_order_id = INVALID_PAINT_ORDER_ID; + } } m_generating_draw_commands = false; m_draw_generation_layer = INVALID_LAYER; m_draw_generation_element = INVALID_ELEMENT; + m_draw_generation_phase_first_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_first_barrier_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_max_paint_order_id = INVALID_PAINT_ORDER_ID; + m_draw_generation_phase_has_output = false; + m_draw_generation_phase_has_barrier = false; + m_draw_generation_error = ResultCode(); m_draw_commands_generated = true; m_counters.draw_generate_ms = perf_elapsed_ms(generate_begin, get_ticks()); return ok; } - void Context::draw(const DrawCommand& command) + void Context::draw(const DrawCommand& command, paint_order_id_t paint_order_id) { lutsassert(); - if(m_generating_draw_commands) - { - luassert(m_draw_generation_layer < m_layers.size()); - append_draw_command(m_draw_generation_layer, m_draw_generation_element, command); - return; - } - luassert(!m_layer_stack.empty()); - u32 layer_index = m_layer_stack.back(); - u32 element_index = m_element_stack.empty() ? INVALID_ELEMENT : m_element_stack.back(); - record_static_draw_command(layer_index, element_index, command); - } - - void Context::draw_for_element(const ElementHandle& element, const DrawCommand& command) - { - lutsassert(); - Element* e = mutable_element(element); - if(!e || e->layer >= m_layers.size()) + luassert_msg(m_generating_draw_commands, + "Explicit Paint Order IDs may only be used while GUI draw callbacks are running."); + luassert(m_draw_generation_layer < m_layers.size()); + luassert(m_draw_generation_phase_first_paint_order_id != INVALID_PAINT_ORDER_ID); + luassert(m_draw_generation_phase_first_barrier_paint_order_id != INVALID_PAINT_ORDER_ID); + if(failed(m_draw_generation_error)) return; + if(paint_order_id == INVALID_PAINT_ORDER_ID || + paint_order_id < m_draw_generation_phase_first_paint_order_id) { + m_draw_generation_error = E_BAD_DATA; return; } - if(m_generating_draw_commands) + const bool barrier = is_paint_order_barrier(command.type); + if((barrier && paint_order_id < m_draw_generation_phase_first_barrier_paint_order_id) || + (m_draw_generation_phase_has_barrier && + paint_order_id <= m_draw_generation_phase_max_paint_order_id) || + (barrier && m_draw_generation_phase_has_output && + paint_order_id <= m_draw_generation_phase_max_paint_order_id)) { - luassert_msg(e->layer == m_draw_generation_layer, - "A draw callback cannot emit commands into a different layer."); - append_draw_command(e->layer, element.index, command); + m_draw_generation_error = E_BAD_DATA; return; } - record_static_draw_command(e->layer, element.index, command); + append_draw_command(m_draw_generation_layer, m_draw_generation_element, command, + paint_order_id); + m_draw_generation_phase_max_paint_order_id = max( + m_draw_generation_phase_max_paint_order_id, paint_order_id); + m_draw_generation_phase_has_output = true; + m_draw_generation_phase_has_barrier |= barrier; } RectF Context::to_screen_rect(u32 layer_index, const RectF& rect) const diff --git a/Modules/Luna/GUI/Source/GUI.hpp b/Modules/Luna/GUI/Source/GUI.hpp index f09523c3..c592d49a 100644 --- a/Modules/Luna/GUI/Source/GUI.hpp +++ b/Modules/Luna/GUI/Source/GUI.hpp @@ -39,16 +39,28 @@ namespace Luna enum class DrawOperationType : u8 { begin_element, - static_command, end_element }; struct DrawOperation { - DrawOperationType type = DrawOperationType::static_command; + DrawOperationType type = DrawOperationType::begin_element; u32 index = U32_MAX; }; + struct ElementVisualEffectRange + { + u32 first_effect = 0; + u32 num_effects = 0; + }; + + struct ElementPaintConfig + { + DrawConfig draw; + ElementVisualEffectRange before_children; + ElementVisualEffectRange after_children; + }; + struct [[Luna::struct("{5D63E090-946C-4941-8452-F11277682199}")]] ScrollViewportHistoryState { RectF visible_rect = RectF(0.0f, 0.0f, 0.0f, 0.0f); @@ -66,9 +78,9 @@ namespace Luna Vector m_layout_callback_configs; Vector m_navigation_configs; Vector m_hit_test_configs; - Vector m_draw_configs; + Vector m_paint_configs; + Vector m_visual_effects; Vector m_backdrop_blur_captures; - Vector m_recorded_draw_commands; Vector> m_layer_draw_operations; Vector m_draw_commands; Vector m_recorded_sdf_shape_floats; @@ -107,6 +119,12 @@ namespace Luna u32 m_generation = 0; u32 m_draw_generation_layer = INVALID_LAYER; u32 m_draw_generation_element = INVALID_ELEMENT; + paint_order_id_t m_draw_generation_phase_first_paint_order_id = INVALID_PAINT_ORDER_ID; + paint_order_id_t m_draw_generation_phase_first_barrier_paint_order_id = INVALID_PAINT_ORDER_ID; + paint_order_id_t m_draw_generation_phase_max_paint_order_id = INVALID_PAINT_ORDER_ID; + bool m_draw_generation_phase_has_output = false; + bool m_draw_generation_phase_has_barrier = false; + ResultCode m_draw_generation_error = ResultCode(); bool m_generating_draw_commands = false; bool m_draw_commands_generated = false; PerformanceCounters m_counters; @@ -157,6 +175,12 @@ namespace Luna virtual void set_element_debug_name(const ElementHandle& element, const Name& name) override; virtual void set_draw_config(const ElementHandle& element, const DrawConfig& config) override; virtual DrawConfig get_draw_config(const ElementHandle& element) const override; + virtual RV set_element_visual_config(const ElementHandle& element, + const ElementVisualConfig& config) override; + virtual ElementVisualConfig get_element_visual_config( + const ElementHandle& element) const override; + virtual void set_child_paint_order_mode(const ElementHandle& element, + ChildPaintOrderMode mode) override; virtual void set_backdrop_blur_capture(const ElementHandle& element, const BackdropBlurCaptureDesc& desc) override; virtual BackdropBlurCaptureDesc get_backdrop_blur_capture( @@ -167,8 +191,7 @@ namespace Luna virtual R append_sdf_color_program(Span floats) override; virtual Span get_sdf_shape_floats() const override; virtual Span get_sdf_color_floats() const override; - virtual void draw(const DrawCommand& command) override; - virtual void draw_for_element(const ElementHandle& element, const DrawCommand& command) override; + virtual void draw(const DrawCommand& command, paint_order_id_t paint_order_id) override; virtual RV register_font(const Name& id, Font::IFontFile* font, u32 font_index = 0) override; virtual FontDesc get_font(const Name& id) override; virtual void set_clipboard_io(const ClipboardIO& io) override; @@ -214,10 +237,15 @@ namespace Luna InteractionState& get_or_create_interaction(id_t id); void mark_subtree_interaction(id_t id, bool hovered, bool active, bool focused, bool clicked, bool double_clicked); void deliver_input_event(id_t id, const InputEvent& event); - void append_draw_command(u32 layer_index, u32 element_index, const DrawCommand& command); - void record_static_draw_command(u32 layer_index, u32 element_index, const DrawCommand& command); + void append_draw_command(u32 layer_index, u32 element_index, const DrawCommand& command, + paint_order_id_t paint_order_id); + RV emit_element_visual_effects(u32 layer_index, u32 element_index, DrawPhase phase, + paint_order_id_t paint_order_id, paint_order_id_t& max_paint_order_id, + bool& has_output); void reset_generated_draw_commands(); - RV invoke_draw_callback(u32 layer_index, u32 element_index, DrawPhase phase); + RV invoke_draw_callback(u32 layer_index, u32 element_index, DrawPhase phase, + paint_order_id_t paint_order_id, paint_order_id_t first_barrier_paint_order_id, + paint_order_id_t& max_paint_order_id, bool& has_output, bool& has_barrier); bool point_hits_element(const Element& element, const Float2U& screen_position) const; bool element_can_focus(const Element& element) const; id_t focus_scope_of(id_t element_id) const; diff --git a/Modules/Luna/GUI/Source/Renderer.cpp b/Modules/Luna/GUI/Source/Renderer.cpp index 50f3cd6b..1a0c85aa 100644 --- a/Modules/Luna/GUI/Source/Renderer.cpp +++ b/Modules/Luna/GUI/Source/Renderer.cpp @@ -11,6 +11,8 @@ #define LUNA_GUI_API LUNA_EXPORT #include "RendererImpl.hpp" #include +#include +#include #include #include #include @@ -18,6 +20,7 @@ #include #include #include +#include #include namespace Luna @@ -240,11 +243,139 @@ namespace Luna struct ClipState { - RectF rect; - RectF rounded_rect; + RectF rect = RectF(0.0f, 0.0f, 0.0f, 0.0f); + RectF rounded_rect = RectF(0.0f, 0.0f, 0.0f, 0.0f); Float4U rounded_radii = Float4U(0.0f); }; + enum class ResolvedDrawEventType : u8 + { + draw, + backdrop_capture + }; + + bool vg_sampler_desc_equal(const RHI::SamplerDesc& lhs, + const RHI::SamplerDesc& rhs) + { + return lhs.min_filter == rhs.min_filter && + lhs.mag_filter == rhs.mag_filter && + lhs.mip_filter == rhs.mip_filter && + lhs.address_u == rhs.address_u && + lhs.address_v == rhs.address_v && + lhs.address_w == rhs.address_w && + lhs.anisotropy_enable == rhs.anisotropy_enable && + lhs.compare_enable == rhs.compare_enable && + lhs.compare_function == rhs.compare_function && + lhs.border_color == rhs.border_color && + lhs.max_anisotropy == rhs.max_anisotropy && + lhs.min_lod == rhs.min_lod && lhs.max_lod == rhs.max_lod; + } + + usize vg_sampler_desc_hash(const RHI::SamplerDesc& sampler, usize seed) + { + seed = memhash(&sampler.min_filter, sizeof(sampler.min_filter), seed); + seed = memhash(&sampler.mag_filter, sizeof(sampler.mag_filter), seed); + seed = memhash(&sampler.mip_filter, sizeof(sampler.mip_filter), seed); + seed = memhash(&sampler.address_u, sizeof(sampler.address_u), seed); + seed = memhash(&sampler.address_v, sizeof(sampler.address_v), seed); + seed = memhash(&sampler.address_w, sizeof(sampler.address_w), seed); + seed = memhash(&sampler.anisotropy_enable, + sizeof(sampler.anisotropy_enable), seed); + seed = memhash(&sampler.compare_enable, + sizeof(sampler.compare_enable), seed); + seed = memhash(&sampler.compare_function, + sizeof(sampler.compare_function), seed); + seed = memhash(&sampler.border_color, + sizeof(sampler.border_color), seed); + seed = memhash(&sampler.max_anisotropy, + sizeof(sampler.max_anisotropy), seed); + f32 min_lod = sampler.min_lod == 0.0f ? 0.0f : sampler.min_lod; + f32 max_lod = sampler.max_lod == 0.0f ? 0.0f : sampler.max_lod; + seed = memhash(&min_lod, sizeof(min_lod), seed); + return memhash(&max_lod, sizeof(max_lod), seed); + } + + struct ResolvedDrawEvent + { + ResolvedDrawEventType type = ResolvedDrawEventType::draw; + paint_order_id_t paint_order_id = INVALID_PAINT_ORDER_ID; + u32 submission_index = 0; + u32 command_index = U32_MAX; + u32 capture_index = U32_MAX; + ClipState clip; + ClipState overflow_clip; + }; + + struct VGBatchKey + { + VG::IShapeBuffer* shape_buffer = nullptr; + RHI::ITexture* texture = nullptr; + RHI::SamplerDesc sampler; + + VGBatchKey() : + sampler(RHI::Filter::linear, RHI::Filter::linear, + RHI::Filter::linear, RHI::TextureAddressMode::repeat, + RHI::TextureAddressMode::repeat, + RHI::TextureAddressMode::repeat) {} + + bool operator==(const VGBatchKey& rhs) const + { + return shape_buffer == rhs.shape_buffer && + texture == rhs.texture && + vg_sampler_desc_equal(sampler, rhs.sampler); + } + }; + + struct PreparedBatchKey + { + RenderBatchType type = RenderBatchType::vg; + VGBatchKey vg; + u32 sdf_page_pair = U32_MAX; + + bool operator==(const PreparedBatchKey& rhs) const + { + if(type != rhs.type) return false; + if(type == RenderBatchType::vg) return vg == rhs.vg; + return sdf_page_pair == rhs.sdf_page_pair; + } + }; + + struct PreparedBatchKeyHash + { + usize operator()(const PreparedBatchKey& key) const + { + usize result = memhash(&key.type, sizeof(key.type)); + if(key.type == RenderBatchType::vg) + { + result = memhash(&key.vg.shape_buffer, + sizeof(key.vg.shape_buffer), result); + result = memhash(&key.vg.texture, + sizeof(key.vg.texture), result); + return vg_sampler_desc_hash(key.vg.sampler, result); + } + return memhash(&key.sdf_page_pair, + sizeof(key.sdf_page_pair), result); + } + }; + + struct PreparedDraw + { + PreparedBatchKey key; + u32 command_index = U32_MAX; + u32 capture_index = U32_MAX; + RectF resolved_rect = RectF(0.0f, 0.0f, 0.0f, 0.0f); + ClipState clip; + SDFDrawDesc sdf; + Float2U sdf_evaluation_origin = Float2U(0.0f); + RectF sdf_raster_domain = RectF(0.0f, 0.0f, 0.0f, 0.0f); + }; + + struct PreparedBatch + { + PreparedBatchKey key; + Vector draw_indices; + }; + bool has_rounded_clip(const ClipState& clip) { return has_clip(clip.rounded_rect); @@ -437,8 +568,12 @@ namespace Luna BufferDesc(BufferUsageFlag::vertex_buffer, sizeof(vertices)))); luset(m_sdf_index_buffer, m_device->new_buffer(MemoryType::upload, BufferDesc(BufferUsageFlag::index_buffer, sizeof(indices)))); + usize sdf_frame_buffer_size = align_upper( + sizeof(SDFFrameBuffer), + m_device->check_feature(DeviceFeature::uniform_buffer_data_alignment). + uniform_buffer_data_alignment); luset(m_sdf_frame_buffer, m_device->new_buffer(MemoryType::upload, - BufferDesc(BufferUsageFlag::uniform_buffer, sizeof(SDFFrameBuffer)))); + BufferDesc(BufferUsageFlag::uniform_buffer, sdf_frame_buffer_size))); void* mapped_data = nullptr; luexp(m_sdf_vertex_buffer->map(0, 0, &mapped_data)); memcpy(mapped_data, vertices, sizeof(vertices)); @@ -506,81 +641,143 @@ namespace Luna Span commands = context->get_draw_commands(); Span context_shape_floats = context->get_sdf_shape_floats(); Span context_color_floats = context->get_sdf_color_floats(); - Vector command_capture_indices; - command_capture_indices.resize(commands.size()); - for(u32& index : command_capture_indices) index = U32_MAX; Vector element_capture_indices; element_capture_indices.resize(elements.size()); for(u32& index : element_capture_indices) index = U32_MAX; + Vector capture_paint_orders; + Vector resolved_events; + resolved_events.reserve(commands.size()); m_num_backdrop_captures = 0; + Vector layer_command_positions; + layer_command_positions.resize(layers.size()); + for(u32& position : layer_command_positions) position = 0; Vector capture_clip_stack; - for(u32 layer_index = 0; layer_index < layers.size(); ++layer_index) + u32 lexical_layer = U32_MAX; + for(u32 command_index = 0; command_index < commands.size(); ++command_index) { - capture_clip_stack.clear(); + const DrawCommand& command = commands[command_index]; + if(command.layer >= layers.size()) + { + return set_error(E_BAD_DATA, + "A GUI command refers to an invalid owning layer."); + } + if(command.layer != lexical_layer) + { + if(!capture_clip_stack.empty()) + { + return set_error(E_BAD_DATA, + "A GUI clip scope cannot cross an owning layer boundary."); + } + if(lexical_layer != U32_MAX && command.layer < lexical_layer) + { + return set_error(E_BAD_DATA, + "The lexical GUI command stream is not ordered by owning layer."); + } + lexical_layer = command.layer; + } + u32 layer_index = command.layer; + u32& layer_command_position = layer_command_positions[layer_index]; const Layer& layer = layers[layer_index]; - for(u32 command_index : layer.draw_command_indices) + if(layer_command_position >= layer.draw_command_indices.size() || + layer.draw_command_indices[layer_command_position] != command_index) + { + return set_error(E_BAD_DATA, + "A GUI command is missing from its owning layer's lexical index."); + } + ++layer_command_position; + RectF resolved_rect = resolve_draw_rect(command, elements); + RectF element_clip(0.0f, 0.0f, 0.0f, 0.0f); + if(command.element != INVALID_ELEMENT && command.element < elements.size()) + { + element_clip = to_screen_rect(layers, layer_index, + elements[command.element].layout_result.clip_rect); + } + if(command.type == DrawCommandType::push_clip) { - if(command_index >= commands.size()) continue; - const DrawCommand& command = commands[command_index]; - RectF resolved_rect = resolve_draw_rect(command, elements); - RectF element_clip; - if(command.element != INVALID_ELEMENT && command.element < elements.size()) + RectF requested_clip = to_screen_rect(layers, layer_index, resolved_rect); + ClipState pushed_clip = capture_clip_stack.empty() ? + ClipState() : capture_clip_stack.back(); + pushed_clip.rect = merge_clip_rect(pushed_clip.rect, element_clip); + pushed_clip.rect = merge_clip_rect(pushed_clip.rect, requested_clip); + if(command.radius > 0.0f) { - element_clip = to_screen_rect(layers, layer_index, - elements[command.element].layout_result.clip_rect); + pushed_clip.rounded_rect = requested_clip; + pushed_clip.rounded_radii = Float4U(command.radius); } - if(command.type == DrawCommandType::push_clip) + capture_clip_stack.push_back(pushed_clip); + continue; + } + if(command.type == DrawCommandType::pop_clip) + { + if(capture_clip_stack.empty()) { - RectF requested_clip = to_screen_rect(layers, layer_index, resolved_rect); - ClipState pushed_clip = capture_clip_stack.empty() ? - ClipState() : capture_clip_stack.back(); - pushed_clip.rect = merge_clip_rect(pushed_clip.rect, element_clip); - pushed_clip.rect = merge_clip_rect(pushed_clip.rect, requested_clip); - if(command.radius > 0.0f) - { - pushed_clip.rounded_rect = requested_clip; - pushed_clip.rounded_radii = Float4U(command.radius); - } - capture_clip_stack.push_back(pushed_clip); - continue; + return set_error(E_BAD_DATA, + "A GUI clip pop has no matching lexical clip push."); } - if(command.type == DrawCommandType::pop_clip) + capture_clip_stack.pop_back(); + continue; + } + if(command.paint_order_id == INVALID_PAINT_ORDER_ID) + { + return set_error(E_BAD_DATA, + "Every drawable GUI command must have a valid Paint Order ID."); + } + if(command.type == DrawCommandType::backdrop_blur_capture) + { + if(command.element == INVALID_ELEMENT || + command.element >= elements.size()) { - if(!capture_clip_stack.empty()) capture_clip_stack.pop_back(); - continue; + return set_error(E_BAD_DATA, + "A backdrop capture marker must belong to an element."); } - if(command.type == DrawCommandType::backdrop_blur_capture) + if(m_num_backdrop_captures >= m_backdrop_captures.size()) { - if(command.element == INVALID_ELEMENT || - command.element >= elements.size()) - { - return set_error(E_BAD_DATA, - "A backdrop capture marker must belong to an element."); - } - if(m_num_backdrop_captures >= m_backdrop_captures.size()) - { - m_backdrop_captures.push_back(BackdropCapture()); - } - u32 capture_index = m_num_backdrop_captures++; - BackdropCapture& capture = m_backdrop_captures[capture_index]; - capture.element = command.element; - capture.desc = command.backdrop_blur_capture; - capture.consumer_bounds = RectF(); - capture.source_rect = RectF(); - capture.used = false; - element_capture_indices[command.element] = capture_index; - command_capture_indices[command_index] = capture_index; - continue; + m_backdrop_captures.push_back(BackdropCapture()); + } + u32 capture_index = m_num_backdrop_captures++; + BackdropCapture& capture = m_backdrop_captures[capture_index]; + capture.element = command.element; + capture.desc = command.backdrop_blur_capture; + capture.consumer_bounds = RectF(0.0f, 0.0f, 0.0f, 0.0f); + capture.source_rect = RectF(0.0f, 0.0f, 0.0f, 0.0f); + capture.used = false; + element_capture_indices[command.element] = capture_index; + capture_paint_orders.push_back(command.paint_order_id); + ResolvedDrawEvent event; + event.type = ResolvedDrawEventType::backdrop_capture; + event.paint_order_id = command.paint_order_id; + event.submission_index = command_index; + event.command_index = command_index; + event.capture_index = capture_index; + resolved_events.push_back(event); + continue; + } + + ClipState clip = capture_clip_stack.empty() ? + ClipState() : capture_clip_stack.back(); + clip.rect = merge_clip_rect(clip.rect, element_clip); + ClipState overflow_clip = capture_clip_stack.empty() ? + ClipState() : capture_clip_stack.back(); + RectF inherited_clip(0.0f, 0.0f, 0.0f, 0.0f); + if(command.element != INVALID_ELEMENT && command.element < elements.size()) + { + u32 parent = elements[command.element].parent; + if(parent != INVALID_ELEMENT && parent < elements.size()) + { + inherited_clip = to_screen_rect(layers, layer_index, + elements[parent].layout_result.clip_rect); } - if(command.type != DrawCommandType::backdrop_blur) continue; + } + u32 capture_index = U32_MAX; + if(command.type == DrawCommandType::backdrop_blur) + { if(command.element == INVALID_ELEMENT || command.element >= elements.size()) { return set_error(E_BAD_DATA, "A backdrop blur draw command must belong to an element."); } u32 source_element = command.element; - u32 capture_index = U32_MAX; while(source_element != INVALID_ELEMENT && source_element < elements.size()) { capture_index = element_capture_indices[source_element]; @@ -592,11 +789,13 @@ namespace Luna return set_error(E_BAD_DATA, "A backdrop blur draw command has no preceding self-or-ancestor capture."); } - command_capture_indices[command_index] = capture_index; + if(capture_index >= capture_paint_orders.size() || + command.paint_order_id <= capture_paint_orders[capture_index]) + { + return set_error(E_BAD_DATA, + "A backdrop blur consumer must have a Paint Order ID greater than its capture."); + } RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); - ClipState clip = capture_clip_stack.empty() ? - ClipState() : capture_clip_stack.back(); - clip.rect = merge_clip_rect(clip.rect, element_clip); if(has_clip(clip.rect)) { screen_rect = intersect_rect(screen_rect, clip.rect); @@ -609,6 +808,30 @@ namespace Luna union_rect(capture.consumer_bounds, screen_rect); } } + + overflow_clip.rect = merge_clip_rect(overflow_clip.rect, inherited_clip); + ResolvedDrawEvent event; + event.paint_order_id = command.paint_order_id; + event.submission_index = command_index; + event.command_index = command_index; + event.capture_index = capture_index; + event.clip = clip; + event.overflow_clip = overflow_clip; + resolved_events.push_back(event); + } + if(!capture_clip_stack.empty()) + { + return set_error(E_BAD_DATA, + "A GUI clip push has no matching lexical clip pop."); + } + for(u32 layer_index = 0; layer_index < layers.size(); ++layer_index) + { + if(layer_command_positions[layer_index] != + layers[layer_index].draw_command_indices.size()) + { + return set_error(E_BAD_DATA, + "A GUI layer contains a stale lexical command index."); + } } RectF screen_bounds(0.0f, 0.0f, m_logical_width, m_logical_height); @@ -648,14 +871,23 @@ namespace Luna context_shape_floats.begin(), context_shape_floats.end()); m_compiled_sdf_color_floats.insert(m_compiled_sdf_color_floats.end(), context_color_floats.begin(), context_color_floats.end()); - Vector clip_stack; - RHI::SamplerDesc nearest_sampler_desc; - nearest_sampler_desc.min_filter = RHI::Filter::nearest; - nearest_sampler_desc.mag_filter = RHI::Filter::nearest; - nearest_sampler_desc.mip_filter = RHI::Filter::nearest; - nearest_sampler_desc.address_u = RHI::TextureAddressMode::clamp; - nearest_sampler_desc.address_v = RHI::TextureAddressMode::clamp; - nearest_sampler_desc.address_w = RHI::TextureAddressMode::clamp; + if(resolved_events.size() > 1) + { + std::stable_sort(resolved_events.begin(), resolved_events.end(), + [](const ResolvedDrawEvent& lhs, const ResolvedDrawEvent& rhs) + { + return lhs.paint_order_id < rhs.paint_order_id; + }); + } + + RHI::SamplerDesc nearest_sampler_desc( + RHI::Filter::nearest, RHI::Filter::nearest, RHI::Filter::nearest, + RHI::TextureAddressMode::clamp, RHI::TextureAddressMode::clamp, + RHI::TextureAddressMode::clamp); + RHI::SamplerDesc default_sampler_desc( + RHI::Filter::linear, RHI::Filter::linear, RHI::Filter::linear, + RHI::TextureAddressMode::repeat, RHI::TextureAddressMode::repeat, + RHI::TextureAddressMode::repeat); u32 first_pending_vg_call = 0; auto flush_pending_vg = [&]() @@ -704,19 +936,20 @@ namespace Luna return state_index; }; - auto emit_sdf = [&](const SDFDrawDesc& desc, const Float2U& evaluation_origin, - const RectF& raster_domain, const ClipState& clip) + auto append_sdf_instance = [&](const PreparedDraw& draw) { + const SDFDrawDesc& desc = draw.sdf; + const Float2U& evaluation_origin = draw.sdf_evaluation_origin; + const RectF& raster_domain = draw.sdf_raster_domain; + const ClipState& clip = draw.clip; const RectF& clip_rect = clip.rect; - if(!desc.shape.floats.valid() || !desc.color.floats.valid()) return; - u32 shape_page = desc.shape.floats.first_float / SDF_PROGRAM_PAGE_FLOATS; - u32 color_page = desc.color.floats.first_float / SDF_PROGRAM_PAGE_FLOATS; + if(!desc.shape.floats.valid() || !desc.color.floats.valid()) return false; u32 local_shape_first = desc.shape.floats.first_float % SDF_PROGRAM_PAGE_FLOATS; u32 local_color_first = desc.color.floats.first_float % SDF_PROGRAM_PAGE_FLOATS; if(local_shape_first + desc.shape.floats.num_floats > SDF_PROGRAM_PAGE_FLOATS || local_color_first + desc.color.floats.num_floats > SDF_PROGRAM_PAGE_FLOATS) { - return; + return false; } RectF shape_rect(evaluation_origin.x + desc.shape.bounds.offset_x, evaluation_origin.y + desc.shape.bounds.offset_y, @@ -734,7 +967,7 @@ namespace Luna if(!rect_visible(draw_rect) || (has_clip(clip_rect) && !rect_visible(intersect_rect(draw_rect, clip_rect)))) { - return; + return false; } SDFInstance instance; instance.draw_rect = Float4U(draw_rect.offset_x, draw_rect.offset_y, @@ -746,334 +979,521 @@ namespace Luna (desc.color.floats.num_floats << SDF_INSTANCE_COLOR_FLOAT_COUNT_SHIFT); instance.program_data = UInt4U(local_shape_first, local_color_first, packed_program_data, resolve_sdf_state(clip)); - flush_pending_vg(); - m_draw_list->draw_call_barrier(); - u32 pair_index = get_page_pair(shape_page, color_page); - u32 instance_index = (u32)m_sdf_instances.size(); m_sdf_instances.push_back(instance); - if(!m_render_batches.empty() && m_render_batches.back().type == RenderBatchType::sdf && - m_render_batches.back().resource_index == pair_index && - m_render_batches.back().first + m_render_batches.back().count == instance_index) - { - ++m_render_batches.back().count; - } - else - { - m_render_batches.push_back({RenderBatchType::sdf, instance_index, 1, pair_index}); - } + return true; }; - for(u32 layer_index = 0; layer_index < layers.size(); ++layer_index) + auto make_vg_key = [&](VG::IShapeBuffer* shape_buffer, + RHI::ITexture* texture, const RHI::SamplerDesc& sampler) + { + PreparedBatchKey key; + key.type = RenderBatchType::vg; + key.vg.shape_buffer = shape_buffer; + key.vg.texture = texture; + key.vg.sampler = sampler; + return key; + }; + + auto append_prepared_sdf = [&](Vector& draws, + u32 command_index, const SDFDrawDesc& desc, + const Float2U& evaluation_origin, const RectF& raster_domain, + const ClipState& clip) { + if(!desc.shape.floats.valid() || !desc.color.floats.valid()) return; + u32 shape_page = desc.shape.floats.first_float / SDF_PROGRAM_PAGE_FLOATS; + u32 color_page = desc.color.floats.first_float / SDF_PROGRAM_PAGE_FLOATS; + PreparedDraw draw; + draw.key.type = RenderBatchType::sdf; + draw.key.sdf_page_pair = get_page_pair(shape_page, color_page); + draw.command_index = command_index; + draw.clip = clip; + draw.sdf = desc; + draw.sdf_evaluation_origin = evaluation_origin; + draw.sdf_raster_domain = raster_domain; + draws.push_back(draw); + }; + + auto prepare_draw = [&](const ResolvedDrawEvent& event, + Vector& draws) + { + if(event.command_index >= commands.size()) return; + const DrawCommand& command = commands[event.command_index]; + if(command.layer >= layers.size()) return; + u32 layer_index = command.layer; const Layer& layer = layers[layer_index]; - clip_stack.clear(); - for(u32 command_index : layer.draw_command_indices) + RectF resolved_rect = resolve_draw_rect(command, elements); + const RectF& clip_rect = event.clip.rect; + + if(command.type == DrawCommandType::backdrop_blur) { - if(command_index >= commands.size()) continue; - const DrawCommand& command = commands[command_index]; - RectF resolved_rect = resolve_draw_rect(command, elements); - RectF element_clip; - RectF inherited_clip; - if(command.element != INVALID_ELEMENT && command.element < elements.size()) + u32 capture_index = event.capture_index; + if(capture_index >= m_num_backdrop_captures || + !m_backdrop_captures[capture_index].used) { - const Element& element = elements[command.element]; - element_clip = to_screen_rect(layers, layer_index, element.layout_result.clip_rect); - if(element.parent != INVALID_ELEMENT && element.parent < elements.size()) - { - inherited_clip = to_screen_rect(layers, layer_index, - elements[element.parent].layout_result.clip_rect); - } + return; } - if(command.type == DrawCommandType::push_clip) + RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); + if((has_clip(clip_rect) && + !rect_visible(intersect_rect(screen_rect, clip_rect))) || + !rect_visible(screen_rect)) { - RectF requested_clip = to_screen_rect(layers, layer_index, resolved_rect); - ClipState pushed_clip = clip_stack.empty() ? ClipState() : clip_stack.back(); - pushed_clip.rect = merge_clip_rect(pushed_clip.rect, element_clip); - pushed_clip.rect = merge_clip_rect(pushed_clip.rect, requested_clip); - if(command.radius > 0.0f) - { - pushed_clip.rounded_rect = requested_clip; - pushed_clip.rounded_radii = Float4U(command.radius); - } - clip_stack.push_back(pushed_clip); - continue; + return; } - if(command.type == DrawCommandType::pop_clip) + PreparedDraw draw; + draw.key = make_vg_key(nullptr, + m_backdrop_captures[capture_index].blur_textures[1], + default_sampler_desc); + draw.command_index = event.command_index; + draw.capture_index = capture_index; + draw.resolved_rect = resolved_rect; + draw.clip = event.clip; + draws.push_back(draw); + return; + } + + if(command.type == DrawCommandType::rect || + command.type == DrawCommandType::gradient_rect || + command.type == DrawCommandType::rounded_rect || + command.type == DrawCommandType::rounded_rect_stroke || + command.type == DrawCommandType::shadow) + { + if(command.type == DrawCommandType::rounded_rect_stroke && + command.line_width <= 0.0f) { - if(!clip_stack.empty()) clip_stack.pop_back(); - continue; + return; } - if(command.type == DrawCommandType::backdrop_blur_capture) + RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); + if(!rect_visible(screen_rect)) return; + Vector shape_floats; + if(command.type == DrawCommandType::rounded_rect || + command.type == DrawCommandType::rounded_rect_stroke || + command.type == DrawCommandType::shadow) { - u32 capture_index = command_capture_indices[command_index]; - if(capture_index < m_num_backdrop_captures && - m_backdrop_captures[capture_index].used) - { - flush_pending_vg(); - m_draw_list->draw_call_barrier(); - m_render_batches.push_back({ - RenderBatchType::backdrop_capture, 0, 0, capture_index}); - } - continue; + sdf_shape_add_rounded_rectangle(shape_floats, + RectF(0.0f, 0.0f, screen_rect.width, screen_rect.height), + Float4U(max(command.radius, 0.0f))); } - - ClipState clip = clip_stack.empty() ? ClipState() : clip_stack.back(); - clip.rect = merge_clip_rect(clip.rect, element_clip); - RectF clip_rect = clip.rect; - auto visual_overflow_clip = [&]() + else { - ClipState overflow_clip = clip_stack.empty() ? ClipState() : clip_stack.back(); - overflow_clip.rect = merge_clip_rect(overflow_clip.rect, inherited_clip); - return overflow_clip; - }; - RectF vg_clip = has_clip(clip_rect) ? to_vg_rect(frame_desc, clip_rect) : RectF(); - m_draw_list->set_clip_rect(vg_clip); - RectF vg_rounded_clip = has_rounded_clip(clip) ? - to_vg_rect(frame_desc, clip.rounded_rect) : RectF(); - m_draw_list->set_rounded_clip_rect(vg_rounded_clip, clip.rounded_radii); - m_draw_list->set_texture(nullptr); - m_draw_list->set_shape_buffer(nullptr); - m_draw_list->set_sampler(nullptr); - - if(command.type == DrawCommandType::backdrop_blur) + sdf_shape_add_rectangle(shape_floats, + RectF(0.0f, 0.0f, screen_rect.width, screen_rect.height)); + } + SDFDrawDesc desc; + if(!append_shape_program(m_compiled_sdf_shape_floats, + shape_floats.cspan(), desc.shape)) return; + Vector color_floats; + if(command.type == DrawCommandType::gradient_rect) { - u32 capture_index = command_capture_indices[command_index]; - if(capture_index >= m_num_backdrop_captures || - !m_backdrop_captures[capture_index].used) - { - continue; - } - RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); - if((has_clip(clip_rect) && - !rect_visible(intersect_rect(screen_rect, clip_rect))) || - !rect_visible(screen_rect)) - { - continue; - } - const BackdropCapture& capture = - m_backdrop_captures[capture_index]; - const RectF& source_rect = capture.source_rect; - f32 min_u = (screen_rect.offset_x - source_rect.offset_x) / - source_rect.width; - f32 max_u = (screen_rect.offset_x + screen_rect.width - - source_rect.offset_x) / source_rect.width; - f32 min_v = (screen_rect.offset_y - source_rect.offset_y) / - source_rect.height; - f32 max_v = (screen_rect.offset_y + screen_rect.height - - source_rect.offset_y) / source_rect.height; - RectF vg_rect = to_vg_rect(frame_desc, screen_rect); - m_draw_list->set_texture(capture.blur_textures[1]); - auto& points = - m_draw_list->get_shape_buffer()->get_shape_points(true); - u32 begin = (u32)points.size(); - if(command.radius > 0.0f) - { - VG::ShapeBuilder::add_rounded_rectangle_filled(points, - 0.0f, 0.0f, vg_rect.width, vg_rect.height, - command.radius); - } - else - { - VG::ShapeBuilder::add_rectangle_filled(points, - 0.0f, 0.0f, vg_rect.width, vg_rect.height); - } - u32 end = (u32)points.size(); - m_draw_list->draw_shape(begin, end - begin, - Float2U(vg_rect.offset_x, vg_rect.offset_y), - Float2U(vg_rect.offset_x + vg_rect.width, - vg_rect.offset_y + vg_rect.height), - Float2U(0.0f), Float2U(vg_rect.width, vg_rect.height), - Float4U(1.0f), Float2U(min_u, max_v), - Float2U(max_u, min_v)); - continue; + sdf_color_add_bilinear_gradient(color_floats, + RectF(0.0f, 0.0f, screen_rect.width, screen_rect.height), + command.color, command.color_top_right, + command.color_bottom_right, command.color_bottom_left); + } + else if(command.type == DrawCommandType::shadow) + { + SDFClipDesc shadow_clip = command.shadow.mode == ShadowMode::inner ? + SDFClipDesc::outer(0.0f) : SDFClipDesc::inner(0.0f); + sdf_color_add_shadow(color_floats, command.color, + command.shadow.offset, command.shadow.softness, + command.shadow.spread, shadow_clip); + } + else if(command.type == DrawCommandType::rounded_rect_stroke) + { + sdf_color_add_solid(color_floats, command.color, + SDFClipDesc::stroke(command.line_width)); + } + else + { + sdf_color_add_solid(color_floats, command.color); } + if(!append_color_program(m_compiled_sdf_color_floats, + color_floats.cspan(), desc.color)) return; + const ClipState& sdf_clip = + command.type == DrawCommandType::rounded_rect_stroke || + (command.type == DrawCommandType::shadow && + command.shadow.mode == ShadowMode::outer) ? + event.overflow_clip : event.clip; + append_prepared_sdf(draws, event.command_index, desc, + Float2U(screen_rect.offset_x, screen_rect.offset_y), + screen_rect, sdf_clip); + return; + } - if(command.type == DrawCommandType::rect || - command.type == DrawCommandType::gradient_rect || - command.type == DrawCommandType::rounded_rect || - command.type == DrawCommandType::shadow) + if(command.type == DrawCommandType::sdf) + { + SDFDrawDesc desc = command.sdf; + if(!resolve_context_program(context_shape_floats, + command.sdf.shape.floats, desc.shape, + validate_sdf_shape_program) || + !resolve_context_program(context_color_floats, + command.sdf.color.floats, desc.color, + validate_sdf_color_program)) { - RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); - if(!rect_visible(screen_rect)) continue; - Vector shape_floats; - if(command.type == DrawCommandType::rounded_rect || command.type == DrawCommandType::shadow) - { - sdf_shape_add_rounded_rectangle(shape_floats, - RectF(0.0f, 0.0f, screen_rect.width, screen_rect.height), - Float4U(max(command.radius, 0.0f))); - } - else - { - sdf_shape_add_rectangle(shape_floats, - RectF(0.0f, 0.0f, screen_rect.width, screen_rect.height)); - } - SDFDrawDesc desc; - if(!append_shape_program(m_compiled_sdf_shape_floats, - shape_floats.cspan(), desc.shape)) - continue; - Vector color_floats; - if(command.type == DrawCommandType::gradient_rect) - { - sdf_color_add_bilinear_gradient(color_floats, - RectF(0.0f, 0.0f, screen_rect.width, screen_rect.height), command.color, - command.color_top_right, command.color_bottom_right, - command.color_bottom_left); - } - else if(command.type == DrawCommandType::shadow) - { - SDFClipDesc shadow_clip = command.shadow.mode == ShadowMode::inner ? - SDFClipDesc::outer(0.0f) : SDFClipDesc::inner(0.0f); - sdf_color_add_shadow(color_floats, command.color, command.shadow.offset, - command.shadow.softness, command.shadow.spread, shadow_clip); - } - else - { - sdf_color_add_solid(color_floats, command.color); - } - if(!append_color_program(m_compiled_sdf_color_floats, - color_floats.cspan(), desc.color)) - continue; - ClipState sdf_clip = command.type == DrawCommandType::shadow && - command.shadow.mode == ShadowMode::outer ? visual_overflow_clip() : clip; - emit_sdf(desc, Float2U(screen_rect.offset_x, screen_rect.offset_y), - screen_rect, sdf_clip); - continue; + return; } + RectF screen_anchor = to_screen_rect(layers, layer_index, resolved_rect); + const ClipState& sdf_clip = desc.color.may_paint_outside ? + event.overflow_clip : event.clip; + append_prepared_sdf(draws, event.command_index, desc, + Float2U(screen_anchor.offset_x, screen_anchor.offset_y), + screen_anchor, sdf_clip); + return; + } - if(command.type == DrawCommandType::sdf) + PreparedDraw draw; + draw.command_index = event.command_index; + draw.resolved_rect = resolved_rect; + draw.clip = event.clip; + switch(command.type) + { + case DrawCommandType::image: + { + RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); + if((has_clip(clip_rect) && + !rect_visible(intersect_rect(screen_rect, clip_rect))) || + !rect_visible(screen_rect) || !color_visible(command.color)) { - SDFDrawDesc desc = command.sdf; - if(!resolve_context_program(context_shape_floats, command.sdf.shape.floats, - desc.shape, validate_sdf_shape_program) || - !resolve_context_program(context_color_floats, command.sdf.color.floats, - desc.color, validate_sdf_color_program)) - { - continue; - } - RectF screen_anchor = to_screen_rect(layers, layer_index, resolved_rect); - ClipState sdf_clip = desc.color.may_paint_outside ? - visual_overflow_clip() : clip; - emit_sdf(desc, Float2U(screen_anchor.offset_x, screen_anchor.offset_y), - screen_anchor, sdf_clip); - continue; + return; } + draw.key = make_vg_key(nullptr, command.texture, + command.nearest_sampler ? nearest_sampler_desc : default_sampler_desc); + break; + } + case DrawCommandType::line: + { + if(!color_visible(command.color) || command.line_width <= 0.0f) return; + Float2U resolved_p0 = resolve_draw_point0(command, elements); + Float2U resolved_p1 = resolve_draw_point1(command, elements); + Float2U p0(layer.screen_position.x + resolved_p0.x, + layer.screen_position.y + resolved_p0.y); + Float2U p1(layer.screen_position.x + resolved_p1.x, + layer.screen_position.y + resolved_p1.y); + f32 margin = max(command.line_width, 1.0f); + RectF bounds(min(p0.x, p1.x) - margin, + min(p0.y, p1.y) - margin, + max(abs(p1.x - p0.x) + margin * 2.0f, 1.0f), + max(abs(p1.y - p0.y) + margin * 2.0f, 1.0f)); + if(has_clip(clip_rect) && + !rect_visible(intersect_rect(bounds, clip_rect))) return; + draw.key = make_vg_key(nullptr, nullptr, default_sampler_desc); + break; + } + case DrawCommandType::text: + { + if(command.text.empty() || command.font_size <= 0.0f || + !color_visible(command.color)) return; + RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); + if((has_clip(clip_rect) && + !rect_visible(intersect_rect(screen_rect, clip_rect))) || + !rect_visible(screen_rect)) return; + draw.key = make_vg_key(m_font_atlas->get_shape_buffer(), + nullptr, default_sampler_desc); + break; + } + case DrawCommandType::shape: + { + if(!command.shape.buffer || command.shape.num_commands == 0 || + !rect_visible(command.shape.bounds) || !rect_visible(resolved_rect) || + !color_visible(command.color)) return; + RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); + if(has_clip(clip_rect) && + !rect_visible(intersect_rect(screen_rect, clip_rect))) return; + const RHI::SamplerDesc& sampler = command.shape.texture && + command.nearest_sampler ? nearest_sampler_desc : default_sampler_desc; + draw.key = make_vg_key(command.shape.buffer, + command.shape.texture, sampler); + break; + } + default: + return; + } + draws.push_back(draw); + }; - switch(command.type) + auto emit_vg = [&](const PreparedDraw& draw) + { + if(draw.command_index >= commands.size()) return; + const DrawCommand& command = commands[draw.command_index]; + if(command.layer >= layers.size()) return; + const Layer& layer = layers[command.layer]; + const RectF& resolved_rect = draw.resolved_rect; + RectF vg_clip = has_clip(draw.clip.rect) ? + to_vg_rect(frame_desc, draw.clip.rect) : + RectF(0.0f, 0.0f, 0.0f, 0.0f); + m_draw_list->set_clip_rect(vg_clip); + RectF vg_rounded_clip = has_rounded_clip(draw.clip) ? + to_vg_rect(frame_desc, draw.clip.rounded_rect) : + RectF(0.0f, 0.0f, 0.0f, 0.0f); + m_draw_list->set_rounded_clip_rect(vg_rounded_clip, + draw.clip.rounded_radii); + m_draw_list->set_texture(nullptr); + m_draw_list->set_shape_buffer(nullptr); + m_draw_list->set_sampler(nullptr); + + switch(command.type) + { + case DrawCommandType::backdrop_blur: + { + if(draw.capture_index >= m_num_backdrop_captures) break; + const BackdropCapture& capture = m_backdrop_captures[draw.capture_index]; + RectF screen_rect = to_screen_rect(layers, command.layer, resolved_rect); + const RectF& source_rect = capture.source_rect; + f32 min_u = (screen_rect.offset_x - source_rect.offset_x) / + source_rect.width; + f32 max_u = (screen_rect.offset_x + screen_rect.width - + source_rect.offset_x) / source_rect.width; + f32 min_v = (screen_rect.offset_y - source_rect.offset_y) / + source_rect.height; + f32 max_v = (screen_rect.offset_y + screen_rect.height - + source_rect.offset_y) / source_rect.height; + RectF vg_rect = to_vg_rect(frame_desc, screen_rect); + m_draw_list->set_texture(capture.blur_textures[1]); + auto& points = m_draw_list->get_shape_buffer()->get_shape_points(true); + u32 begin = (u32)points.size(); + if(command.radius > 0.0f) { - case DrawCommandType::image: + VG::ShapeBuilder::add_rounded_rectangle_filled(points, + 0.0f, 0.0f, vg_rect.width, vg_rect.height, + command.radius); + } + else { - RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); - if((has_clip(clip_rect) && !rect_visible(intersect_rect(screen_rect, clip_rect))) || - !rect_visible(screen_rect) || !color_visible(command.color)) - { - break; - } - RectF vg_rect = to_vg_rect(frame_desc, screen_rect); - m_draw_list->set_texture(command.texture); - if(command.nearest_sampler) m_draw_list->set_sampler(&nearest_sampler_desc); - auto& points = m_draw_list->get_shape_buffer()->get_shape_points(true); - u32 begin = (u32)points.size(); - VG::ShapeBuilder::add_rectangle_filled(points, 0.0f, 0.0f, - vg_rect.width, vg_rect.height); - u32 end = (u32)points.size(); - m_draw_list->draw_shape(begin, end - begin, - Float2U(vg_rect.offset_x, vg_rect.offset_y), - Float2U(vg_rect.offset_x + vg_rect.width, vg_rect.offset_y + vg_rect.height), - Float2U(0.0f), Float2U(vg_rect.width, vg_rect.height), command.color, - command.min_texcoord, command.max_texcoord); - break; + VG::ShapeBuilder::add_rectangle_filled(points, + 0.0f, 0.0f, vg_rect.width, vg_rect.height); + } + u32 end = (u32)points.size(); + m_draw_list->draw_shape(begin, end - begin, + Float2U(vg_rect.offset_x, vg_rect.offset_y), + Float2U(vg_rect.offset_x + vg_rect.width, + vg_rect.offset_y + vg_rect.height), + Float2U(0.0f), Float2U(vg_rect.width, vg_rect.height), + Float4U(1.0f), Float2U(min_u, max_v), + Float2U(max_u, min_v)); + break; + } + case DrawCommandType::image: + { + RectF screen_rect = to_screen_rect(layers, command.layer, resolved_rect); + RectF vg_rect = to_vg_rect(frame_desc, screen_rect); + m_draw_list->set_texture(command.texture); + if(command.nearest_sampler) m_draw_list->set_sampler(&nearest_sampler_desc); + auto& points = m_draw_list->get_shape_buffer()->get_shape_points(true); + u32 begin = (u32)points.size(); + VG::ShapeBuilder::add_rectangle_filled(points, 0.0f, 0.0f, + vg_rect.width, vg_rect.height); + u32 end = (u32)points.size(); + m_draw_list->draw_shape(begin, end - begin, + Float2U(vg_rect.offset_x, vg_rect.offset_y), + Float2U(vg_rect.offset_x + vg_rect.width, + vg_rect.offset_y + vg_rect.height), + Float2U(0.0f), Float2U(vg_rect.width, vg_rect.height), + command.color, command.min_texcoord, command.max_texcoord); + break; + } + case DrawCommandType::line: + { + Float2U resolved_p0 = resolve_draw_point0(command, elements); + Float2U resolved_p1 = resolve_draw_point1(command, elements); + Float2U p0(layer.screen_position.x + resolved_p0.x, + layer.screen_position.y + resolved_p0.y); + Float2U p1(layer.screen_position.x + resolved_p1.x, + layer.screen_position.y + resolved_p1.y); + f32 margin = max(command.line_width, 1.0f); + f32 dx = abs(p1.x - p0.x); + f32 dy = abs(p1.y - p0.y); + RectF bounds(min(p0.x, p1.x) - margin, + min(p0.y, p1.y) - margin, + max(dx + margin * 2.0f, 1.0f), + max(dy + margin * 2.0f, 1.0f)); + RectF vg_rect = to_vg_rect(frame_desc, bounds); + auto& points = m_draw_list->get_shape_buffer()->get_shape_points(true); + u32 begin = (u32)points.size(); + Float2U local_p0(p0.x - bounds.offset_x, + bounds.height - (p0.y - bounds.offset_y)); + Float2U local_p1(p1.x - bounds.offset_x, + bounds.height - (p1.y - bounds.offset_y)); + VG::ShapeBuilder::add_line(points, local_p0.x, local_p0.y, + local_p1.x, local_p1.y, command.line_width); + u32 end = (u32)points.size(); + m_draw_list->draw_shape(begin, end - begin, + Float2U(vg_rect.offset_x, vg_rect.offset_y), + Float2U(vg_rect.offset_x + vg_rect.width, + vg_rect.offset_y + vg_rect.height), + Float2U(0.0f), Float2U(vg_rect.width, vg_rect.height), + command.color, Float2U(0.0f), Float2U(1.0f)); + break; + } + case DrawCommandType::text: + { + RectF screen_rect = to_screen_rect(layers, command.layer, resolved_rect); + FontDesc font = context->get_font(command.font); + if(!font.font) + { + font.font = Font::get_default_font(); + font.font_index = 0; } - case DrawCommandType::line: + VG::TextArrangeSection section; + section.font_file = font.font; + section.font_index = font.font_index; + section.font_size = command.font_size; + section.color = command.color; + section.num_chars = command.text.size(); + RectF vg_rect = to_vg_rect(frame_desc, screen_rect); + VG::TextArrangeResult arranged = VG::arrange_text( + command.text.c_str(), command.text.size(), + Span(§ion, 1), vg_rect, + command.vertical_alignment, command.horizontal_alignment); + VG::commit_text_arrange_result(arranged, + Span(§ion, 1), + m_font_atlas, m_draw_list); + break; + } + case DrawCommandType::shape: + { + RectF screen_rect = to_screen_rect(layers, command.layer, resolved_rect); + RectF vg_rect = to_vg_rect(frame_desc, screen_rect); + m_draw_list->set_shape_buffer(command.shape.buffer); + m_draw_list->set_texture(command.shape.texture); + if(command.shape.texture && command.nearest_sampler) + m_draw_list->set_sampler(&nearest_sampler_desc); + Float2U shape_min(command.shape.bounds.offset_x, + command.shape.bounds.offset_y + command.shape.bounds.height); + Float2U shape_max(command.shape.bounds.offset_x + + command.shape.bounds.width, command.shape.bounds.offset_y); + m_draw_list->draw_shape(command.shape.first_command, + command.shape.num_commands, + Float2U(vg_rect.offset_x, vg_rect.offset_y), + Float2U(vg_rect.offset_x + vg_rect.width, + vg_rect.offset_y + vg_rect.height), + shape_min, shape_max, command.color, + command.min_texcoord, command.max_texcoord); + break; + } + default: + break; + } + }; + + usize event_begin = 0; + while(event_begin < resolved_events.size()) + { + usize event_end = event_begin + 1; + paint_order_id_t paint_order_id = + resolved_events[event_begin].paint_order_id; + while(event_end < resolved_events.size() && + resolved_events[event_end].paint_order_id == paint_order_id) + { + ++event_end; + } + + bool has_capture_event = false; + for(usize event_index = event_begin; + event_index < event_end; ++event_index) + { + if(resolved_events[event_index].type == + ResolvedDrawEventType::backdrop_capture) { - if(!color_visible(command.color) || command.line_width <= 0.0f) break; - Float2U resolved_p0 = resolve_draw_point0(command, elements); - Float2U resolved_p1 = resolve_draw_point1(command, elements); - Float2U p0(layer.screen_position.x + resolved_p0.x, - layer.screen_position.y + resolved_p0.y); - Float2U p1(layer.screen_position.x + resolved_p1.x, - layer.screen_position.y + resolved_p1.y); - f32 margin = max(command.line_width, 1.0f); - f32 dx = abs(p1.x - p0.x); - f32 dy = abs(p1.y - p0.y); - RectF bounds(min(p0.x, p1.x) - margin, min(p0.y, p1.y) - margin, - max(dx + margin * 2.0f, 1.0f), max(dy + margin * 2.0f, 1.0f)); - if(has_clip(clip_rect) && !rect_visible(intersect_rect(bounds, clip_rect))) break; - RectF vg_rect = to_vg_rect(frame_desc, bounds); - auto& points = m_draw_list->get_shape_buffer()->get_shape_points(true); - u32 begin = (u32)points.size(); - Float2U local_p0(p0.x - bounds.offset_x, bounds.height - (p0.y - bounds.offset_y)); - Float2U local_p1(p1.x - bounds.offset_x, bounds.height - (p1.y - bounds.offset_y)); - VG::ShapeBuilder::add_line(points, local_p0.x, local_p0.y, - local_p1.x, local_p1.y, command.line_width); - u32 end = (u32)points.size(); - m_draw_list->draw_shape(begin, end - begin, - Float2U(vg_rect.offset_x, vg_rect.offset_y), - Float2U(vg_rect.offset_x + vg_rect.width, vg_rect.offset_y + vg_rect.height), - Float2U(0.0f), Float2U(vg_rect.width, vg_rect.height), command.color, - Float2U(0.0f), Float2U(1.0f)); + has_capture_event = true; break; } - case DrawCommandType::text: + } + if(has_capture_event) + { + if(event_end - event_begin != 1) + { + return set_error(E_BAD_DATA, + "A backdrop capture must own an exclusive Paint Order ID."); + } + const ResolvedDrawEvent& event = resolved_events[event_begin]; + if(event.capture_index < m_num_backdrop_captures && + m_backdrop_captures[event.capture_index].used) + { + flush_pending_vg(); + m_draw_list->draw_call_barrier(); + m_render_batches.push_back({ + RenderBatchType::backdrop_capture, 0, 0, + event.capture_index}); + } + event_begin = event_end; + continue; + } + + Vector prepared_draws; + prepared_draws.reserve(event_end - event_begin); + for(usize event_index = event_begin; + event_index < event_end; ++event_index) + { + prepare_draw(resolved_events[event_index], prepared_draws); + } + + HashMap batch_indices; + Vector batches; + for(u32 draw_index = 0; draw_index < prepared_draws.size(); ++draw_index) + { + const PreparedBatchKey& key = prepared_draws[draw_index].key; + auto iter = batch_indices.find(key); + u32 batch_index; + if(iter == batch_indices.end()) + { + batch_index = (u32)batches.size(); + PreparedBatch batch; + batch.key = key; + batches.push_back(move(batch)); + batch_indices.insert(make_pair(key, batch_index)); + } + else { - if(command.text.empty() || command.font_size <= 0.0f || !color_visible(command.color)) - break; - RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); - if((has_clip(clip_rect) && !rect_visible(intersect_rect(screen_rect, clip_rect))) || - !rect_visible(screen_rect)) break; - FontDesc font = context->get_font(command.font); - if(!font.font) + batch_index = iter->second; + } + batches[batch_index].draw_indices.push_back(draw_index); + } + + for(const PreparedBatch& batch : batches) + { + if(batch.key.type == RenderBatchType::vg) + { + for(u32 draw_index : batch.draw_indices) { - font.font = Font::get_default_font(); - font.font_index = 0; + emit_vg(prepared_draws[draw_index]); } - VG::TextArrangeSection section; - section.font_file = font.font; - section.font_index = font.font_index; - section.font_size = command.font_size; - section.color = command.color; - section.num_chars = command.text.size(); - RectF vg_rect = to_vg_rect(frame_desc, screen_rect); - VG::TextArrangeResult arranged = VG::arrange_text(command.text.c_str(), command.text.size(), - Span(§ion, 1), vg_rect, - command.vertical_alignment, command.horizontal_alignment); - VG::commit_text_arrange_result(arranged, - Span(§ion, 1), m_font_atlas, - m_draw_list); - break; + continue; } - case DrawCommandType::shape: + + u32 first_instance = (u32)m_sdf_instances.size(); + for(u32 draw_index : batch.draw_indices) { - if(!command.shape.buffer || command.shape.num_commands == 0 || - !rect_visible(command.shape.bounds) || !rect_visible(resolved_rect) || - !color_visible(command.color)) break; - RectF screen_rect = to_screen_rect(layers, layer_index, resolved_rect); - if(has_clip(clip_rect) && !rect_visible(intersect_rect(screen_rect, clip_rect))) break; - RectF vg_rect = to_vg_rect(frame_desc, screen_rect); - m_draw_list->set_shape_buffer(command.shape.buffer); - m_draw_list->set_texture(command.shape.texture); - if(command.shape.texture && command.nearest_sampler) - m_draw_list->set_sampler(&nearest_sampler_desc); - Float2U shape_min(command.shape.bounds.offset_x, - command.shape.bounds.offset_y + command.shape.bounds.height); - Float2U shape_max(command.shape.bounds.offset_x + command.shape.bounds.width, - command.shape.bounds.offset_y); - m_draw_list->draw_shape(command.shape.first_command, command.shape.num_commands, - Float2U(vg_rect.offset_x, vg_rect.offset_y), - Float2U(vg_rect.offset_x + vg_rect.width, vg_rect.offset_y + vg_rect.height), - shape_min, shape_max, command.color, - command.min_texcoord, command.max_texcoord); - break; + append_sdf_instance(prepared_draws[draw_index]); } - default: - break; + u32 instance_count = (u32)m_sdf_instances.size() - first_instance; + if(instance_count) + { + flush_pending_vg(); + m_draw_list->draw_call_barrier(); + if(!m_render_batches.empty() && + m_render_batches.back().type == RenderBatchType::sdf && + m_render_batches.back().resource_index == + batch.key.sdf_page_pair && + m_render_batches.back().first + + m_render_batches.back().count == first_instance) + { + m_render_batches.back().count += instance_count; + } + else + { + m_render_batches.push_back({RenderBatchType::sdf, + first_instance, instance_count, + batch.key.sdf_page_pair}); + } } } + event_begin = event_end; } m_draw_list->set_texture(nullptr); m_draw_list->set_shape_buffer(nullptr); m_draw_list->set_sampler(nullptr); - m_draw_list->set_clip_rect(RectF()); - m_draw_list->set_rounded_clip_rect(RectF(), Float4U(0.0f)); + m_draw_list->set_clip_rect(RectF(0.0f, 0.0f, 0.0f, 0.0f)); + m_draw_list->set_rounded_clip_rect( + RectF(0.0f, 0.0f, 0.0f, 0.0f), Float4U(0.0f)); flush_pending_vg(); luexp(m_draw_list->compile()); m_counters.vg_draw_call_count = (u32)m_draw_list->get_draw_calls().size(); @@ -1532,7 +1952,8 @@ namespace Luna SDFProgramPage& color_page = m_sdf_color_pages[pair.color_page]; luexp(pair.descriptor_set->update_descriptors({ WriteDescriptorSet::uniform_buffer_view(0, - BufferViewDesc::uniform_buffer(m_sdf_frame_buffer, 0, sizeof(SDFFrameBuffer))), + BufferViewDesc::uniform_buffer(m_sdf_frame_buffer, 0, + (u32)m_sdf_frame_buffer->get_desc().size)), WriteDescriptorSet::read_buffer_view(1, BufferViewDesc::structured_buffer(shape_page.buffer, 0, shape_page.num_floats, sizeof(f32))), diff --git a/Modules/Luna/GameGUI/BuiltInNodes.hpp b/Modules/Luna/GameGUI/BuiltInNodes.hpp new file mode 100644 index 00000000..c6f75947 --- /dev/null +++ b/Modules/Luna/GameGUI/BuiltInNodes.hpp @@ -0,0 +1,32 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file BuiltInNodes.hpp +* @author JXMaster +* @date 2026/8/25 +*/ +#pragma once +#include "Node.hpp" + +namespace Luna +{ + namespace GameGUI + { + //! Gets the built-in Flex node type ID. + LUNA_GAME_GUI_API Guid get_flex_node_type(); + //! Gets the built-in Canvas node type ID. + LUNA_GAME_GUI_API Guid get_canvas_node_type(); + //! Gets the built-in Panel node type ID. + LUNA_GAME_GUI_API Guid get_panel_node_type(); + //! Gets the built-in Text node type ID. + LUNA_GAME_GUI_API Guid get_text_node_type(); + //! Gets the built-in Button node type ID. + LUNA_GAME_GUI_API Guid get_button_node_type(); + //! Gets the built-in AssetInstance node type ID. + LUNA_GAME_GUI_API Guid get_asset_instance_node_type(); + //! Registers all GameGUI built-in node types. + LUNA_GAME_GUI_API RV register_builtin_node_types(); + } +} diff --git a/Modules/Luna/GameGUI/Document.hpp b/Modules/Luna/GameGUI/Document.hpp new file mode 100644 index 00000000..78b1c575 --- /dev/null +++ b/Modules/Luna/GameGUI/Document.hpp @@ -0,0 +1,108 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Document.hpp +* @author JXMaster +* @date 2026/8/25 +*/ +#pragma once +#include +#include + +#ifndef LUNA_GAME_GUI_API +#define LUNA_GAME_GUI_API +#endif + +namespace Luna +{ + namespace GameGUI + { + //! Describes the severity of one document or instance diagnostic. + enum class DiagnosticSeverity : u8 + { + //! Informational diagnostic. + info, + //! Recoverable warning. + warning, + //! Error that prevents validation or generation. + error + }; + + //! Describes one document or instance diagnostic. + struct Diagnostic + { + //! Diagnostic severity. + DiagnosticSeverity severity = DiagnosticSeverity::error; + //! Node associated with the diagnostic, or zero when the diagnostic is document-wide. + Guid node; + //! Human-readable diagnostic text. + String message; + //! Nested asset GUID mount chain associated with this diagnostic. + Vector asset_mount_chain; + }; + + //! Describes one ordered child edge in a GameGUI semantic tree. + struct ChildLink + { + //! Asset-local ID of the child node. + Guid child; + //! Semantic slot selected by the parent node type. + Name slot; + //! Parent-owned attachment data, such as Canvas placement. + Variant attachment; + }; + + //! Stores one node in the canonical flat document representation. + struct NodeRecord + { + //! Stable asset-local node ID. + Guid id; + //! Stable registered node type ID. + Guid type; + //! Optional author-facing node name. + Name name; + //! Current cooked property payload. + Variant properties = Variant(VariantType::object); + //! Ordered outgoing child edges. + Vector children; + }; + + //! Stores one current cooked GameGUI semantic node tree. + //! @remark Runtime element handles, instance state and prepared caches are never stored in this object. + struct Document + { + lustruct("Luna::GameGUI::Document", "{318C604E-37FB-453E-BF7E-FA5532EEFF27}"); + + //! Asset-local root node ID. + Guid root; + //! Flat canonical node records. + Vector nodes; + }; + + //! Gets the Asset type name used by GameGUI documents. + //! @return Returns `GameGUI`. + LUNA_GAME_GUI_API Name get_asset_type(); + + //! Finds one node in a document. + //! @param[in] document The document to search. + //! @param[in] id Asset-local node ID. + //! @return Returns the node, or `nullptr` when no node matches. + LUNA_GAME_GUI_API const NodeRecord* find_node(const Document& document, const Guid& id); + + //! Finds one mutable node in a document. + //! @param[in] document The document to search. + //! @param[in] id Asset-local node ID. + //! @return Returns the node, or `nullptr` when no node matches. + LUNA_GAME_GUI_API NodeRecord* find_node(Document& document, const Guid& id); + + //! Validates document topology and canonical record invariants. + //! @param[in] document The document to validate. + //! @param[out] diagnostics Optional destination for all validation errors. + //! @return Returns success only when the document is a single rooted tree. + LUNA_GAME_GUI_API RV validate_document(const Document& document, + Vector* diagnostics = nullptr); + + } +} diff --git a/Modules/Luna/GameGUI/GameGUI.Target.cs b/Modules/Luna/GameGUI/GameGUI.Target.cs new file mode 100644 index 00000000..8410a987 --- /dev/null +++ b/Modules/Luna/GameGUI/GameGUI.Target.cs @@ -0,0 +1,16 @@ +namespace LunaBuild.Core.Targets; + +public sealed class GameGUITargetRules : TargetRules +{ + public GameGUITargetRules() + : base( + name: "GameGUI", + targetDirectory: "Modules/Luna/GameGUI", + rulesPath: "Modules/Luna/GameGUI/GameGUI.Target.cs") + { + Headers("*.hpp", "Source/**.hpp"); + MetaHeaders("Instance.hpp", "Source/InstanceInternal.hpp"); + Sources("Source/**.cpp"); + DependsOn("Runtime", "GUI", "Asset", "VFS"); + } +} diff --git a/Modules/Luna/GameGUI/GameGUI.hpp b/Modules/Luna/GameGUI/GameGUI.hpp new file mode 100644 index 00000000..6bb756a2 --- /dev/null +++ b/Modules/Luna/GameGUI/GameGUI.hpp @@ -0,0 +1,22 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file GameGUI.hpp +* @author JXMaster +* @date 2026/8/25 +*/ +#pragma once +#include "BuiltInNodes.hpp" +#include "Instance.hpp" + +namespace Luna +{ + namespace GameGUI + { + //! Gets the GameGUI module. + //! @return Returns the GameGUI module. + LUNA_GAME_GUI_API Module* module_game_gui(); + } +} diff --git a/Modules/Luna/GameGUI/Instance.hpp b/Modules/Luna/GameGUI/Instance.hpp new file mode 100644 index 00000000..6a8ba8d3 --- /dev/null +++ b/Modules/Luna/GameGUI/Instance.hpp @@ -0,0 +1,117 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Instance.hpp +* @author JXMaster +* @date 2026/8/25 +*/ +#pragma once +#include "Node.hpp" +#include +#include "Instance.generated.hpp" + +namespace Luna +{ + namespace GameGUI + { + //! Resolves an already loaded asset into an object usable by GameGUI. + using ResourceResolveCallback = R (*)(object_t userdata, Asset::asset_t asset); + + //! Describes a no-I/O runtime resource resolver. + struct ResourceResolver + { + //! Resolution callback. Null selects the default loaded-Asset resolver. + ResourceResolveCallback resolve = nullptr; + //! User object retained by the instance and passed to @ref resolve. + ObjRef userdata; + }; + + //! Describes one emitted symbolic GameGUI action. + struct Action + { + //! Action name authored on the semantic node. + Name name; + //! Asset-local source node ID. + Guid node; + //! Stable mounted source identity, including the complete nested mount path. + GUI::id_t source_id = 0; + //! Action-specific payload. + Variant payload; + }; + + //! Describes one semantic node generated during the current frame. + struct GeneratedNodeInfo + { + //! Asset-local semantic node ID. + Guid node; + //! Stable mounted identity including the complete nested mount path. + GUI::id_t source_id = 0; + //! Stable ID of the root GUI element returned by the node generator. + GUI::id_t root_element_id = 0; + }; + + //! Describes one GameGUI runtime instance. + struct InstanceDesc + { + //! Root semantic document. + Ref document; + //! Optional Asset handle that identifies @ref document for dependency + //! diagnostics. + Asset::asset_t source_asset; + //! Host-selected stable scope mixed into every generated GUI ID. + GUI::id_t instance_scope = GUI::DEFAULT_DATA_SCOPE; + //! Optional no-I/O resource resolver. + ResourceResolver resource_resolver; + }; + + //! @interface IInstance + //! Compiles a retained semantic tree into GUI elements and owns cross-frame + //! node state. + struct [[Luna::interface("{73CC35BC-7056-41D1-9DF4-8C827C79E94A}")]] IInstance : virtual Interface + { + //! Validates and prepares all known nodes and loaded nested + //! dependencies. + //! @remark This is the only phase that resolves the nested dependency graph. + //! It performs no file I/O. Unsupported node types are diagnosed and retained + //! as non-generated subtrees without failing this call. + virtual RV prepare() = 0; + //! Builds a fresh GUI element tree for the current frame. + //! @param[in] context Begun GUI context with an active layer. + //! @return Returns the generated root element. + //! @remark An unsupported semantic root returns a successful invalid handle. + //! An instance can be built at most once for each context generation. + //! The instance must remain alive and @ref prepare must not be called again until + //! @ref GUI::IContext::generate_draw_commands finishes for this frame, because generated + //! GUI callbacks reference the instance's prepared node data. + virtual R build(GUI::IContext* context) = 0; + //! Resolves authored actions after @ref GUI::IContext::route_input. + //! @param[in] context The same context and generation passed to the latest + //! @ref build call. + virtual RV resolve_interactions(GUI::IContext* context) = 0; + //! Gets actions emitted by the latest resolution pass. + //! @return Returns a view invalidated by the next build, resolution or @ref + //! clear_actions call. + virtual Span get_actions() const = 0; + //! Clears emitted actions. + virtual void clear_actions() = 0; + //! Checks whether the latest resolution pass requested a second layout pass. + virtual bool relayout_requested() const = 0; + //! Gets preparation and generation diagnostics. + //! @return Returns a view invalidated by the next @ref prepare call. + virtual Span get_diagnostics() const = 0; + //! Gets frame-local semantic-to-GUI generation mappings. + //! @return Returns a view invalidated by the next @ref build or @ref prepare + //! call. + virtual Span get_generated_nodes() const = 0; + //! Gets the stable GUI ID assigned to one root-document node role. + virtual GUI::id_t make_stable_id(const Guid& node, const c8* role) const = 0; + }; + + //! Creates one retained GameGUI runtime instance. + //! @param[in] desc Instance configuration. + //! @return Returns the new instance. + LUNA_GAME_GUI_API Ref new_instance(const InstanceDesc& desc); + } +} diff --git a/Modules/Luna/GameGUI/Node.hpp b/Modules/Luna/GameGUI/Node.hpp new file mode 100644 index 00000000..b5aa1292 --- /dev/null +++ b/Modules/Luna/GameGUI/Node.hpp @@ -0,0 +1,145 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Node.hpp +* @author JXMaster +* @date 2026/8/25 +*/ +#pragma once +#include "Document.hpp" +#include +#include + +namespace Luna +{ + namespace GameGUI + { + namespace Internal + { + struct Instance; + } + + //! Provides controlled services to node build callbacks. + class BuildContext + { + public: + //! Gets the target GUI context. + LUNA_GAME_GUI_API GUI::IContext* gui() const; + //! Gets a deterministic GUI ID for one semantic role on the current node. + LUNA_GAME_GUI_API GUI::id_t make_id(const c8* role) const; + //! Gets a deterministic GUI ID for one direct child semantic role. + LUNA_GAME_GUI_API GUI::id_t make_child_id(const Guid& child, const c8* role) const; + //! Gets persistent per-instance state for the current mounted node. + LUNA_GAME_GUI_API Variant& state(); + //! Gets optional typed immutable data compiled during instance preparation. + LUNA_GAME_GUI_API const Any& prepared_data() const; + //! Builds all direct child links in authored order. + LUNA_GAME_GUI_API RV build_children(); + //! Gets the incoming parent-owned child link, or `nullptr` for an instance root. + LUNA_GAME_GUI_API const ChildLink* parent_link() const; + //! Builds one nested GameGUI document asset at the current mount point. + //! @param[in] asset The already loaded GameGUI document asset. + //! @return Returns the nested generated root element. + LUNA_GAME_GUI_API R build_nested(Asset::asset_t asset); + //! Resolves one already loaded resource using the instance resolver. + LUNA_GAME_GUI_API R resolve_resource(Asset::asset_t asset) const; + //! Installs a Flex layout descriptor whose storage remains valid for the current frame. + LUNA_GAME_GUI_API void set_flex_layout(const GUI::ElementHandle& element, + const GUI::FlexLayoutDesc& desc); + //! Installs a Canvas layout descriptor whose item storage remains valid for the current frame. + LUNA_GAME_GUI_API void set_canvas_layout(const GUI::ElementHandle& element, + Span items, bool clip_children = false); + + private: + friend struct Internal::Instance; + explicit BuildContext(void* impl) : m_impl(impl) {} + void* m_impl; + }; + + //! Provides controlled services to node interaction resolution callbacks. + class ResolveContext + { + public: + //! Gets the target GUI context. + LUNA_GAME_GUI_API GUI::IContext* gui() const; + //! Gets the generated GUI ID for one semantic role on the current node. + LUNA_GAME_GUI_API GUI::id_t make_id(const c8* role) const; + //! Gets persistent per-instance state for the current mounted node. + LUNA_GAME_GUI_API Variant& state(); + //! Gets optional typed immutable data compiled during instance preparation. + LUNA_GAME_GUI_API const Any& prepared_data() const; + //! Emits one symbolic action. + LUNA_GAME_GUI_API void emit_action(const Name& name, const Variant& payload = Variant()); + //! Requests one host-driven layout pass before draw generation. + LUNA_GAME_GUI_API void request_relayout(); + + private: + friend struct Internal::Instance; + explicit ResolveContext(void* impl) : m_impl(impl) {} + void* m_impl; + }; + + //! Validates the payload of one known node. + using NodeValidateCallback = RV(*)(const NodeRecord& node, object_t userdata); + //! Compiles one known raw node payload into optional typed immutable data. + //! @remark Types stored in the returned @ref Any must be registered with Runtime. + using NodePrepareCallback = R(*)(const NodeRecord& node, object_t userdata); + //! Constructs persistent per-instance state for one mounted node. + using NodeStateCreateCallback = Variant(*)(object_t userdata); + //! Builds one known semantic node into GUI elements. + using NodeBuildCallback = R(*)(BuildContext& context, const NodeRecord& node, + object_t userdata); + //! Resolves interactions for one known semantic node after GUI input routing. + using NodeResolveCallback = RV(*)(ResolveContext& context, const NodeRecord& node, + object_t userdata); + //! Enumerates direct asset references stored by one known semantic node. + using NodeCollectAssetsCallback = void(*)(const NodeRecord& node, Vector& assets, + object_t userdata); + + //! Describes one registered GameGUI semantic node type. + struct NodeTypeDesc + { + //! Stable type ID serialized in node records. + Guid type; + //! Stable programmatic type name. + Name name; + //! Optional known-node validation callback. + NodeValidateCallback validate = nullptr; + //! Optional typed immutable cache construction callback. + NodePrepareCallback prepare = nullptr; + //! Optional per-instance state construction callback. + NodeStateCreateCallback create_state = nullptr; + //! Required runtime generation callback. + NodeBuildCallback build = nullptr; + //! Optional interaction resolution callback. + NodeResolveCallback resolve = nullptr; + //! Optional direct asset reference enumerator. + NodeCollectAssetsCallback collect_assets = nullptr; + //! Whether references enumerated by @ref collect_assets mount nested GameGUI documents. + bool nested_document = false; + //! User object retained by the registry and passed to callbacks. + ObjRef userdata; + }; + + //! Registers one semantic node type. + //! @param[in] desc Descriptor copied into the registry. + //! @return Returns @ref E_ALREADY_EXISTS if the type ID or name is already registered. + LUNA_GAME_GUI_API RV register_node_type(const NodeTypeDesc& desc); + + //! Unregisters one semantic node type. + //! @param[in] type Type ID to remove. + //! @return Returns @ref E_NOT_FOUND if the type is not registered. + LUNA_GAME_GUI_API RV unregister_node_type(const Guid& type); + + //! Gets one semantic node descriptor. + //! @param[in] type Type ID to query. + //! @return Returns a descriptor copy or @ref E_NOT_FOUND. + LUNA_GAME_GUI_API R get_node_type(const Guid& type); + + //! Enumerates all registered semantic node descriptors. + //! @param[out] descriptors Descriptor copies are appended to this vector. + LUNA_GAME_GUI_API void get_node_types(Vector& descriptors); + } +} diff --git a/Modules/Luna/GameGUI/Source/BuiltInNodes.cpp b/Modules/Luna/GameGUI/Source/BuiltInNodes.cpp new file mode 100644 index 00000000..95533431 --- /dev/null +++ b/Modules/Luna/GameGUI/Source/BuiltInNodes.cpp @@ -0,0 +1,600 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file BuiltInNodes.cpp +* @author JXMaster +* @date 2026/8/25 +*/ +#include +#define LUNA_GAME_GUI_API LUNA_EXPORT +#include "Internal.hpp" +#include + +namespace Luna +{ + namespace GameGUI + { + namespace + { + constexpr Guid FLEX_NODE_TYPE("{DA52CB84-F9D5-4F70-89E9-9110A28E8F90}"); + constexpr Guid CANVAS_NODE_TYPE("{4D9DD1E5-05B8-4578-9C2B-04990333EBA8}"); + constexpr Guid PANEL_NODE_TYPE("{3422E8A6-A381-4E37-8EB0-AEA8CC179F5B}"); + constexpr Guid TEXT_NODE_TYPE("{2A517F8A-4316-4E77-8A4F-4FFB2A32026B}"); + constexpr Guid BUTTON_NODE_TYPE("{88E839CE-7731-4B93-86E6-B7A80FD6DCE5}"); + constexpr Guid ASSET_INSTANCE_NODE_TYPE("{D13F9808-6F27-4B43-B7B0-C771E5850AB1}"); + + RV decode_asset_guid(const Variant& value, Guid& guid) + { + if(value.type() != VariantType::string) + { + return set_error(E_BAD_DATA, + "GameGUI AssetInstance asset must be a canonical GUID string."); + } + RV result = decode_guid(value.c_str(), value.str().size(), guid); + if(!result.valid()) + { + return set_error(E_BAD_DATA, + "GameGUI AssetInstance asset must be a canonical GUID string."); + } + return ok; + } + + f32 get_number(const Variant& properties, const c8* key, f32 default_value) + { + const Variant& value = properties[Name(key)]; + return value.type() == VariantType::number ? (f32)value.fnum() : default_value; + } + + bool get_boolean(const Variant& properties, const c8* key, bool default_value) + { + const Variant& value = properties[Name(key)]; + return value.type() == VariantType::boolean ? value.boolean() : default_value; + } + + GUI::FlexWrap get_flex_wrap(const Variant& properties) + { + Name value = properties["wrap"].str(); + if(value == Name("wrap")) return GUI::FlexWrap::wrap; + if(value == Name("wrap_reverse")) return GUI::FlexWrap::wrap_reverse; + return GUI::FlexWrap::none; + } + + GUI::FlexAlignment get_flex_alignment(const Variant& properties, const c8* key, + GUI::FlexAlignment default_value) + { + Name value = properties[Name(key)].str(); + if(value == Name("start")) return GUI::FlexAlignment::start; + if(value == Name("center")) return GUI::FlexAlignment::center; + if(value == Name("end")) return GUI::FlexAlignment::end; + if(value == Name("stretch")) return GUI::FlexAlignment::stretch; + if(value == Name("space_between")) return GUI::FlexAlignment::space_between; + if(value == Name("space_around")) return GUI::FlexAlignment::space_around; + if(value == Name("space_evenly")) return GUI::FlexAlignment::space_evenly; + return default_value; + } + + Float2U get_float2(const Variant& value, const Float2U& default_value) + { + if(value.type() != VariantType::array || value.size() != 2) return default_value; + return Float2U((f32)value[0].fnum(default_value.x), (f32)value[1].fnum(default_value.y)); + } + + Float4U get_float4(const Variant& value, const Float4U& default_value) + { + if(value.type() != VariantType::array || value.size() != 4) return default_value; + return Float4U((f32)value[0].fnum(default_value.x), (f32)value[1].fnum(default_value.y), + (f32)value[2].fnum(default_value.z), (f32)value[3].fnum(default_value.w)); + } + + bool is_number_array(const Variant& value, usize count) + { + if(value.type() != VariantType::array || value.size() != count) return false; + for(const Variant& item : value.values()) + { + if(item.type() != VariantType::number) return false; + } + return true; + } + + bool is_visual_effect_type(const Name& type) + { + return type == Name("rect") || type == Name("gradient_rect") || + type == Name("rounded_rect") || + type == Name("rounded_rect_stroke") || type == Name("shadow"); + } + + RV validate_visual_effects(const Variant& properties) + { + if(!properties.contains("visual_effects")) return ok; + const Variant& effects = properties["visual_effects"]; + if(effects.type() != VariantType::array) + { + return set_error(E_BAD_DATA, + "GameGUI visual_effects must be an array."); + } + const c8* array_fields[] = { + "inset", "color", "color_top_right", "color_bottom_right", + "color_bottom_left" + }; + const c8* number_fields[] = { + "radius", "line_width", "shadow_softness", "shadow_spread" + }; + for(const Variant& effect : effects.values()) + { + if(effect.type() != VariantType::object) + { + return set_error(E_BAD_DATA, + "Every GameGUI visual effect must be an object."); + } + Name phase = effect["phase"].str(); + if(phase != Name("before_children") && phase != Name("after_children")) + { + return set_error(E_BAD_DATA, + "GameGUI visual-effect phase must be before_children or after_children."); + } + if(!is_visual_effect_type(effect["type"].str())) + { + return set_error(E_BAD_DATA, + "GameGUI visual-effect type is unsupported."); + } + for(const c8* field : array_fields) + { + if(effect.contains(field) && !is_number_array(effect[field], 4)) + { + return set_error(E_BAD_DATA, + "GameGUI visual-effect vector fields must contain four numbers."); + } + } + if(effect.contains("shadow_offset") && + !is_number_array(effect["shadow_offset"], 2)) + { + return set_error(E_BAD_DATA, + "GameGUI visual-effect shadow_offset must contain two numbers."); + } + for(const c8* field : number_fields) + { + if(effect.contains(field) && + effect[field].type() != VariantType::number) + { + return set_error(E_BAD_DATA, + "GameGUI visual-effect scalar fields must be numbers."); + } + } + if(effect.contains("shadow_mode")) + { + Name mode = effect["shadow_mode"].str(); + if(mode != Name("outer") && mode != Name("inner")) + { + return set_error(E_BAD_DATA, + "GameGUI visual-effect shadow_mode must be outer or inner."); + } + } + } + return ok; + } + + GUI::ElementVisualEffect decode_visual_effect(const Variant& source) + { + GUI::ElementVisualEffect effect; + GUI::DrawCommand& command = effect.command; + Name type = source["type"].str(); + if(type == Name("gradient_rect")) + command.type = GUI::DrawCommandType::gradient_rect; + else if(type == Name("rounded_rect")) + command.type = GUI::DrawCommandType::rounded_rect; + else if(type == Name("rounded_rect_stroke")) + command.type = GUI::DrawCommandType::rounded_rect_stroke; + else if(type == Name("shadow")) command.type = GUI::DrawCommandType::shadow; + else command.type = GUI::DrawCommandType::rect; + command.rect_reference = GUI::DrawCommandRectReference::element; + Float4U inset = get_float4(source["inset"], Float4U(0.0f)); + command.rect = RectF(inset.x, inset.y, -inset.x - inset.z, + -inset.y - inset.w); + command.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); + Float4U default_color = command.type == GUI::DrawCommandType::shadow ? + Float4U(0.0f, 0.0f, 0.0f, 0.5f) : Float4U(1.0f); + command.color = get_float4(source["color"], default_color); + command.color_top_right = get_float4(source["color_top_right"], command.color); + command.color_bottom_right = get_float4(source["color_bottom_right"], command.color); + command.color_bottom_left = get_float4(source["color_bottom_left"], command.color); + command.radius = get_number(source, "radius", 0.0f); + command.line_width = get_number(source, "line_width", 1.0f); + command.shadow.offset = get_float2(source["shadow_offset"], Float2U(0.0f)); + command.shadow.softness = get_number(source, "shadow_softness", 0.0f); + command.shadow.spread = get_number(source, "shadow_spread", 0.0f); + command.shadow.mode = source["shadow_mode"].str() == Name("inner") ? + GUI::ShadowMode::inner : GUI::ShadowMode::outer; + return effect; + } + + void set_element_visuals(BuildContext& context, const GUI::ElementHandle& element, + const NodeRecord& node, Span built_in = {}) + { + Vector before_children; + Vector after_children; + before_children.insert(before_children.end(), built_in.begin(), built_in.end()); + const Variant& authored = node.properties["visual_effects"]; + if(authored.type() == VariantType::array) + { + for(const Variant& source : authored.values()) + { + GUI::ElementVisualEffect effect = decode_visual_effect(source); + if(source["phase"].str() == Name("after_children")) + after_children.push_back(move(effect)); + else before_children.push_back(move(effect)); + } + } + if(before_children.empty() && after_children.empty()) return; + GUI::ElementVisualConfig config; + config.before_children = before_children.cspan(); + config.after_children = after_children.cspan(); + lupanic_if_failed(context.gui()->set_element_visual_config(element, config)); + } + + GUI::LayoutConfig get_layout(const Variant& properties) + { + GUI::LayoutConfig layout; + if(properties["width"].type() == VariantType::number) + { + layout.width.kind = GUI::SizeKind::fixed; + layout.width.value = (f32)properties["width"].fnum(); + } + else if(properties["width_percent"].type() == VariantType::number) + { + layout.width.kind = GUI::SizeKind::percent; + layout.width.value = (f32)properties["width_percent"].fnum(); + } + if(properties["height"].type() == VariantType::number) + { + layout.height.kind = GUI::SizeKind::fixed; + layout.height.value = (f32)properties["height"].fnum(); + } + else if(properties["height_percent"].type() == VariantType::number) + { + layout.height.kind = GUI::SizeKind::percent; + layout.height.value = (f32)properties["height_percent"].fnum(); + } + layout.margin = get_float4(properties["margin"], Float4U(0.0f)); + layout.padding = get_float4(properties["padding"], Float4U(0.0f)); + layout.flex_grow = get_number(properties, "flex_grow", 0.0f); + layout.flex_shrink = get_number(properties, "flex_shrink", 1.0f); + return layout; + } + + void set_common_element_data(BuildContext& context, const GUI::ElementHandle& element, + const NodeRecord& node) + { + context.gui()->set_layout_config(element, get_layout(node.properties)); + if(!node.name.empty()) context.gui()->set_element_debug_name(element, node.name); + } + + RV validate_properties_object(const NodeRecord& node, object_t userdata) + { + if(node.properties.valid() && node.properties.type() != VariantType::object) + { + return set_error(E_BAD_DATA, "GameGUI built-in node properties must be an object."); + } + return validate_visual_effects(node.properties); + } + + R build_flex(BuildContext& context, const NodeRecord& node, + object_t userdata) + { + GUI::ElementHandle element = context.gui()->begin_element(context.make_id("element")); + set_common_element_data(context, element, node); + GUI::FlexLayoutDesc layout; + layout.axis = node.properties["axis"].str() == Name("x") ? + GUI::LayoutAxis::x : GUI::LayoutAxis::y; + layout.reverse = get_boolean(node.properties, "reverse", false); + layout.wrap = get_flex_wrap(node.properties); + layout.main_alignment = get_flex_alignment(node.properties, "main_alignment", + GUI::FlexAlignment::start); + layout.cross_alignment = get_flex_alignment(node.properties, "cross_alignment", + GUI::FlexAlignment::stretch); + layout.line_alignment = get_flex_alignment(node.properties, "line_alignment", + GUI::FlexAlignment::start); + layout.main_axis_gap = get_number(node.properties, "gap", 0.0f); + layout.cross_axis_gap = get_number(node.properties, "line_gap", 0.0f); + layout.clip_children = get_boolean(node.properties, "clip_children", false); + context.set_flex_layout(element, layout); + set_element_visuals(context, element, node); + RV children_result = context.build_children(); + context.gui()->end_element(); + if(!children_result.valid()) return children_result.errcode(); + return element; + } + + GUI::CanvasLayoutItem decode_canvas_item(BuildContext& context, const ChildLink& link) + { + GUI::CanvasLayoutItem item; + item.element_id = context.make_child_id(link.child, "element"); + const Variant& attachment = link.attachment; + item.anchor_min = get_float2(attachment["anchor_min"], Float2U(0.0f)); + item.anchor_max = get_float2(attachment["anchor_max"], item.anchor_min); + item.offset = get_float4(attachment["offset"], Float4U(0.0f)); + item.pivot = get_float2(attachment["pivot"], Float2U(0.0f)); + return item; + } + + R build_canvas(BuildContext& context, const NodeRecord& node, + object_t userdata) + { + GUI::ElementHandle element = context.gui()->begin_element(context.make_id("element")); + set_common_element_data(context, element, node); + Vector items; + for(const ChildLink& link : node.children) items.push_back(decode_canvas_item(context, link)); + context.set_canvas_layout(element, + Span(items.data(), items.size()), + get_boolean(node.properties, "clip_children", false)); + set_element_visuals(context, element, node); + RV children_result = context.build_children(); + context.gui()->end_element(); + if(!children_result.valid()) return children_result.errcode(); + return element; + } + + GUI::DrawCommand panel_command(const Variant& properties, + const Float4U& default_color) + { + GUI::DrawCommand command; + command.type = GUI::DrawCommandType::rounded_rect; + command.rect_reference = GUI::DrawCommandRectReference::element; + command.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); + command.color = get_float4(properties["color"], default_color); + command.radius = get_number(properties, "radius", 0.0f); + return command; + } + + R build_panel(BuildContext& context, const NodeRecord& node, + object_t userdata) + { + GUI::ElementHandle element = context.gui()->begin_element(context.make_id("element")); + set_common_element_data(context, element, node); + GUI::FlexLayoutDesc layout; + context.set_flex_layout(element, layout); + GUI::ElementVisualEffect visual; + visual.command = panel_command(node.properties, + Float4U(0.22f, 0.22f, 0.22f, 1.0f)); + set_element_visuals(context, element, node, + Span(&visual, 1)); + RV children_result = context.build_children(); + context.gui()->end_element(); + if(!children_result.valid()) return children_result.errcode(); + return element; + } + + GUI::DrawCommand text_command(const Variant& properties) + { + GUI::DrawCommand command; + command.type = GUI::DrawCommandType::text; + command.rect_reference = GUI::DrawCommandRectReference::element; + command.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); + command.color = get_float4(properties["text_color"], Float4U(1.0f)); + command.font = properties["font"].str(); + command.font_size = get_number(properties, "font_size", 16.0f); + command.text = properties["text"].c_str(); + return command; + } + + GUI::MeasureResult measure_text(GUI::IContext* context, + const GUI::ElementHandle& element, const Float2U& available_size, void* userdata) + { + GUI::MeasureResult result; + const GUI::LayoutConfig& layout = context->get_element(element.index)->layout; + if(layout.width.kind != GUI::SizeKind::fit && + layout.height.kind != GUI::SizeKind::fit) return result; + const NodeRecord& node = *static_cast(userdata); + Name text = node.properties["text"].str(); + if(text.empty()) return result; + GUI::FontDesc font = context->get_font(node.properties["font"].str()); + if(!font.font) + { + font.font = Font::get_default_font(); + font.font_index = 0; + } + f32 font_size = get_number(node.properties, "font_size", 16.0f); + if(!font.font || font_size <= 0.0f) return result; + + f32 padding = layout.padding.x + layout.padding.z; + f32 width = available_size.x + padding; + if(layout.width.kind == GUI::SizeKind::fixed) width = layout.width.value; + else if(layout.width.kind == GUI::SizeKind::percent) width *= layout.width.value; + width = max(width, layout.width.min); + if(layout.width.max >= 0.0f) + width = min(width, max(layout.width.max, layout.width.min)); + width = max(width - padding, 1.0f); + + VG::TextArrangeSection section; + section.font_file = font.font; + section.font_index = font.font_index; + section.font_size = font_size; + section.num_chars = text.size(); + // Measure all lines; the parent's available height must not truncate Auto height. + VG::TextArrangeResult arranged = VG::arrange_text(text.c_str(), text.size(), + Span(§ion, 1), + RectF(0.0f, 0.0f, width, F32_MAX), + VG::TextAlignment::end, VG::TextAlignment::begin); + result.desired = Float2U(arranged.bounding_rect.width, arranged.bounding_rect.height); + return result; + } + + R build_text(BuildContext& context, const NodeRecord& node, + object_t userdata) + { + GUI::ElementHandle element = context.gui()->begin_element(context.make_id("element")); + set_common_element_data(context, element, node); + GUI::LayoutCallbackConfig callbacks; + callbacks.algorithm = "GameGUI.Text"; + callbacks.measure_callback = measure_text; + // The instance owns this immutable record until it is prepared again. + callbacks.userdata = const_cast(&node); + context.gui()->set_layout_callback_config(element, callbacks); + GUI::ElementVisualEffect visual; + visual.command = text_command(node.properties); + set_element_visuals(context, element, node, + Span(&visual, 1)); + context.gui()->end_element(); + return element; + } + + R build_button(BuildContext& context, const NodeRecord& node, + object_t userdata) + { + GUI::ElementHandle element = context.gui()->begin_element(context.make_id("element")); + set_common_element_data(context, element, node); + GUI::Interactable interactable; + interactable.flags = GUI::InteractableFlag::hoverable | + GUI::InteractableFlag::activatable | GUI::InteractableFlag::focusable; + interactable.pointer_hit_behavior = GUI::PointerHitBehavior::target; + context.gui()->set_interactable(element, interactable); + GUI::ElementVisualEffect visuals[2]; + visuals[0].command = panel_command(node.properties, + Float4U(0.22f, 0.22f, 0.22f, 1.0f)); + visuals[1].command = text_command(node.properties); + set_element_visuals(context, element, node, + Span(visuals, 2)); + context.gui()->end_element(); + return element; + } + + RV resolve_button(ResolveContext& context, const NodeRecord& node, object_t userdata) + { + GUI::InteractionState interaction = context.gui()->get_interaction_state( + context.make_id("element")); + if(interaction.clicked) + { + Name action = node.properties["action"].str(); + if(!action.empty()) + { + Variant& state = context.state(); + state["click_count"] = state["click_count"].unum() + 1; + context.emit_action(action, node.properties["action_payload"]); + } + } + return ok; + } + + RV validate_asset_instance(const NodeRecord& node, object_t userdata) + { + RV properties_result = validate_properties_object(node, userdata); + if(!properties_result.valid()) return properties_result; + if(!node.children.empty()) + { + return set_error(E_BAD_DATA, "GameGUI AssetInstance nodes cannot have local children."); + } + Guid asset_guid; + RV result = decode_asset_guid(node.properties["asset"], asset_guid); + if(!result.valid()) return result; + if(asset_guid == Guid()) + { + return set_error(E_BAD_DATA, "GameGUI AssetInstance requires a non-zero asset GUID."); + } + return ok; + } + + R prepare_asset_instance(const NodeRecord& node, object_t userdata) + { + Guid asset_guid; + RV result = decode_asset_guid(node.properties["asset"], asset_guid); + if(!result.valid()) return result.errcode(); + Any prepared; + prepared.emplace(Asset::get_asset(asset_guid)); + return prepared; + } + + void collect_asset_instance(const NodeRecord& node, Vector& assets, + object_t userdata) + { + Guid asset_guid; + RV result = decode_asset_guid(node.properties["asset"], asset_guid); + if(result.valid() && asset_guid != Guid()) assets.push_back(Asset::get_asset(asset_guid)); + } + + R build_asset_instance(BuildContext& context, const NodeRecord& node, + object_t userdata) + { + const Asset::asset_t* asset = context.prepared_data().as(); + if(!asset || !*asset) return E_BAD_DATA; + GUI::ElementHandle element = context.gui()->begin_element(context.make_id("element")); + set_common_element_data(context, element, node); + GUI::FlexLayoutDesc layout; + context.set_flex_layout(element, layout); + set_element_visuals(context, element, node); + auto nested = context.build_nested(*asset); + context.gui()->end_element(); + if(!nested.valid()) return nested.errcode(); + return element; + } + + RV register_if_missing(const NodeTypeDesc& desc) + { + auto existing = get_node_type(desc.type); + if(existing.valid()) return existing.get().name == desc.name ? ok : RV(E_ALREADY_EXISTS); + return register_node_type(desc); + } + } + + LUNA_GAME_GUI_API Guid get_flex_node_type() { return FLEX_NODE_TYPE; } + LUNA_GAME_GUI_API Guid get_canvas_node_type() { return CANVAS_NODE_TYPE; } + LUNA_GAME_GUI_API Guid get_panel_node_type() { return PANEL_NODE_TYPE; } + LUNA_GAME_GUI_API Guid get_text_node_type() { return TEXT_NODE_TYPE; } + LUNA_GAME_GUI_API Guid get_button_node_type() { return BUTTON_NODE_TYPE; } + LUNA_GAME_GUI_API Guid get_asset_instance_node_type() { return ASSET_INSTANCE_NODE_TYPE; } + + LUNA_GAME_GUI_API RV register_builtin_node_types() + { + lutry + { + NodeTypeDesc desc; + desc.type = FLEX_NODE_TYPE; + desc.name = "Flex"; + desc.validate = validate_properties_object; + desc.build = build_flex; + luexp(register_if_missing(desc)); + + desc = NodeTypeDesc(); + desc.type = CANVAS_NODE_TYPE; + desc.name = "Canvas"; + desc.validate = validate_properties_object; + desc.build = build_canvas; + luexp(register_if_missing(desc)); + + desc = NodeTypeDesc(); + desc.type = PANEL_NODE_TYPE; + desc.name = "Panel"; + desc.validate = validate_properties_object; + desc.build = build_panel; + luexp(register_if_missing(desc)); + + desc = NodeTypeDesc(); + desc.type = TEXT_NODE_TYPE; + desc.name = "Text"; + desc.validate = validate_properties_object; + desc.build = build_text; + luexp(register_if_missing(desc)); + + desc = NodeTypeDesc(); + desc.type = BUTTON_NODE_TYPE; + desc.name = "Button"; + desc.validate = validate_properties_object; + desc.build = build_button; + desc.resolve = resolve_button; + luexp(register_if_missing(desc)); + + desc = NodeTypeDesc(); + desc.type = ASSET_INSTANCE_NODE_TYPE; + desc.name = "AssetInstance"; + desc.validate = validate_asset_instance; + desc.prepare = prepare_asset_instance; + desc.build = build_asset_instance; + desc.collect_assets = collect_asset_instance; + desc.nested_document = true; + luexp(register_if_missing(desc)); + } + lucatchret; + return ok; + } + } +} diff --git a/Modules/Luna/GameGUI/Source/CookedDocument.cpp b/Modules/Luna/GameGUI/Source/CookedDocument.cpp new file mode 100644 index 00000000..dc9a2966 --- /dev/null +++ b/Modules/Luna/GameGUI/Source/CookedDocument.cpp @@ -0,0 +1,370 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file CookedDocument.cpp +* @author JXMaster +* @date 2026/8/29 +*/ +#include +#define LUNA_GAME_GUI_API LUNA_EXPORT +#include "Internal.hpp" +#include +#include +#include + +namespace Luna +{ + namespace GameGUI + { + namespace + { + constexpr u8 COOKED_MAGIC[8] = {'L', 'U', 'N', 'A', 'G', 'G', 'U', 'I'}; + constexpr u32 MAX_COOKED_COLLECTION_SIZE = 16 * 1024 * 1024; + constexpr u32 MAX_COOKED_VARIANT_DEPTH = 256; + + RV write_exact(IStream* stream, const void* data, usize size) + { + usize written = 0; + lutry + { + luexp(stream->write(data, size, &written)); + } + lucatchret; + return written == size ? ok : RV(E_IO_ERROR); + } + + RV read_exact(IStream* stream, void* data, usize size) + { + usize read_bytes = 0; + lutry + { + luexp(stream->read(data, size, &read_bytes)); + } + lucatchret; + return read_bytes == size ? ok : RV(E_BAD_DATA); + } + + RV write_u8(IStream* stream, u8 value) + { + return write_exact(stream, &value, sizeof(value)); + } + + RV write_u32(IStream* stream, u32 value) + { + u8 bytes[4]; + for(u32 i = 0; i < 4; ++i) bytes[i] = (u8)(value >> (i * 8)); + return write_exact(stream, bytes, sizeof(bytes)); + } + + RV write_u64(IStream* stream, u64 value) + { + u8 bytes[8]; + for(u32 i = 0; i < 8; ++i) bytes[i] = (u8)(value >> (i * 8)); + return write_exact(stream, bytes, sizeof(bytes)); + } + + R read_u8(IStream* stream) + { + u8 value; + RV result = read_exact(stream, &value, sizeof(value)); + if(failed(result)) return result.errcode(); + return value; + } + + R read_u32(IStream* stream) + { + u8 bytes[4]; + RV result = read_exact(stream, bytes, sizeof(bytes)); + if(failed(result)) return result.errcode(); + u32 value = 0; + for(u32 i = 0; i < 4; ++i) value |= (u32)bytes[i] << (i * 8); + return value; + } + + R read_u64(IStream* stream) + { + u8 bytes[8]; + RV result = read_exact(stream, bytes, sizeof(bytes)); + if(failed(result)) return result.errcode(); + u64 value = 0; + for(u32 i = 0; i < 8; ++i) value |= (u64)bytes[i] << (i * 8); + return value; + } + + RV write_guid(IStream* stream, const Guid& value) + { + lutry + { + luexp(write_u64(stream, value.high)); + luexp(write_u64(stream, value.low)); + } + lucatchret; + return ok; + } + + R read_guid(IStream* stream) + { + lutry + { + lulet(high, read_u64(stream)); + lulet(low, read_u64(stream)); + return Guid(high, low); + } + lucatchret; + return E_FAILURE; + } + + RV write_string(IStream* stream, const c8* value) + { + usize size = value ? strlen(value) : 0; + if(size > U32_MAX) return E_BAD_DATA; + lutry + { + luexp(write_u32(stream, (u32)size)); + if(size) luexp(write_exact(stream, value, size)); + } + lucatchret; + return ok; + } + + R read_string(IStream* stream) + { + lutry + { + lulet(size, read_u32(stream)); + if(size > MAX_COOKED_COLLECTION_SIZE) + luthrow(set_error(E_BAD_DATA, "GameGUI cooked string is too large.")); + String value(size, '\0'); + if(size) luexp(read_exact(stream, value.data(), size)); + return value; + } + lucatchret; + return E_FAILURE; + } + + RV write_variant(IStream* stream, const Variant& value, u32 depth = 0) + { + if(depth >= MAX_COOKED_VARIANT_DEPTH) return E_BAD_DATA; + lutry + { + luexp(write_u8(stream, (u8)value.type())); + switch(value.type()) + { + case VariantType::null: break; + case VariantType::object: + if(value.size() > U32_MAX) luthrow(E_BAD_DATA); + luexp(write_u32(stream, (u32)value.size())); + { + Vector keys; + keys.reserve(value.size()); + for(const auto& pair : value.key_values()) keys.push_back(pair.first); + sort(keys.begin(), keys.end(), [](const Name& left, const Name& right) + { + return strcmp(left.c_str(), right.c_str()) < 0; + }); + for(const Name& key : keys) + { + luexp(write_string(stream, key.c_str())); + luexp(write_variant(stream, value[key], depth + 1)); + } + } + break; + case VariantType::array: + if(value.size() > U32_MAX) luthrow(E_BAD_DATA); + luexp(write_u32(stream, (u32)value.size())); + for(const Variant& child : value.values()) + luexp(write_variant(stream, child, depth + 1)); + break; + case VariantType::number: + luexp(write_u8(stream, (u8)value.number_type())); + if(value.number_type() == VariantNumberType::number_i64) + { + luexp(write_u64(stream, (u64)value.inum())); + } + else if(value.number_type() == VariantNumberType::number_u64) + { + luexp(write_u64(stream, value.unum())); + } + else + { + f64 number = value.fnum(); + u64 bits; + memcpy(&bits, &number, sizeof(bits)); + luexp(write_u64(stream, bits)); + } + break; + case VariantType::string: + luexp(write_string(stream, value.c_str())); + break; + case VariantType::boolean: + luexp(write_u8(stream, value.boolean() ? 1 : 0)); + break; + case VariantType::blob: + if(value.blob_size() > U32_MAX || value.blob_alignment() > U32_MAX) + luthrow(E_BAD_DATA); + luexp(write_u32(stream, (u32)value.blob_alignment())); + luexp(write_u32(stream, (u32)value.blob_size())); + if(value.blob_size()) + luexp(write_exact(stream, value.blob_data(), value.blob_size())); + break; + default: luthrow(E_BAD_DATA); + } + } + lucatchret; + return ok; + } + + R read_variant(IStream* stream, u32 depth = 0) + { + if(depth >= MAX_COOKED_VARIANT_DEPTH) return E_BAD_DATA; + lutry + { + lulet(type_value, read_u8(stream)); + VariantType type = (VariantType)type_value; + switch(type) + { + case VariantType::null: return Variant(); + case VariantType::object: + { + lulet(count, read_u32(stream)); + if(count > MAX_COOKED_COLLECTION_SIZE) luthrow(E_BAD_DATA); + Variant value(VariantType::object); + for(u32 i = 0; i < count; ++i) + { + lulet(key, read_string(stream)); + lulet(child, read_variant(stream, depth + 1)); + value[Name(key.c_str())] = move(child); + } + return value; + } + case VariantType::array: + { + lulet(count, read_u32(stream)); + if(count > MAX_COOKED_COLLECTION_SIZE) luthrow(E_BAD_DATA); + Variant value(VariantType::array); + for(u32 i = 0; i < count; ++i) + { + lulet(child, read_variant(stream, depth + 1)); + value.push_back(move(child)); + } + return value; + } + case VariantType::number: + { + lulet(number_type_value, read_u8(stream)); + lulet(bits, read_u64(stream)); + VariantNumberType number_type = (VariantNumberType)number_type_value; + if(number_type == VariantNumberType::number_i64) return Variant((i64)bits); + if(number_type == VariantNumberType::number_u64) return Variant(bits); + if(number_type != VariantNumberType::number_f64) luthrow(E_BAD_DATA); + f64 number; + memcpy(&number, &bits, sizeof(number)); + return Variant(number); + } + case VariantType::string: + { + lulet(value, read_string(stream)); + return Variant(value.c_str()); + } + case VariantType::boolean: + { + lulet(value, read_u8(stream)); + if(value > 1) luthrow(E_BAD_DATA); + return Variant(value != 0); + } + case VariantType::blob: + { + lulet(alignment, read_u32(stream)); + lulet(size, read_u32(stream)); + if(size > MAX_COOKED_COLLECTION_SIZE) luthrow(E_BAD_DATA); + Blob blob(size, alignment); + if(size) luexp(read_exact(stream, blob.data(), size)); + return Variant(move(blob)); + } + default: luthrow(E_BAD_DATA); + } + } + lucatchret; + return E_FAILURE; + } + } + + R> read_cooked_document(IStream* stream) + { + if(!stream) return E_BAD_ARGUMENTS; + lutry + { + u8 magic[sizeof(COOKED_MAGIC)]; + luexp(read_exact(stream, magic, sizeof(magic))); + if(memcmp(magic, COOKED_MAGIC, sizeof(magic))) + luthrow(set_error(E_BAD_DATA, "Invalid GameGUI cooked document header.")); + Ref document = new_object(); + luset(document->root, read_guid(stream)); + lulet(node_count, read_u32(stream)); + if(node_count > MAX_COOKED_COLLECTION_SIZE) luthrow(E_BAD_DATA); + document->nodes.reserve(node_count); + for(u32 i = 0; i < node_count; ++i) + { + NodeRecord node; + luset(node.id, read_guid(stream)); + luset(node.type, read_guid(stream)); + lulet(name, read_string(stream)); + node.name = name.c_str(); + luset(node.properties, read_variant(stream)); + lulet(child_count, read_u32(stream)); + if(child_count > MAX_COOKED_COLLECTION_SIZE) luthrow(E_BAD_DATA); + node.children.reserve(child_count); + for(u32 child_index = 0; child_index < child_count; ++child_index) + { + ChildLink link; + luset(link.child, read_guid(stream)); + lulet(slot, read_string(stream)); + link.slot = slot.c_str(); + lulet(has_attachment, read_u8(stream)); + if(has_attachment > 1) luthrow(E_BAD_DATA); + if(has_attachment) luset(link.attachment, read_variant(stream)); + node.children.push_back(move(link)); + } + document->nodes.push_back(move(node)); + } + luexp(validate_document(*document)); + return document; + } + lucatchret; + return E_FAILURE; + } + + RV write_cooked_document(IStream* stream, const Document& document) + { + if(!stream) return E_BAD_ARGUMENTS; + lutry + { + luexp(validate_document(document)); + luexp(write_exact(stream, COOKED_MAGIC, sizeof(COOKED_MAGIC))); + luexp(write_guid(stream, document.root)); + if(document.nodes.size() > U32_MAX) luthrow(E_BAD_DATA); + luexp(write_u32(stream, (u32)document.nodes.size())); + for(const NodeRecord& node : document.nodes) + { + luexp(write_guid(stream, node.id)); + luexp(write_guid(stream, node.type)); + luexp(write_string(stream, node.name.c_str())); + luexp(write_variant(stream, node.properties)); + if(node.children.size() > U32_MAX) luthrow(E_BAD_DATA); + luexp(write_u32(stream, (u32)node.children.size())); + for(const ChildLink& link : node.children) + { + luexp(write_guid(stream, link.child)); + luexp(write_string(stream, link.slot.c_str())); + luexp(write_u8(stream, link.attachment.valid() ? 1 : 0)); + if(link.attachment.valid()) luexp(write_variant(stream, link.attachment)); + } + } + } + lucatchret; + return ok; + } + } +} diff --git a/Modules/Luna/GameGUI/Source/Document.cpp b/Modules/Luna/GameGUI/Source/Document.cpp new file mode 100644 index 00000000..3f22e961 --- /dev/null +++ b/Modules/Luna/GameGUI/Source/Document.cpp @@ -0,0 +1,193 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Document.cpp +* @author JXMaster +* @date 2026/8/25 +*/ +#include +#define LUNA_GAME_GUI_API LUNA_EXPORT +#include "Internal.hpp" +#include + +namespace Luna +{ + namespace GameGUI + { + namespace + { + void add_diagnostic(Vector* diagnostics, DiagnosticSeverity severity, + const Guid& node, const c8* message) + { + if(!diagnostics) return; + Diagnostic diagnostic; + diagnostic.severity = severity; + diagnostic.node = node; + diagnostic.message = message; + diagnostics->push_back(move(diagnostic)); + } + + bool visit_node(u32 index, const Document& document, const HashMap& indices, + Vector& colors, usize& visited, Vector* diagnostics) + { + if(colors[index] == 1) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, document.nodes[index].id, + "A cycle exists in the GameGUI node tree."); + return false; + } + if(colors[index] == 2) return true; + colors[index] = 1; + ++visited; + for(const ChildLink& child : document.nodes[index].children) + { + auto child_index = indices.find(child.child); + if(child_index == indices.end()) continue; + if(!visit_node(child_index->second, document, indices, colors, visited, diagnostics)) + return false; + } + colors[index] = 2; + return true; + } + } + + LUNA_GAME_GUI_API Name get_asset_type() + { + return "GameGUI"; + } + + LUNA_GAME_GUI_API const NodeRecord* find_node(const Document& document, const Guid& id) + { + for(const NodeRecord& node : document.nodes) + { + if(node.id == id) return &node; + } + return nullptr; + } + + LUNA_GAME_GUI_API NodeRecord* find_node(Document& document, const Guid& id) + { + for(NodeRecord& node : document.nodes) + { + if(node.id == id) return &node; + } + return nullptr; + } + + LUNA_GAME_GUI_API RV validate_document(const Document& document, + Vector* diagnostics) + { + bool valid = true; + if(document.root == Guid()) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, Guid(), + "The GameGUI document root ID is zero."); + valid = false; + } + if(document.nodes.empty()) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, Guid(), + "The GameGUI document contains no nodes."); + valid = false; + } + HashMap indices; + for(u32 i = 0; i < document.nodes.size(); ++i) + { + const NodeRecord& node = document.nodes[i]; + if(node.id == Guid() || node.type == Guid()) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, node.id, + "A GameGUI node has a zero node ID or type ID."); + valid = false; + } + if(node.properties.type() != VariantType::object) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, node.id, + "GameGUI node properties must be an object."); + valid = false; + } + if(!indices.insert(make_pair(node.id, i)).second) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, node.id, + "Duplicate GameGUI node ID."); + valid = false; + } + } + auto root = indices.find(document.root); + if(root == indices.end()) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, document.root, + "The GameGUI root node does not exist."); + valid = false; + } + Vector parent_counts(document.nodes.size(), 0); + for(const NodeRecord& node : document.nodes) + { + for(const ChildLink& child : node.children) + { + auto child_index = indices.find(child.child); + if(child_index == indices.end()) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, node.id, + "A GameGUI child link references a missing node."); + valid = false; + continue; + } + if(++parent_counts[child_index->second] > 1) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, child.child, + "A GameGUI node has more than one parent."); + valid = false; + } + } + } + if(root != indices.end() && parent_counts[root->second] != 0) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, document.root, + "The GameGUI root node has a parent."); + valid = false; + } + if(root != indices.end()) + { + Vector colors(document.nodes.size(), 0); + usize visited = 0; + if(!visit_node(root->second, document, indices, colors, visited, diagnostics)) valid = false; + if(visited != document.nodes.size()) + { + add_diagnostic(diagnostics, DiagnosticSeverity::error, Guid(), + "The GameGUI document contains nodes outside the root tree."); + valid = false; + } + } + if(!valid) return set_error(E_BAD_DATA, "GameGUI document topology validation failed."); + return ok; + } + + void get_direct_referred_assets(const Document& document, + Vector& assets) + { + Vector collected; + for(const NodeRecord& node : document.nodes) + { + auto desc = get_node_type(node.type); + if(desc.valid() && desc.get().collect_assets) + desc.get().collect_assets(node, collected, desc.get().userdata.get()); + } + for(Asset::asset_t asset : collected) + { + bool duplicate = false; + for(Asset::asset_t existing : assets) + { + if(existing == asset) + { + duplicate = true; + break; + } + } + if(asset && !duplicate) assets.push_back(asset); + } + } + } +} diff --git a/Modules/Luna/GameGUI/Source/GameGUI.cpp b/Modules/Luna/GameGUI/Source/GameGUI.cpp new file mode 100644 index 00000000..8b340548 --- /dev/null +++ b/Modules/Luna/GameGUI/Source/GameGUI.cpp @@ -0,0 +1,130 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file GameGUI.cpp +* @author JXMaster +* @date 2026/8/25 +*/ +#include +#define LUNA_GAME_GUI_API LUNA_EXPORT +#include "Internal.hpp" +#include "InstanceInternal.hpp" +#include "GameGUI.meta.generated.hpp" +#include +#include + +namespace Luna +{ + namespace GameGUI + { + namespace + { + constexpr const c8* RUNTIME_ASSET_LOADER = "Luna.GameGUI.Runtime"; + + R load_asset(object_t userdata, Asset::asset_t asset, + const Name& data_unit, const Path& path) + { + lutry + { + Path document_path = path; + document_path.append_extension("cooked"); + lulet(file, VFS::open_file(document_path, FileOpenFlag::read, + FileCreationMode::open_existing)); + lulet(document, read_cooked_document(file)); + return ObjRef(document.object()); + } + lucatch + { + return luerr; + } + return E_FAILURE; + } + + R load_default_asset(object_t userdata, Asset::asset_t asset, + const Name& data_unit) + { + return ObjRef(new_object().object()); + } + + RV save_asset(object_t userdata, Asset::asset_t asset, const Name& data_unit, + const Path& path, object_t data) + { + Document* document = cast_object(data); + if (!document) + return E_BAD_ARGUMENTS; + lutry + { + luexp(validate_document(*document)); + Path document_path = path; + document_path.append_extension("cooked"); + lulet(file, VFS::open_file(document_path, FileOpenFlag::write, + FileCreationMode::create_always)); + luexp(write_cooked_document(file, *document)); + } + lucatchret; + return ok; + } + + RV set_asset_data(object_t userdata, Asset::asset_t asset, const Name& data_unit, + object_t data) + { + if (data && !cast_object(data)) + return E_BAD_ARGUMENTS; + return ok; + } + + void get_referred_assets(object_t userdata, Asset::asset_t asset, + const Name& data_unit, Vector& referred_assets) + { + auto document = Asset::get_asset_data_unit_object(asset, Name()); + if(document.valid() && document.get()) + get_direct_referred_assets(*document.get(), referred_assets); + } + + struct GameGUIModule : Module + { + virtual const c8* get_name() override { return "GameGUI"; } + + virtual RV on_register() override + { + return add_dependency_modules( + this, {module_asset(), GUI::module_gui(), module_vfs()}); + } + + virtual RV on_init() override + { + Meta::register_GameGUI_types(); + register_boxed_type(); + lutry + { + luexp(register_builtin_node_types()); + Asset::AssetLoaderDesc loader; + loader.name = RUNTIME_ASSET_LOADER; + loader.on_load_asset_data_unit = load_asset; + loader.on_load_asset_data_unit_default_data = load_default_asset; + loader.on_save_asset_data_unit = save_asset; + loader.on_set_asset_data_unit = set_asset_data; + loader.on_get_referred_assets = get_referred_assets; + Asset::register_asset_loader(loader); + Asset::AssetTypeDesc type; + type.name = get_asset_type(); + type.main_data_unit_loader = RUNTIME_ASSET_LOADER; + Asset::register_asset_type(type); + } + lucatchret; + return ok; + } + + virtual void on_close() override { close_node_registry(); } + }; + } + + LUNA_GAME_GUI_API Module* module_game_gui() + { + static GameGUIModule module; + return &module; + } + } +} diff --git a/Modules/Luna/GameGUI/Source/Instance.cpp b/Modules/Luna/GameGUI/Source/Instance.cpp new file mode 100644 index 00000000..0b8a7232 --- /dev/null +++ b/Modules/Luna/GameGUI/Source/Instance.cpp @@ -0,0 +1,501 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Instance.cpp +* @author JXMaster +* @date 2026/8/25 +*/ +#include +#define LUNA_GAME_GUI_API LUNA_EXPORT +#include "Internal.hpp" +#include "InstanceInternal.hpp" + +namespace Luna +{ + namespace GameGUI + { + namespace + { + bool contains_document(Span documents, object_t document) + { + for (object_t candidate : documents) + { + if (candidate == document) + return true; + } + return false; + } + } + + using namespace Internal; + + GUI::id_t Internal::Instance::node_identity(GUI::id_t mount_scope, const Guid& node) const + { + GUI::id_t result = GUI::make_scoped_id(mount_scope, node.high); + return GUI::make_scoped_id(result, node.low); + } + + GUI::id_t Internal::Instance::role_id(GUI::id_t identity, const c8* role) const + { + return GUI::make_scoped_id(identity, role ? role : ""); + } + + Variant& Internal::Instance::state(GUI::id_t identity, PreparedNode* node) + { + auto iter = states.find(identity); + if (iter == states.end()) + { + Variant initial_state = node->type.create_state ? node->type.create_state(node->type.userdata.get()) + : Variant(VariantType::object); + iter = states.insert(make_pair(identity, move(initial_state))).first; + } + return iter->second; + } + + void Internal::Instance::add_diagnostic(DiagnosticSeverity severity, const Guid& node, const c8* message, + Span mount_chain) + { + Diagnostic diagnostic; + diagnostic.severity = severity; + diagnostic.node = node; + diagnostic.message = message; + diagnostic.asset_mount_chain.assign(mount_chain.begin(), mount_chain.end()); + diagnostics.push_back(move(diagnostic)); + } + + R Internal::Instance::resolve_asset(Asset::asset_t asset) const + { + if (!asset) + return E_BAD_ARGUMENTS; + if (desc.resource_resolver.resolve) + { + return desc.resource_resolver.resolve(desc.resource_resolver.userdata.get(), asset); + } + auto state = Asset::get_asset_data_unit_state(asset, Name()); + if (!state.valid() || state.get() != Asset::AssetDataUnitState::loaded) + { + return set_error(E_NOT_FOUND, "GameGUI resource resolution requires already loaded asset data."); + } + auto data = Asset::get_asset_data_unit_object(asset, Name()); + if (!data.valid() || !data.get()) + return E_NOT_FOUND; + return data.get(); + } + + R Internal::Instance::prepare_document(const Ref& source, const Guid& asset_guid, + Vector& active_documents, + Vector& active_mounts) + { + object_t source_object = source.object(); + if (contains_document(Span(active_documents.data(), active_documents.size()), + source_object)) + { + Vector cycle_chain = active_mounts; + if (asset_guid != Guid()) + cycle_chain.push_back(asset_guid); + add_diagnostic(DiagnosticSeverity::error, Guid(), + "A cycle exists in the nested GameGUI asset mount chain.", + Span(cycle_chain.data(), cycle_chain.size())); + return set_error(E_LOOP, "Cycling GameGUI asset dependency detected."); + } + auto existing = prepared_by_object.find(source_object); + if (existing != prepared_by_object.end()) + { + if (asset_guid != Guid()) + prepared_by_asset.insert_or_assign(asset_guid, existing->second); + return existing->second; + } + + lutry + { + luexp(validate_document(*source, &diagnostics)); + UniquePtr owned(memnew()); + PreparedDocument* prepared_document = owned.get(); + prepared_document->source = source; + prepared_documents.push_back(move(owned)); + prepared_by_object.insert(make_pair(source_object, prepared_document)); + if (asset_guid != Guid()) + prepared_by_asset.insert_or_assign(asset_guid, prepared_document); + active_documents.push_back(source_object); + if (asset_guid != Guid()) + active_mounts.push_back(asset_guid); + + for (const NodeRecord& serialized_node : source->nodes) + { + PreparedNode prepared_node; + prepared_node.record = serialized_node; + auto type_result = get_node_type(serialized_node.type); + if (!type_result.valid()) + { + add_diagnostic(DiagnosticSeverity::error, serialized_node.id, + "The GameGUI node type provider is unavailable."); + u32 node_index = (u32)prepared_document->nodes.size(); + prepared_document->node_indices.insert(make_pair(serialized_node.id, node_index)); + prepared_document->nodes.push_back(move(prepared_node)); + continue; + } + NodeTypeDesc type = move(type_result.get()); + prepared_node.type = move(type); + if (prepared_node.type.validate) + { + luexp(prepared_node.type.validate(prepared_node.record, prepared_node.type.userdata.get())); + } + if (prepared_node.type.prepare) + { + luset(prepared_node.prepared_data, + prepared_node.type.prepare(prepared_node.record, prepared_node.type.userdata.get())); + } + prepared_node.supported = true; + u32 node_index = (u32)prepared_document->nodes.size(); + prepared_document->node_indices.insert(make_pair(prepared_node.record.id, node_index)); + prepared_document->nodes.push_back(move(prepared_node)); + } + + for (PreparedNode& node : prepared_document->nodes) + { + if (!node.supported || !node.type.nested_document || !node.type.collect_assets) + continue; + Vector nested_assets; + node.type.collect_assets(node.record, nested_assets, node.type.userdata.get()); + for (Asset::asset_t nested_asset : nested_assets) + { + lulet(resource, resolve_asset(nested_asset)); + Ref nested_document(resource); + if (!nested_document) + { + add_diagnostic(DiagnosticSeverity::error, node.record.id, + "A nested GameGUI asset does not contain a GameGUI document."); + luthrow(E_BAD_DATA); + } + Guid nested_guid = Asset::get_asset_guid(nested_asset); + lulet(unused, prepare_document(nested_document, nested_guid, active_documents, active_mounts)); + (void)unused; + } + } + active_documents.pop_back(); + if (asset_guid != Guid()) + active_mounts.pop_back(); + return prepared_document; + } + lucatch + { + if (!active_documents.empty() && active_documents.back() == source_object) + { + active_documents.pop_back(); + if (asset_guid != Guid() && !active_mounts.empty()) + active_mounts.pop_back(); + } + return luerr; + } + } + + R Internal::Instance::build_node(PreparedDocument* document, PreparedNode* node, + GUI::id_t mount_scope, const ChildLink* parent_link) + { + if (!node->supported) + return GUI::ElementHandle(); + UniquePtr owned_frame(memnew()); + FrameNodeData* node_frame_data = owned_frame.get(); + frame_data.push_back(move(owned_frame)); + GUI::id_t identity = node_identity(mount_scope, node->record.id); + GeneratedNode generated; + generated.node = node; + generated.identity = identity; + generated_nodes.push_back(generated); + GeneratedNodeInfo info; + info.node = node->record.id; + info.source_id = identity; + usize info_index = generated_node_info.size(); + generated_node_info.push_back(info); + + BuildContextData data; + data.instance = this; + data.document = document; + data.node = node; + data.parent_link = parent_link; + data.mount_scope = mount_scope; + data.identity = identity; + data.frame_data = node_frame_data; + BuildContext context(&data); + auto result = node->type.build(context, node->record, node->type.userdata.get()); + if (result.valid()) + generated_node_info[info_index].root_element_id = result.get().id; + return result; + } + + RV Internal::Instance::build_children(BuildContextData& context) + { + lutry + { + for (const ChildLink& link : context.node->record.children) + { + auto index = context.document->node_indices.find(link.child); + if (index == context.document->node_indices.end()) + { + luthrow(E_BAD_DATA); + } + lulet(unused, build_node(context.document, &context.document->nodes[index->second], + context.mount_scope, &link)); + (void)unused; + } + } + lucatchret; + return ok; + } + + R Internal::Instance::build_nested(BuildContextData& context, Asset::asset_t asset) + { + Guid asset_guid = Asset::get_asset_guid(asset); + auto nested = prepared_by_asset.find(asset_guid); + if (nested == prepared_by_asset.end()) + { + return set_error(E_BAD_CALLING_TIME, + "Nested GameGUI asset was not resolved during instance preparation."); + } + PreparedDocument* document = nested->second; + auto root = document->node_indices.find(document->source->root); + if (root == document->node_indices.end()) + return E_BAD_DATA; + GUI::id_t nested_mount_scope = GUI::make_scoped_id(context.identity, asset_guid.high); + nested_mount_scope = GUI::make_scoped_id(nested_mount_scope, asset_guid.low); + return build_node(document, &document->nodes[root->second], nested_mount_scope, nullptr); + } + + LUNA_GAME_GUI_API GUI::IContext* BuildContext::gui() const + { + return ((BuildContextData*)m_impl)->instance->gui; + } + + LUNA_GAME_GUI_API GUI::id_t BuildContext::make_id(const c8* role) const + { + BuildContextData* data = (BuildContextData*)m_impl; + return data->instance->role_id(data->identity, role); + } + + LUNA_GAME_GUI_API GUI::id_t BuildContext::make_child_id(const Guid& child, const c8* role) const + { + BuildContextData* data = (BuildContextData*)m_impl; + GUI::id_t identity = data->instance->node_identity(data->mount_scope, child); + return data->instance->role_id(identity, role); + } + + LUNA_GAME_GUI_API Variant& BuildContext::state() + { + BuildContextData* data = (BuildContextData*)m_impl; + return data->instance->state(data->identity, data->node); + } + + LUNA_GAME_GUI_API const Any& BuildContext::prepared_data() const + { + return ((BuildContextData*)m_impl)->node->prepared_data; + } + + LUNA_GAME_GUI_API RV BuildContext::build_children() + { + BuildContextData* data = (BuildContextData*)m_impl; + return data->instance->build_children(*data); + } + + LUNA_GAME_GUI_API const ChildLink* BuildContext::parent_link() const + { + return ((BuildContextData*)m_impl)->parent_link; + } + + LUNA_GAME_GUI_API R BuildContext::build_nested(Asset::asset_t asset) + { + BuildContextData* data = (BuildContextData*)m_impl; + return data->instance->build_nested(*data, asset); + } + + LUNA_GAME_GUI_API R BuildContext::resolve_resource(Asset::asset_t asset) const + { + return ((BuildContextData*)m_impl)->instance->resolve_asset(asset); + } + + LUNA_GAME_GUI_API void BuildContext::set_flex_layout(const GUI::ElementHandle& element, + const GUI::FlexLayoutDesc& desc) + { + BuildContextData* data = (BuildContextData*)m_impl; + data->frame_data->flex = desc; + GUI::LayoutCallbackConfig config; + config.algorithm = "GameGUI.Flex"; + config.measure_callback = GUI::measure_flex; + config.callback = GUI::layout_flex; + config.userdata = &data->frame_data->flex; + gui()->set_layout_callback_config(element, config); + } + + LUNA_GAME_GUI_API void BuildContext::set_canvas_layout(const GUI::ElementHandle& element, + Span items, + bool clip_children) + { + BuildContextData* data = (BuildContextData*)m_impl; + data->frame_data->canvas_items.assign(items.begin(), items.end()); + data->frame_data->canvas.items = Span(data->frame_data->canvas_items.data(), + data->frame_data->canvas_items.size()); + data->frame_data->canvas.clip_children = clip_children; + GUI::LayoutCallbackConfig config; + config.algorithm = "GameGUI.Canvas"; + config.callback = GUI::layout_canvas; + config.userdata = &data->frame_data->canvas; + gui()->set_layout_callback_config(element, config); + } + + LUNA_GAME_GUI_API GUI::IContext* ResolveContext::gui() const + { + return ((ResolveContextData*)m_impl)->instance->gui; + } + + LUNA_GAME_GUI_API GUI::id_t ResolveContext::make_id(const c8* role) const + { + ResolveContextData* data = (ResolveContextData*)m_impl; + return data->instance->role_id(data->identity, role); + } + + LUNA_GAME_GUI_API Variant& ResolveContext::state() + { + ResolveContextData* data = (ResolveContextData*)m_impl; + return data->instance->state(data->identity, data->node); + } + + LUNA_GAME_GUI_API const Any& ResolveContext::prepared_data() const + { + return ((ResolveContextData*)m_impl)->node->prepared_data; + } + + LUNA_GAME_GUI_API void ResolveContext::emit_action(const Name& name, const Variant& payload) + { + ResolveContextData* data = (ResolveContextData*)m_impl; + Action action; + action.name = name; + action.node = data->node->record.id; + action.source_id = data->identity; + action.payload = payload; + data->instance->actions.push_back(move(action)); + } + + LUNA_GAME_GUI_API void ResolveContext::request_relayout() + { + ((ResolveContextData*)m_impl)->instance->relayout = true; + } + + RV Internal::Instance::prepare() + { + if (!desc.document) + return E_BAD_ARGUMENTS; + prepared = false; + prepared_documents.clear(); + prepared_by_object.clear(); + prepared_by_asset.clear(); + states.clear(); + frame_data.clear(); + generated_nodes.clear(); + generated_node_info.clear(); + root_document = nullptr; + build_generation = 0; + diagnostics.clear(); + Vector active_documents; + Vector active_mounts; + Guid root_asset_guid = desc.source_asset ? Asset::get_asset_guid(desc.source_asset) : Guid(); + auto root = prepare_document(desc.document, root_asset_guid, active_documents, active_mounts); + if (!root.valid()) + return root.errcode(); + root_document = root.get(); + prepared = true; + return ok; + } + + R Internal::Instance::build(GUI::IContext* context) + { + if (!context) + return E_BAD_ARGUMENTS; + if (context->generation() == 0) + { + return set_error(E_BAD_CALLING_TIME, "GameGUI instance build requires a begun GUI context frame."); + } + if (context == gui && context->generation() == build_generation) + { + return set_error(E_BAD_CALLING_TIME, + "A GameGUI instance can only be built once per GUI context frame."); + } + if (!prepared) + { + RV result = prepare(); + if (!result.valid()) + return result.errcode(); + } + gui = context; + build_generation = context->generation(); + frame_data.clear(); + generated_nodes.clear(); + generated_node_info.clear(); + actions.clear(); + relayout = false; + PreparedDocument* document = root_document; + auto root = document->node_indices.find(document->source->root); + if (root == document->node_indices.end()) + return E_BAD_DATA; + return build_node(document, &document->nodes[root->second], desc.instance_scope, nullptr); + } + + RV Internal::Instance::resolve_interactions(GUI::IContext* context) + { + if (!context || context != gui) + return E_BAD_ARGUMENTS; + if (context->generation() != build_generation) + return E_BAD_CALLING_TIME; + actions.clear(); + relayout = false; + lutry + { + for (const GeneratedNode& generated : generated_nodes) + { + if (!generated.node->type.resolve) + continue; + ResolveContextData data; + data.instance = this; + data.node = generated.node; + data.identity = generated.identity; + ResolveContext resolve_context(&data); + luexp(generated.node->type.resolve(resolve_context, generated.node->record, + generated.node->type.userdata.get())); + } + } + lucatchret; + return ok; + } + + Span Internal::Instance::get_actions() const + { + return Span(actions.data(), actions.size()); + } + + void Internal::Instance::clear_actions() { actions.clear(); } + + bool Internal::Instance::relayout_requested() const { return relayout; } + + Span Internal::Instance::get_diagnostics() const + { + return Span(diagnostics.data(), diagnostics.size()); + } + + Span Internal::Instance::get_generated_nodes() const + { + return Span(generated_node_info.data(), generated_node_info.size()); + } + + GUI::id_t Internal::Instance::make_stable_id(const Guid& node, const c8* role) const + { + return role_id(node_identity(desc.instance_scope, node), role); + } + + LUNA_GAME_GUI_API Ref new_instance(const InstanceDesc& desc) + { + Ref instance = new_object(); + instance->desc = desc; + return instance; + } + } +} diff --git a/Modules/Luna/GameGUI/Source/InstanceInternal.hpp b/Modules/Luna/GameGUI/Source/InstanceInternal.hpp new file mode 100644 index 00000000..328242ea --- /dev/null +++ b/Modules/Luna/GameGUI/Source/InstanceInternal.hpp @@ -0,0 +1,113 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file InstanceInternal.hpp +* @author JXMaster +* @date 2026/8/25 +*/ +#pragma once +#include "../Instance.hpp" +#include +#include +#include "InstanceInternal.generated.hpp" + +namespace Luna +{ + namespace GameGUI + { + namespace Internal + { + struct PreparedNode + { + NodeRecord record; + NodeTypeDesc type; + Any prepared_data; + bool supported = false; + }; + + struct PreparedDocument + { + Ref source; + Vector nodes; + HashMap node_indices; + }; + + struct FrameNodeData + { + GUI::FlexLayoutDesc flex; + Vector canvas_items; + GUI::CanvasLayoutDesc canvas; + }; + + struct GeneratedNode + { + PreparedNode* node = nullptr; + GUI::id_t identity = 0; + }; + + struct BuildContextData + { + Instance* instance = nullptr; + PreparedDocument* document = nullptr; + PreparedNode* node = nullptr; + const ChildLink* parent_link = nullptr; + GUI::id_t mount_scope = 0; + GUI::id_t identity = 0; + FrameNodeData* frame_data = nullptr; + }; + + struct ResolveContextData + { + Instance* instance = nullptr; + PreparedNode* node = nullptr; + GUI::id_t identity = 0; + }; + + struct [[Luna::struct("{3663D89A-F8A5-4266-931C-DC5C0E47C12C}")]] Instance : IInstance + { + luiimpl(); + + InstanceDesc desc; + GUI::IContext* gui = nullptr; + Vector> prepared_documents; + HashMap prepared_by_object; + HashMap prepared_by_asset; + PreparedDocument* root_document = nullptr; + HashMap states; + Vector> frame_data; + Vector generated_nodes; + Vector generated_node_info; + Vector actions; + Vector diagnostics; + u32 build_generation = 0; + bool prepared = false; + bool relayout = false; + + GUI::id_t node_identity(GUI::id_t mount_scope, const Guid& node) const; + GUI::id_t role_id(GUI::id_t identity, const c8* role) const; + Variant& state(GUI::id_t identity, PreparedNode* node); + void add_diagnostic(DiagnosticSeverity severity, const Guid& node, const c8* message, + Span mount_chain = Span()); + R resolve_asset(Asset::asset_t asset) const; + R prepare_document(const Ref& source, const Guid& asset_guid, + Vector& active_documents, Vector& active_mounts); + R build_node(PreparedDocument* document, PreparedNode* node, GUI::id_t mount_scope, + const ChildLink* parent_link); + RV build_children(BuildContextData& context); + R build_nested(BuildContextData& context, Asset::asset_t asset); + + virtual RV prepare() override; + virtual R build(GUI::IContext* context) override; + virtual RV resolve_interactions(GUI::IContext* context) override; + virtual Span get_actions() const override; + virtual void clear_actions() override; + virtual bool relayout_requested() const override; + virtual Span get_diagnostics() const override; + virtual Span get_generated_nodes() const override; + virtual GUI::id_t make_stable_id(const Guid& node, const c8* role) const override; + }; + } + } +} diff --git a/Modules/Luna/GameGUI/Source/Internal.hpp b/Modules/Luna/GameGUI/Source/Internal.hpp new file mode 100644 index 00000000..ae238ebd --- /dev/null +++ b/Modules/Luna/GameGUI/Source/Internal.hpp @@ -0,0 +1,25 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Internal.hpp +* @author JXMaster +* @date 2026/8/25 +*/ +#pragma once +#include "../GameGUI.hpp" +#include +#include + +namespace Luna +{ + namespace GameGUI + { + void get_direct_referred_assets(const Document& document, + Vector& assets); + R> read_cooked_document(IStream* stream); + RV write_cooked_document(IStream* stream, const Document& document); + void close_node_registry(); + } +} diff --git a/Modules/Luna/GameGUI/Source/Node.cpp b/Modules/Luna/GameGUI/Source/Node.cpp new file mode 100644 index 00000000..96a57a62 --- /dev/null +++ b/Modules/Luna/GameGUI/Source/Node.cpp @@ -0,0 +1,72 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Node.cpp +* @author JXMaster +* @date 2026/8/25 +*/ +#include +#define LUNA_GAME_GUI_API LUNA_EXPORT +#include "Internal.hpp" +#include +#include + +namespace Luna +{ + namespace GameGUI + { + namespace + { + SpinLock g_node_registry_lock; + HashMap g_node_types; + } + + LUNA_GAME_GUI_API RV register_node_type(const NodeTypeDesc& desc) + { + if(desc.type == Guid() || desc.name.empty() || !desc.build) + { + return set_error(E_BAD_ARGUMENTS, + "GameGUI node descriptors require a non-zero type, name and build callback."); + } + LockGuard guard(g_node_registry_lock); + if(g_node_types.find(desc.type) != g_node_types.end()) return E_ALREADY_EXISTS; + for(const auto& registered : g_node_types) + { + if(registered.second.name == desc.name) return E_ALREADY_EXISTS; + } + g_node_types.insert(make_pair(desc.type, desc)); + return ok; + } + + LUNA_GAME_GUI_API RV unregister_node_type(const Guid& type) + { + LockGuard guard(g_node_registry_lock); + auto iter = g_node_types.find(type); + if(iter == g_node_types.end()) return E_NOT_FOUND; + g_node_types.erase(iter); + return ok; + } + + LUNA_GAME_GUI_API R get_node_type(const Guid& type) + { + LockGuard guard(g_node_registry_lock); + auto iter = g_node_types.find(type); + if(iter == g_node_types.end()) return E_NOT_FOUND; + return iter->second; + } + + LUNA_GAME_GUI_API void get_node_types(Vector& descriptors) + { + LockGuard guard(g_node_registry_lock); + for(const auto& desc : g_node_types) descriptors.push_back(desc.second); + } + + void close_node_registry() + { + LockGuard guard(g_node_registry_lock); + g_node_types.clear(); + } + } +} diff --git a/Modules/Luna/Pak/Pak.Target.cs b/Modules/Luna/Pak/Pak.Target.cs new file mode 100644 index 00000000..2b32250e --- /dev/null +++ b/Modules/Luna/Pak/Pak.Target.cs @@ -0,0 +1,12 @@ +namespace LunaBuild.Core.Targets; + +public sealed class PakTargetRules : TargetRules +{ + public PakTargetRules() : base("Pak", "Modules/Luna/Pak", "Modules/Luna/Pak/Pak.Target.cs") + { + Headers("*.hpp", "Source/*.hpp"); + MetaHeaders("Pak.hpp", "Source/Package.hpp"); + Sources("Source/*.cpp"); + DependsOn("Runtime", "Zip"); + } +} diff --git a/Modules/Luna/Pak/Pak.hpp b/Modules/Luna/Pak/Pak.hpp new file mode 100644 index 00000000..1f9946c8 --- /dev/null +++ b/Modules/Luna/Pak/Pak.hpp @@ -0,0 +1,160 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Pak.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include +#include "Pak.generated.hpp" + +#ifndef LUNA_PAK_API +#define LUNA_PAK_API +#endif + +namespace Luna +{ + namespace Pak + { + //! @addtogroup Pak Pak + //! Directory trees stored in single ZIP/ZIP64 streams, independent of VFS and Asset. + //! @{ + + //! Selects package access. Editable packages still read their input without writing it. + enum class OpenMode : u8 + { + //! Allows file reads and metadata queries. + read, + //! Also allows staged changes and flushing to a separate stream. + read_write + }; + + //! Supported ZIP compression methods. + enum class CompressionMethod : u16 + { + //! Stores file data without compression. + store = 0, + //! Compresses file data with Deflate. + deflate = 8 + }; + + //! Session options for newly created files and writable staging storage. + struct Options + { + //! The default compression for new files. Existing files keep their method. + CompressionMethod compression = CompressionMethod::store; + //! Deflate level 1-9 or default 0. Store requires 0. + u32 compression_level = 0; + //! Maximum size of one built-in memory staging stream, in uncompressed bytes. + //! Does not limit read-only streaming or custom staging streams. + u64 max_memory_file_size = 256 * 1024 * 1024; + //! Optional retained userdata passed to @ref create_staging_stream. + ObjRef staging_userdata; + //! Optional factory for private, readable, writable, seekable, resizable staging streams. + //! @details Pak resets each returned stream to zero length. It must not alias the + //! package source or another staging stream, even through separate handles. Pak + //! exclusively owns its contents/cursor while retained. The callback runs under + //! the package mutex and must not reenter the package. The object's lifetime + //! should clean up temporary storage when needed. Null uses memory staging. + R> (*create_staging_stream)(object_t userdata) = nullptr; + }; + + //! A filesystem-like directory tree with staged edits and repeatable flush. + //! @details Paths are case-sensitive UTF-8 with '/' separators. Empty and '/' name + //! the root; an optional leading slash and directory trailing slash are accepted. + //! Other empty, '.' and '..' components, backslashes and colons are rejected. + //! Package and file-handle operations are internally serialized. Separately opened + //! packages or external users sharing a stream require additional synchronization. + //! Destruction discards changes; file handles retain the package. + struct [[Luna::interface("{B9DD7BC7-88AF-401C-8721-1682BB870599}")]] IPak : virtual Interface + { + //! Tests whether the package has not been explicitly discarded. + virtual bool is_open() = 0; + //! Tests whether this package permits edits. + virtual bool is_read_only() = 0; + //! Tests whether changes or a newly created empty package await flush. + //! A successful flush clears this flag; external publication is caller-owned. + virtual bool is_dirty() = 0; + //! Opens a file with Runtime access flags and creation modes. + //! @param[in] path A file path. Its parent must exist. A trailing slash requires a directory. + //! @param[in] flags Read and/or write access. user_buffering is accepted as a hint. + //! @param[in] creation The file creation/truncation policy. Creating/truncating requires write access. + //! @details A file permits multiple readers or one exclusive writer; conflicting + //! opens return E_BUSY. Writable opening copies existing data into staging unless + //! truncating. Read-only seeking replays ZIP decoding as needed; it is not constant-time. + //! Growth zero-fills gaps. IFile::flush only flushes staging, not the package. + virtual R> open_file(const c8* path, FileOpenFlag flags, FileCreationMode creation) = 0; + //! Queries a file or directory. Timestamp fields are zero. Read-only packages + //! report FileAttributeFlag::read_only on all nodes. + //! @param[in] path The file or directory path. + virtual R get_file_attribute(const c8* path) = 0; + //! Returns a sorted snapshot of one directory's direct children, excluding '.' and '..'. + //! @param[in] path The directory path. Snapshots do not block editing or flush. + virtual R> open_dir(const c8* path) = 0; + //! Creates one directory. Its parent must exist; existing nodes return E_ALREADY_EXISTS. + //! @param[in] path The non-root directory path. + virtual RV create_dir(const c8* path) = 0; + //! Copies one file's contents and compression to a new path with existing parent. + //! @param[in] from_path The source file. An active writer causes E_BUSY. + //! @param[in] to_path The new file path. Existing nodes cause E_ALREADY_EXISTS. + virtual RV copy_file(const c8* from_path, const c8* to_path) = 0; + //! Moves a file or directory subtree, with optional replacement of an existing file. + //! @param[in] from_path The non-root source path. + //! @param[in] to_path The new path with an existing parent. + //! @details Open files in the affected subtree cause E_BUSY. A directory cannot + //! be moved into itself. Validation failure leaves the tree unchanged. + //! @param[in] flags Allows file replacement with allow_overwrite. no_copy is always satisfied + //! within one package. Replacing directories or entries with open handles is rejected. + virtual RV move_file(const c8* from_path, const c8* to_path, FileMoveFlag flags = FileMoveFlag::none) = 0; + //! Deletes one file or empty directory. The root cannot be deleted. + //! @param[in] path The path to delete. An open file causes E_BUSY. + virtual RV delete_file(const c8* path) = 0; + //! Gets the selected compression of a file. + //! @param[in] path The file path. + virtual R get_file_compression(const c8* path) = 0; + //! Changes a closed file's compression at the next flush. + //! @param[in] path The file path. Open files cause E_BUSY. + //! @param[in] method Store or Deflate. + //! @param[in] level Deflate level 1-9 or default 0; Store requires 0. + virtual RV set_file_compression(const c8* path, CompressionMethod method, u32 level = 0) = 0; + //! Writes a complete package and adopts the output for subsequent operations. + //! @param[in] destination A distinct readable, writable, seekable, resizable stream. + //! @details All file handles must be released, otherwise E_BUSY is returned. + //! The output must not alias the current source or retained staging, even through + //! other handles. Identical pointers are rejected. Output contents are replaced. + //! Success leaves the package open, releases previous sources/staging, and clears + //! dirty state. Failure preserves pending changes and old input for retry, but + //! output can be partial. Even unchanged packages write a complete output. + //! Read-only packages return E_ACCESS_DENIED. No filesystem publication or durable + //! synchronization is implied; the adopted output must remain unchanged while retained. + virtual RV flush(ISeekableStream* destination) = 0; + //! Closes the package without saving. Open files cause E_BUSY. Already closed + //! packages succeed. Destruction also discards pending changes without writes. + virtual RV discard() = 0; + }; + + //! Opens a package and builds its directory index without decoding file payloads. + //! @param[in] source Retained readable/seekable input, interpreted from offset zero. + //! Its contents and size must remain unchanged while retained; Pak may move its cursor. + //! @param[in] mode Package access. read_write does not require writable input. + //! @param[in] options Compression defaults for new files and staging configuration. + //! @details Invalid paths, duplicate names, file/directory collisions and non-empty + //! directory entries return E_BAD_DATA. Encrypted/unsupported codecs return E_NOT_SUPPORTED. + //! Missing parent directory entries are synthesized. ZIP link/permission attributes + //! are not interpreted. ZIP/ZIP64 sizes are limited to signed 64-bit stream offsets. + LUNA_PAK_API R> open_pak(ISeekableStream* source, + OpenMode mode = OpenMode::read, const Options& options = Options()); + //! Creates an empty editable package without writing any stream. + //! @param[in] options Compression defaults and staging configuration. + LUNA_PAK_API R> new_pak(const Options& options = Options()); + //! @} + } + + struct Module; + //! Gets the Pak module, which registers Zip as a dependency. Initialize before use + //! and release all package/file/iterator objects before Runtime shutdown. + LUNA_PAK_API Module* module_pak(); +} diff --git a/Modules/Luna/Pak/Source/File.cpp b/Modules/Luna/Pak/Source/File.cpp new file mode 100644 index 00000000..8db49fa4 --- /dev/null +++ b/Modules/Luna/Pak/Source/File.cpp @@ -0,0 +1,282 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file File.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include "Package.hpp" + +namespace Luna::Pak +{ + R seek_position(u64 position, u64 size, i64 offset, SeekMode mode) + { + u64 base; + switch(mode) + { + case SeekMode::begin: base = 0; break; + case SeekMode::current: base = position; break; + case SeekMode::end: base = size; break; + default: return E_BAD_ARGUMENTS; + } + if(base > I64_MAX || offset < -(i64)base || (offset > 0 && base > (u64)(I64_MAX - offset))) return E_OUT_OF_RANGE; + return (u64)((i64)base + offset); + } + + RV write_all(IStream* stream, const void* buffer, usize size, usize* written) + { + usize done = 0; + if(written) *written = 0; + while(done < size) + { + usize count = 0; + auto result = stream->write((const byte_t*)buffer + done, size - done, &count); + if(count > size - done) return E_IO_ERROR; + done += count; + if(written) *written = done; + if(failed(result)) return result; + if(!count) return E_IO_ERROR; + } + return ok; + } + + RV MemoryStream::read(void* buffer, usize size, usize* read_bytes) + { + if(read_bytes) *read_bytes = 0; + if(size && !buffer) return E_BAD_ARGUMENTS; + usize amount = m_position >= m_bytes.size() ? 0 : (usize)min(size, m_bytes.size() - m_position); + if(amount) memcpy(buffer, m_bytes.data() + (usize)m_position, amount); + m_position += amount; + if(read_bytes) *read_bytes = amount; + return ok; + } + + RV MemoryStream::write(const void* buffer, usize size, usize* write_bytes) + { + if(write_bytes) *write_bytes = 0; + if(size && !buffer) return E_BAD_ARGUMENTS; + if(!size) return ok; + if(size > (u64)I64_MAX - m_position) return E_OUT_OF_RANGE; + if(m_position + size > m_bytes.size()) + { + auto result = set_size(m_position + size); + if(failed(result)) return result; + } + memcpy(m_bytes.data() + (usize)m_position, buffer, size); + m_position += size; + if(write_bytes) *write_bytes = size; + return ok; + } + + RV MemoryStream::seek(i64 offset, SeekMode mode) + { + auto result = seek_position(m_position, m_bytes.size(), offset, mode); + if(failed(result)) return result.errcode(); + m_position = result.get(); + return ok; + } + + RV MemoryStream::set_size(u64 size) + { + if(size > m_limit || size > I64_MAX || size > USIZE_MAX) return E_OUT_OF_RANGE; + usize old_size = m_bytes.size(); + if(size > m_bytes.capacity()) + { + u64 capacity = min(m_limit, max(size, (u64)m_bytes.capacity() * 2)); + m_bytes.reserve((usize)capacity); + } + m_bytes.resize((usize)size); + if(size > old_size) memzero(m_bytes.data() + old_size, (usize)size - old_size); + return ok; + } + + PakFile::~PakFile() + { + if(!m_owner || !m_node) return; + MutexGuard guard(m_owner->m_mutex); + m_reader = nullptr; + if(test_flags(m_flags, FileOpenFlag::write)) m_node->writer = false; + else --m_node->readers; + --m_owner->m_handles; + } + + RV PakFile::prepare_reader() + { + if(!m_reader || m_decode_position > m_position) + { + auto opened = m_owner->m_archive->open_entry(m_node->index); + if(failed(opened)) return opened.errcode(); + m_reader = move(opened.get()); + m_decode_position = 0; + } + byte_t buffer[65536]; + while(m_decode_position < m_position) + { + usize count = 0; + usize amount = (usize)min(sizeof(buffer), m_position - m_decode_position); + auto result = m_reader->read(buffer, amount, &count); + m_decode_position += count; + if(failed(result) || !count || count > amount) + { + m_reader = nullptr; + return failed(result) ? result : RV(E_BAD_DATA); + } + } + return ok; + } + + RV PakFile::read(void* buffer, usize size, usize* read_bytes) + { + MutexGuard guard(m_owner->m_mutex); + if(read_bytes) *read_bytes = 0; + if(!test_flags(m_flags, FileOpenFlag::read)) return E_ACCESS_DENIED; + if(size && !buffer) return E_BAD_ARGUMENTS; + if(!size) return ok; + lutry + { + if(m_position < m_node->size) + { + Ref input; + if(m_node->data) + { + luexp(m_node->data->seek((i64)m_position, SeekMode::begin)); + input = m_node->data; + } + else + { + luexp(prepare_reader()); + input = m_reader; + } + usize remaining = (usize)min(size, m_node->size - m_position); + usize done = 0; + while(remaining) + { + usize count = 0; + auto result = input->read((byte_t*)buffer + done, remaining, &count); + if(count > remaining) return E_IO_ERROR; + done += count; + remaining -= count; + m_position += count; + if(!m_node->data) m_decode_position += count; + if(read_bytes) *read_bytes = done; + if(failed(result)) + { + m_reader = nullptr; + return result; + } + if(!count) return E_BAD_DATA; + } + } + // A sequential read reaching EOF validates CRC now, even if the caller + // requested exactly the file length. Seeking directly past data does not. + if(!m_node->data && m_position == m_node->size && + (!m_node->size || (m_reader && m_decode_position == m_node->size))) + { + if(!m_reader) luexp(prepare_reader()); + byte_t extra; + usize count = 0; + auto result = m_reader->read(&extra, 1, &count); + if(failed(result)) + { + m_reader = nullptr; + return result; + } + if(count) return E_BAD_DATA; + } + } + lucatchret; + return ok; + } + + RV PakFile::resize(u64 size) + { + if(size > I64_MAX || (!m_owner->m_options.create_staging_stream && + (size > m_owner->m_options.max_memory_file_size || size > USIZE_MAX))) return E_OUT_OF_RANGE; + if(size == m_node->size) return ok; + if(size < m_node->size || !m_owner->m_options.create_staging_stream) + { + auto result = m_node->data->set_size(size); + u64 new_size = m_node->data->get_size(); + if(new_size != m_node->size) + { + m_owner->m_dirty = true; + m_node->data_changed = true; + m_node->size = new_size; + } + return result; + } + auto seek = m_node->data->seek((i64)m_node->size, SeekMode::begin); + if(failed(seek)) return seek; + byte_t zeros[65536]{}; + m_owner->m_dirty = true; + m_node->data_changed = true; + while(m_node->size < size) + { + usize amount = (usize)min(sizeof(zeros), size - m_node->size); + u64 previous_size = m_node->size; + auto result = write_all(m_node->data, zeros, amount); + m_node->size = m_node->data->get_size(); + if(failed(result)) return result; + if(m_node->size != previous_size + amount) return E_IO_ERROR; + } + return ok; + } + + RV PakFile::write(const void* buffer, usize size, usize* write_bytes) + { + MutexGuard guard(m_owner->m_mutex); + if(write_bytes) *write_bytes = 0; + if(!test_flags(m_flags, FileOpenFlag::write)) return E_ACCESS_DENIED; + if(size && !buffer) return E_BAD_ARGUMENTS; + if(!size) return ok; + if(size > (u64)I64_MAX - m_position) return E_OUT_OF_RANGE; + if(!m_owner->m_options.create_staging_stream && + m_position + size > min(m_owner->m_options.max_memory_file_size, USIZE_MAX)) return E_OUT_OF_RANGE; + lutry + { + if(m_position > m_node->size) luexp(resize(m_position)); + luexp(m_node->data->seek((i64)m_position, SeekMode::begin)); + m_owner->m_dirty = true; + m_node->data_changed = true; + usize count = 0; + auto result = write_all(m_node->data, buffer, size, &count); + m_position += count; + m_node->size = m_node->data->get_size(); + if(write_bytes) *write_bytes = count; + return result; + } + lucatchret; + return ok; + } + + R PakFile::tell() { MutexGuard guard(m_owner->m_mutex); return m_position; } + u64 PakFile::get_size() { MutexGuard guard(m_owner->m_mutex); return m_node->size; } + + RV PakFile::seek(i64 offset, SeekMode mode) + { + MutexGuard guard(m_owner->m_mutex); + auto result = seek_position(m_position, m_node->size, offset, mode); + if(failed(result)) return result.errcode(); + m_position = result.get(); + return ok; + } + + RV PakFile::set_size(u64 size) + { + MutexGuard guard(m_owner->m_mutex); + if(!test_flags(m_flags, FileOpenFlag::write)) return E_ACCESS_DENIED; + return resize(size); + } + + void PakFile::flush() + { + MutexGuard guard(m_owner->m_mutex); + if(m_node->data) + { + auto file = m_node->data.as(); + if(file) file->flush(); + } + } +} diff --git a/Modules/Luna/Pak/Source/Package.cpp b/Modules/Luna/Pak/Source/Package.cpp new file mode 100644 index 00000000..cc438788 --- /dev/null +++ b/Modules/Luna/Pak/Source/Package.cpp @@ -0,0 +1,663 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Package.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include +#define LUNA_PAK_API LUNA_EXPORT +#include "Package.hpp" +#include +#include +#include +#include + +namespace Luna::Pak +{ + struct ParsedPath + { + String name; + bool directory = false; + }; + + static R parse_path(const c8* path) + { + if(!path) return E_BAD_ARGUMENTS; + usize size = strlen(path); + ParsedPath result; + if(!size || (size == 1 && path[0] == '/')) + { + result.directory = true; + return result; + } + usize begin = path[0] == '/' ? 1 : 0; + result.directory = path[size - 1] == '/'; + usize end = size - (result.directory ? 1 : 0); + if(end <= begin || end - begin + (result.directory ? 1 : 0) > 65535) return E_BAD_ARGUMENTS; + usize component = begin; + for(usize i = begin; i <= end;) + { + if(i == end || path[i] == '/') + { + usize length = i - component; + if(!length || (length == 1 && path[component] == '.') || + (length == 2 && path[component] == '.' && path[component + 1] == '.')) return E_BAD_ARGUMENTS; + component = ++i; + } + else + { + if(path[i] == '\\' || path[i] == ':') return E_BAD_ARGUMENTS; + usize consumed = 0; + auto decoded = utf8_decode_char(path + i, end - i, &consumed); + if(failed(decoded)) return E_BAD_ARGUMENTS; + i += consumed; + } + } + result.name.assign(path + begin, end - begin); + return result; + } + + static String parent_path(const String& path) + { + for(usize i = path.size(); i; --i) + { + if(path[i - 1] == '/') return String(path.data(), i - 1); + } + return String(); + } + + static bool is_descendant(const String& path, const String& parent) + { + return path.size() > parent.size() && path[parent.size()] == '/' && + !memcmp(path.data(), parent.data(), parent.size()); + } + + static RV validate_compression(CompressionMethod method, u32 level) + { + if(method != CompressionMethod::store && method != CompressionMethod::deflate) return E_NOT_SUPPORTED; + if(level > 9 || (method == CompressionMethod::store && level)) return E_BAD_ARGUMENTS; + return ok; + } + + RV Package::init(ISeekableStream* source, OpenMode mode, const Options& options) + { + if(mode != OpenMode::read && mode != OpenMode::read_write) return E_BAD_ARGUMENTS; + lutry + { + luexp(validate_compression(options.compression, options.compression_level)); + m_mutex = new_mutex(); + m_options = options; + m_writable = mode == OpenMode::read_write; + m_source = source; + if(source) luset(m_archive, Zip::open_archive(source)); + luexp(load_index()); + m_dirty = !source; + } + lucatchret; + return ok; + } + + RV Package::load_index() + { + UniquePtr root(memnew()); + root->directory = true; + m_nodes.insert(make_pair(String(), move(root))); + if(!m_archive) return ok; + lutry + { + lulet(entries, m_archive->get_entries()); + for(const auto& entry : entries) + { + auto parsed = parse_path(entry.name.c_str()); + if(failed(parsed) || entry.name.empty() || entry.name[0] == '/' || + strlen(entry.name.c_str()) != entry.name.size()) + { + return set_error(E_BAD_DATA, "Invalid Pak entry path: %s.", entry.name.c_str()); + } + auto path = move(parsed.get().name); + if(entry.encrypted || (entry.compression != Zip::CompressionMethod::store && + entry.compression != Zip::CompressionMethod::deflate)) return E_NOT_SUPPORTED; + if(entry.size > I64_MAX) return E_FILE_TOO_BIG; + if(entry.directory && entry.size) return set_error(E_BAD_DATA, "Pak directory contains file data: %s.", entry.name.c_str()); + // Build implicit parents before the explicit node. An explicit directory + // may appear before or after its descendants in the central directory. + for(usize i = 0; i < path.size(); ++i) + { + if(path[i] != '/') continue; + String parent(path.data(), i); + auto found = m_nodes.find(parent); + if(found != m_nodes.end()) + { + if(!found->second->directory) return set_error(E_BAD_DATA, "Pak file is also a parent directory: %s.", parent.c_str()); + } + else + { + UniquePtr node(memnew()); + node->directory = true; + m_nodes.insert(make_pair(move(parent), move(node))); + } + } + auto found = m_nodes.find(path); + if(found == m_nodes.end()) + { + UniquePtr node(memnew()); + node->directory = entry.directory; + found = m_nodes.insert(make_pair(path, move(node))).first; + } + else if(!entry.directory || !found->second->directory || found->second->index != U64_MAX) + { + return set_error(E_BAD_DATA, "Duplicate or conflicting Pak entry: %s.", entry.name.c_str()); + } + auto node = found->second.get(); + node->index = entry.index; + node->original_name = entry.name; + node->size = entry.size; + node->compression = (CompressionMethod)entry.compression; + } + } + lucatchret; + return ok; + } + + RV Package::check_open(bool writing) + { + if(!m_open) return E_BAD_CALLING_TIME; + if(writing && !m_writable) return E_ACCESS_DENIED; + return ok; + } + + R Package::find_node(const String& path, bool require_directory) + { + auto found = m_nodes.find(path); + if(found != m_nodes.end()) + { + if(require_directory && !found->second->directory) return E_NOT_DIRECTORY; + return found->second.get(); + } + for(usize i = 0; i < path.size(); ++i) + { + if(path[i] != '/') continue; + auto parent = m_nodes.find(String(path.data(), i)); + if(parent != m_nodes.end() && !parent->second->directory) return E_NOT_DIRECTORY; + } + return E_NOT_FOUND; + } + + RV Package::check_parent(const String& path) + { + auto found = find_node(parent_path(path), true); + if(failed(found)) return found.errcode(); + return ok; + } + + R> Package::make_staging(u64 size) + { + Ref stream; + lutry + { + if(m_options.create_staging_stream) + { + luset(stream, m_options.create_staging_stream(m_options.staging_userdata ? m_options.staging_userdata.get() : nullptr)); + if(!stream || stream == m_source) return E_BAD_ARGUMENTS; + for(auto& item : m_nodes) + { + if(item.second->data == stream) return E_BAD_ARGUMENTS; + } + } + else + { + if(size > m_options.max_memory_file_size || size > USIZE_MAX) return E_OUT_OF_RANGE; + auto memory = new_object(); + memory->m_limit = min(m_options.max_memory_file_size, min(USIZE_MAX, I64_MAX)); + stream = memory; + } + luexp(stream->set_size(0)); + luexp(stream->seek(0, SeekMode::begin)); + } + lucatchret; + return stream; + } + + R> Package::copy_contents(Node* node, bool truncate) + { + Ref result; + lutry + { + luset(result, make_staging(truncate ? 0 : node->size)); + if(truncate) return result; + Ref input; + if(node->data) + { + luexp(node->data->seek(0, SeekMode::begin)); + input = node->data; + } + else luset(input, m_archive->open_entry(node->index)); + byte_t buffer[65536]; + u64 copied = 0; + while(copied < node->size) + { + usize count = 0; + luexp(input->read(buffer, (usize)min(sizeof(buffer), node->size - copied), &count)); + if(!count) return E_BAD_DATA; + luexp(write_all(result, buffer, count)); + copied += count; + } + // Consume EOF to surface the original ZIP CRC before committing a staging copy. + usize count = 0; + luexp(input->read(buffer, 1, &count)); + if(count) return E_BAD_DATA; + } + lucatchret; + return result; + } + + bool Package::is_open() { MutexGuard guard(m_mutex); return m_open; } + bool Package::is_read_only() { MutexGuard guard(m_mutex); return !m_writable; } + bool Package::is_dirty() { MutexGuard guard(m_mutex); return m_dirty; } + + R> Package::open_file(const c8* path, FileOpenFlag flags, FileCreationMode creation) + { + MutexGuard guard(m_mutex); + Ref result; + lutry + { + bool writing = test_flags(flags, FileOpenFlag::write); + if(((u32)flags & ~7U) || !((u32)flags & 3U)) return E_BAD_ARGUMENTS; + if(creation < FileCreationMode::create_always || creation > FileCreationMode::open_existing_as_new) return E_BAD_ARGUMENTS; + luexp(check_open(writing)); + lulet(parsed, parse_path(path)); + auto found = find_node(parsed.name, parsed.directory); + Node* node = nullptr; + if(succeeded(found)) node = found.get(); + else if(unwrap_errcode(found.errcode()) != E_NOT_FOUND) return found.errcode(); + if(node && node->directory) return E_IS_DIRECTORY; + if(parsed.directory) return E_NOT_DIRECTORY; + bool truncate = creation == FileCreationMode::create_always || creation == FileCreationMode::open_existing_as_new; + if(truncate && !writing) return E_ACCESS_DENIED; + if(node) + { + if(creation == FileCreationMode::create_new) return E_ALREADY_EXISTS; + if(node->writer || (writing && node->readers)) return E_BUSY; + if(writing && (truncate || !node->data)) + { + lulet(data, copy_contents(node, truncate)); + node->data = move(data); + } + if(truncate) + { + node->size = 0; + node->data_changed = true; + m_dirty = true; + } + } + else + { + if(creation == FileCreationMode::open_existing || creation == FileCreationMode::open_existing_as_new) return E_NOT_FOUND; + if(!writing) return E_ACCESS_DENIED; + luexp(check_parent(parsed.name)); + lulet(data, make_staging(0)); + UniquePtr created(memnew()); + created->data = move(data); + created->data_changed = true; + created->compression = m_options.compression; + created->compression_level = m_options.compression_level; + node = created.get(); + m_nodes.insert(make_pair(move(parsed.name), move(created))); + m_dirty = true; + } + auto file = new_object(); + file->m_owner = this; + file->m_node = node; + file->m_flags = flags; + if(writing) node->writer = true; + else ++node->readers; + ++m_handles; + result = file; + } + lucatchret; + return result; + } + + R Package::get_file_attribute(const c8* path) + { + MutexGuard guard(m_mutex); + FileAttribute result{}; + lutry + { + luexp(check_open()); + lulet(parsed, parse_path(path)); + lulet(node, find_node(parsed.name, parsed.directory)); + result.size = node->size; + if(node->directory) result.attributes |= FileAttributeFlag::directory; + if(!m_writable) result.attributes |= FileAttributeFlag::read_only; + } + lucatchret; + return result; + } + + R> Package::open_dir(const c8* path) + { + MutexGuard guard(m_mutex); + Ref iterator; + lutry + { + luexp(check_open()); + lulet(parsed, parse_path(path)); + luexp(find_node(parsed.name, true)); + auto result = new_object(); + for(const auto& item : m_nodes) + { + if(item.first.empty() || parent_path(item.first).compare(parsed.name)) continue; + DirectoryEntry entry; + usize begin = parsed.name.empty() ? 0 : parsed.name.size() + 1; + entry.name.assign(item.first.data() + begin, item.first.size() - begin); + entry.attributes = item.second->directory ? FileAttributeFlag::directory : FileAttributeFlag::none; + if(!m_writable) entry.attributes |= FileAttributeFlag::read_only; + result->m_entries.push_back(move(entry)); + } + sort(result->m_entries.begin(), result->m_entries.end(), [](const DirectoryEntry& lhs, const DirectoryEntry& rhs) + { + return strcmp(lhs.name.c_str(), rhs.name.c_str()) < 0; + }); + iterator = result; + } + lucatchret; + return iterator; + } + + RV Package::create_dir(const c8* path) + { + MutexGuard guard(m_mutex); + lutry + { + luexp(check_open(true)); + lulet(parsed, parse_path(path)); + if(m_nodes.find(parsed.name) != m_nodes.end()) return E_ALREADY_EXISTS; + if(parsed.name.size() + 1 > 65535) return E_BAD_ARGUMENTS; + luexp(check_parent(parsed.name)); + UniquePtr node(memnew()); + node->directory = true; + m_nodes.insert(make_pair(move(parsed.name), move(node))); + m_dirty = true; + } + lucatchret; + return ok; + } + + RV Package::copy_file(const c8* from_path, const c8* to_path) + { + MutexGuard guard(m_mutex); + lutry + { + luexp(check_open(true)); + lulet(from, parse_path(from_path)); + lulet(to, parse_path(to_path)); + lulet(source, find_node(from.name, from.directory)); + if(source->directory) return E_IS_DIRECTORY; + if(source->writer) return E_BUSY; + if(to.directory) return E_NOT_DIRECTORY; + if(m_nodes.find(to.name) != m_nodes.end()) return E_ALREADY_EXISTS; + luexp(check_parent(to.name)); + lulet(data, copy_contents(source, false)); + UniquePtr node(memnew()); + node->size = source->size; + node->compression = source->compression; + node->compression_level = source->compression_level; + node->data = move(data); + node->data_changed = true; + m_nodes.insert(make_pair(move(to.name), move(node))); + m_dirty = true; + } + lucatchret; + return ok; + } + + RV Package::move_file(const c8* from_path, const c8* to_path, FileMoveFlag flags) + { + MutexGuard guard(m_mutex); + if((flags & ~(FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy)) != FileMoveFlag::none) return E_BAD_ARGUMENTS; + lutry + { + luexp(check_open(true)); + lulet(from, parse_path(from_path)); + lulet(to, parse_path(to_path)); + lulet(source, find_node(from.name, from.directory)); + if(from.name.empty() || to.name.empty()) return E_BAD_ARGUMENTS; + if(to.directory && !source->directory) return E_NOT_DIRECTORY; + auto destination = m_nodes.find(to.name); + if(destination != m_nodes.end()) + { + if(!test_flags(flags, FileMoveFlag::allow_overwrite)) return E_ALREADY_EXISTS; + if(!from.name.compare(to.name)) return E_BAD_ARGUMENTS; + if(source->directory || destination->second->directory) return E_IS_DIRECTORY; + if(destination->second->writer || destination->second->readers) return E_BUSY; + } + if(source->directory && is_descendant(to.name, from.name)) return E_BAD_ARGUMENTS; + luexp(check_parent(to.name)); + Vector> changes; + for(const auto& item : m_nodes) + { + if(item.first.compare(from.name) && !is_descendant(item.first, from.name)) continue; + if(item.second->writer || item.second->readers) return E_BUSY; + String name = to.name; + name.append(item.first.data() + from.name.size(), item.first.size() - from.name.size()); + if(name.size() + (item.second->directory ? 1 : 0) > 65535) return E_BAD_ARGUMENTS; + changes.push_back(make_pair(item.first, move(name))); + } + // Move owners out before inserting new keys so rehashing cannot invalidate + // iterators and no half-updated namespace is observable. + Vector>> nodes; + if(destination != m_nodes.end()) m_nodes.erase(destination); + for(auto& change : changes) + { + auto found = m_nodes.find(change.first); + nodes.push_back(make_pair(move(change.second), move(found->second))); + m_nodes.erase(found); + } + for(auto& item : nodes) m_nodes.insert(move(item)); + m_dirty = true; + } + lucatchret; + return ok; + } + + RV Package::delete_file(const c8* path) + { + MutexGuard guard(m_mutex); + lutry + { + luexp(check_open(true)); + lulet(parsed, parse_path(path)); + if(parsed.name.empty()) return E_BAD_ARGUMENTS; + lulet(node, find_node(parsed.name, parsed.directory)); + if(node->writer || node->readers) return E_BUSY; + if(node->directory) + { + for(const auto& item : m_nodes) + { + if(is_descendant(item.first, parsed.name)) return E_DIRECTORY_NOT_EMPTY; + } + } + m_nodes.erase(parsed.name); + m_dirty = true; + } + lucatchret; + return ok; + } + + R Package::get_file_compression(const c8* path) + { + MutexGuard guard(m_mutex); + CompressionMethod result = CompressionMethod::store; + lutry + { + luexp(check_open()); + lulet(parsed, parse_path(path)); + lulet(node, find_node(parsed.name, parsed.directory)); + if(node->directory) return E_IS_DIRECTORY; + result = node->compression; + } + lucatchret; + return result; + } + + RV Package::set_file_compression(const c8* path, CompressionMethod method, u32 level) + { + MutexGuard guard(m_mutex); + lutry + { + luexp(check_open(true)); + luexp(validate_compression(method, level)); + lulet(parsed, parse_path(path)); + lulet(node, find_node(parsed.name, parsed.directory)); + if(node->directory) return E_IS_DIRECTORY; + if(node->writer || node->readers) return E_BUSY; + if(node->compression != method || node->compression_level != level) + { + node->compression = method; + node->compression_level = level; + node->compression_changed = true; + m_dirty = true; + } + } + lucatchret; + return ok; + } + + RV Package::flush(ISeekableStream* destination) + { + MutexGuard guard(m_mutex); + lutry + { + luexp(check_open(true)); + if(m_handles) return E_BUSY; + if(!destination || m_source == destination) return E_BAD_ARGUMENTS; + for(const auto& item : m_nodes) + { + if(item.second->data == destination) return E_BAD_ARGUMENTS; + } + Ref archive; + if(m_source) + { + luset(archive, Zip::open_archive(m_source, Zip::OpenMode::read_write)); + } + else + { + luset(archive, Zip::new_archive()); + } + HashSet surviving; + for(const auto& item : m_nodes) + { + if(item.second->index != U64_MAX) surviving.insert(item.second->index); + } + lulet(entries, archive->get_entries()); + for(const auto& entry : entries) + { + if(surviving.find(entry.index) == surviving.end()) luexp(archive->delete_entry(entry.index)); + } + Vector> renames; + u64 temporary_id = 0; + for(const auto& item : m_nodes) + { + auto node = item.second.get(); + if(node->index == U64_MAX) continue; + String name = item.first; + if(node->directory) name.push_back('/'); + if(!name.compare(node->original_name)) continue; + String temporary; + // Break rename cycles before assigning final names (including exchanges + // of files and directories). Temporary names never survive a saved ZIP. + while(true) + { + c8 buffer[96]; + snprintf(buffer, sizeof(buffer), "__luna_pak_move_%llu__", (unsigned long long)temporary_id++); + temporary = buffer; + if(m_nodes.find(temporary) != m_nodes.end()) continue; + if(node->directory) temporary.push_back('/'); + auto found = archive->find_entry(temporary.c_str()); + if(failed(found)) + { + if(unwrap_errcode(found.errcode()) != E_NOT_FOUND) return found.errcode(); + break; + } + } + luexp(archive->rename_entry(node->index, temporary.c_str())); + renames.push_back(make_pair(node, move(name))); + } + for(const auto& rename : renames) luexp(archive->rename_entry(rename.first->index, rename.second.c_str())); + for(const auto& item : m_nodes) + { + if(item.first.empty()) continue; + auto node = item.second.get(); + if(node->index == U64_MAX) + { + if(node->directory) + { + luexp(archive->add_directory(item.first.c_str())); + } + else + { + luexp(archive->add_file(item.first.c_str(), node->data, + (Zip::CompressionMethod)node->compression, node->compression_level)); + } + } + else if(!node->directory) + { + if(node->data_changed) luexp(archive->replace_file(node->index, node->data)); + if(node->compression_changed || node->data_changed) + { + luexp(archive->set_compression(node->index, (Zip::CompressionMethod)node->compression, node->compression_level)); + } + } + } + luexp(archive->save(destination)); + // Reopen and validate before discarding any retry state. A destination that + // fails reads after a successful write is not adopted. + auto next = new_object(); + luexp(next->init(destination, OpenMode::read_write, m_options)); + for(auto& item : next->m_nodes) + { + auto previous = m_nodes.find(item.first); + if(previous != m_nodes.end()) item.second->compression_level = previous->second->compression_level; + } + m_archive = move(next->m_archive); + m_source = move(next->m_source); + m_nodes = move(next->m_nodes); + m_dirty = false; + } + lucatchret; + return ok; + } + + RV Package::discard() + { + MutexGuard guard(m_mutex); + if(m_handles) return E_BUSY; + m_nodes.clear(); + m_archive = nullptr; + m_source = nullptr; + m_open = false; + m_dirty = false; + return ok; + } + + LUNA_PAK_API R> open_pak(ISeekableStream* source, OpenMode mode, const Options& options) + { + if(!source) return E_BAD_ARGUMENTS; + auto package = new_object(); + auto result = package->init(source, mode, options); + if(failed(result)) return result.errcode(); + return Ref(package); + } + + LUNA_PAK_API R> new_pak(const Options& options) + { + auto package = new_object(); + auto result = package->init(nullptr, OpenMode::read_write, options); + if(failed(result)) return result.errcode(); + return Ref(package); + } +} diff --git a/Modules/Luna/Pak/Source/Package.hpp b/Modules/Luna/Pak/Source/Package.hpp new file mode 100644 index 00000000..42ac37c3 --- /dev/null +++ b/Modules/Luna/Pak/Source/Package.hpp @@ -0,0 +1,134 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Package.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include "../Pak.hpp" +#include +#include +#include +#include +#include +#include "Package.generated.hpp" + +namespace Luna::Pak +{ + struct PathHash + { + usize operator()(const String& path) const { return memhash(path.data(), path.size()); } + }; + struct PathEqual + { + bool operator()(const String& lhs, const String& rhs) const { return lhs.compare(rhs) == 0; } + }; + + struct Node + { + bool directory = false; + u64 index = U64_MAX; + u64 size = 0; + String original_name; + Ref data; + CompressionMethod compression = CompressionMethod::store; + u32 compression_level = 0; + bool data_changed = false; + bool compression_changed = false; + usize readers = 0; + bool writer = false; + }; + + struct [[Luna::struct("{5A02E1F3-58E0-445B-9CED-4BB9CC650B72}")]] Package : IPak + { + luiimpl(); + Ref m_mutex; + Options m_options; + Ref m_source; + Ref m_archive; + HashMap, PathHash, PathEqual> m_nodes; + usize m_handles = 0; + bool m_open = true; + bool m_writable = false; + bool m_dirty = false; + RV init(ISeekableStream* source, OpenMode mode, const Options& options); + RV load_index(); + RV check_open(bool writing = false); + R find_node(const String& path, bool require_directory = false); + RV check_parent(const String& path); + R> make_staging(u64 size); + R> copy_contents(Node* node, bool truncate); + bool is_open() override; + bool is_read_only() override; + bool is_dirty() override; + R> open_file(const c8* path, FileOpenFlag flags, FileCreationMode creation) override; + R get_file_attribute(const c8* path) override; + R> open_dir(const c8* path) override; + RV create_dir(const c8* path) override; + RV copy_file(const c8* from_path, const c8* to_path) override; + RV move_file(const c8* from_path, const c8* to_path, FileMoveFlag flags) override; + RV delete_file(const c8* path) override; + R get_file_compression(const c8* path) override; + RV set_file_compression(const c8* path, CompressionMethod method, u32 level) override; + RV flush(ISeekableStream* destination) override; + RV discard() override; + }; + + struct [[Luna::struct("{C3A4B920-6786-4AF6-B3C0-2C729E4BF61B}")]] PakFile : IFile + { + luiimpl(); + Ref m_owner; + Node* m_node = nullptr; + FileOpenFlag m_flags = FileOpenFlag::none; + u64 m_position = 0; + u64 m_decode_position = 0; + Ref m_reader; + ~PakFile(); + RV prepare_reader(); + RV resize(u64 size); + RV read(void* buffer, usize size, usize* read_bytes) override; + RV write(const void* buffer, usize size, usize* write_bytes) override; + R tell() override; + RV seek(i64 offset, SeekMode mode) override; + u64 get_size() override; + RV set_size(u64 size) override; + void flush() override; + }; + + struct DirectoryEntry + { + String name; + FileAttributeFlag attributes; + }; + + struct [[Luna::struct("{200E81BA-7DE4-421D-B06A-50C1B1671A3F}")]] DirectoryIterator : IFileIterator + { + luiimpl(); + Vector m_entries; + usize m_index = 0; + bool is_valid() override { return m_index < m_entries.size(); } + const c8* get_filename() override { return is_valid() ? m_entries[m_index].name.c_str() : nullptr; } + FileAttributeFlag get_attributes() override { return is_valid() ? m_entries[m_index].attributes : FileAttributeFlag::none; } + bool move_next() override { if(is_valid()) ++m_index; return is_valid(); } + }; + + struct [[Luna::struct("{7AD00597-6E50-43AC-AEA0-699BF3BCC095}")]] MemoryStream : ISeekableStream + { + luiimpl(); + Vector m_bytes; + u64 m_position = 0; + u64 m_limit = 0; + RV read(void* buffer, usize size, usize* read_bytes) override; + RV write(const void* buffer, usize size, usize* write_bytes) override; + R tell() override { return m_position; } + RV seek(i64 offset, SeekMode mode) override; + u64 get_size() override { return m_bytes.size(); } + RV set_size(u64 size) override; + }; + + R seek_position(u64 position, u64 size, i64 offset, SeekMode mode); + RV write_all(IStream* stream, const void* buffer, usize size, usize* written = nullptr); +} diff --git a/Modules/Luna/Pak/Source/Pak.cpp b/Modules/Luna/Pak/Source/Pak.cpp new file mode 100644 index 00000000..f25bae62 --- /dev/null +++ b/Modules/Luna/Pak/Source/Pak.cpp @@ -0,0 +1,34 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Pak.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include +#define LUNA_PAK_API LUNA_EXPORT +#include "Package.hpp" +#include "Pak.meta.generated.hpp" +#include + +namespace Luna +{ + struct PakModule : Module + { + const c8* get_name() override { return "Pak"; } + RV on_register() override { return add_dependency_module(this, module_zip()); } + RV on_init() override + { + Meta::register_Pak_types(); + return ok; + } + }; + + LUNA_PAK_API Module* module_pak() + { + static PakModule module; + return &module; + } +} diff --git a/Modules/Luna/Runtime/File.hpp b/Modules/Luna/Runtime/File.hpp index 1435ae2a..167a91cf 100644 --- a/Modules/Luna/Runtime/File.hpp +++ b/Modules/Luna/Runtime/File.hpp @@ -70,6 +70,19 @@ namespace Luna open_existing_as_new = 5 }; + //! Specifies options for moving files and directories. + enum class FileMoveFlag : u32 + { + //! Does not overwrite an existing destination. Uses the platform's default move behavior. + none = 0x00, + //! Allows a file to replace an existing file. Existing directories cannot be overwritten, + //! and a directory cannot replace an existing file. + allow_overwrite = 0x01, + //! Forbids cross-filesystem copy/delete fallback. Returns @ref E_NOT_SUPPORTED for a + //! cross-filesystem move instead of copying. + no_copy = 0x02, + }; + //! Specifies file attributes. struct FileAttribute { @@ -161,16 +174,30 @@ namespace Luna //! * @ref E_BAD_PLATFORM_CALL for all errors that cannot be identified. LUNA_RUNTIME_API RV copy_file(const c8* from_path, const c8* to_path); //! Moves the file or directory from the source path to the destination path. This call can also be used to rename a file. - //! @param[in] from_path Source file or directory path. If `from_path` does not existm this operation failed with @ref E_NOT_FOUND. - //! @param[in] to_path Destination file or directory path. If `to_path` already exists, this operation fails with @ref E_ALREADY_EXISTS - //! and the existing file will not be modified. + //! @param[in] from_path Source file or directory path. If it does not exist, this operation fails with @ref E_NOT_FOUND. + //! @param[in] to_path Destination file or directory path. Its parent directory must exist. + //! @param[in] flags The move options. By default, an existing destination causes @ref E_ALREADY_EXISTS + //! and remains unmodified. @ref FileMoveFlag::allow_overwrite permits replacing an existing file. + //! @details Directories can be moved to absent destinations, but cannot participate in replacement. + //! Cross-filesystem moves follow native platform support: Windows permits copy/delete fallback for + //! files unless @ref FileMoveFlag::no_copy is set; POSIX always requires a native rename. + //! Combine @ref FileMoveFlag::allow_overwrite and @ref FileMoveFlag::no_copy to publish a prepared + //! file without deleting its destination first or falling back to copying. Native handle-sharing + //! rules apply; close handles that prevent the move first. Namespace atomicity and durability follow + //! the underlying filesystem; this function does not synchronize file data or promise recovery after power loss. + //! @par Valid Usage + //! * The paths must identify distinct files or directories. + //! * Callers must exclude concurrent changes to the source and destination entries. //! @par Possible Errors //! * @ref E_BAD_ARGUMENTS //! * @ref E_ALREADY_EXISTS //! * @ref E_ACCESS_DENIED //! * @ref E_NOT_FOUND + //! * @ref E_IS_DIRECTORY if either entry is a directory when replacement is requested. + //! * @ref E_NOT_SUPPORTED if a cross-filesystem move cannot use a native rename and no fallback is available or allowed. + //! * @ref E_BUSY //! * @ref E_BAD_PLATFORM_CALL for all errors that cannot be identified. - LUNA_RUNTIME_API RV move_file(const c8* from_path, const c8* to_path); + LUNA_RUNTIME_API RV move_file(const c8* from_path, const c8* to_path, FileMoveFlag flags = FileMoveFlag::none); //! Deletes the specified file or directory. //! @param[in] file_path The file or directory to delete. //! If this is a directory, it must be empty. diff --git a/Modules/Luna/Runtime/Guid.hpp b/Modules/Luna/Runtime/Guid.hpp new file mode 100644 index 00000000..04285d56 --- /dev/null +++ b/Modules/Luna/Runtime/Guid.hpp @@ -0,0 +1,79 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Guid.hpp +* @author JXMaster +* @date 2026/8/25 +*/ +#pragma once +#include "Result.hpp" + +namespace Luna +{ + //! The number of characters in the canonical textual representation of a GUID. + inline constexpr usize GUID_STRING_LENGTH = 36; + + //! Encodes one GUID into its canonical lowercase textual representation. + //! @param[in] guid The GUID to encode. + //! @param[out] out_buffer The buffer that receives exactly 36 characters. No null terminator is appended. + //! @param[in] buf_size The number of writable characters in @p out_buffer. + //! @return Returns @ref ok on success, @ref E_BAD_ARGUMENTS if @p out_buffer is `nullptr`, or + //! @ref E_INSUFFICIENT_USER_BUFFER if @p buf_size is smaller than 36. + inline constexpr RV encode_guid(const Guid& guid, c8* out_buffer, usize buf_size) + { + if(!out_buffer) return E_BAD_ARGUMENTS; + if(buf_size < GUID_STRING_LENGTH) return E_INSUFFICIENT_USER_BUFFER; + usize output_index = 0; + for(usize digit_index = 0; digit_index < 32; ++digit_index) + { + if(digit_index == 8 || digit_index == 12 || digit_index == 16 || digit_index == 20) + { + out_buffer[output_index++] = '-'; + } + u8 digit; + if(digit_index < 16) + { + digit = (u8)((guid.high >> ((15 - digit_index) * 4)) & 0x0f); + } + else + { + digit = (u8)((guid.low >> ((31 - digit_index) * 4)) & 0x0f); + } + out_buffer[output_index++] = digit < 10 ? (c8)('0' + digit) : (c8)('a' + digit - 10); + } + return ok; + } + + //! Decodes one GUID from its canonical textual representation. + //! @param[in] buffer The character buffer to decode. The buffer does not need to be null-terminated. + //! @param[in] buf_size The number of readable characters in @p buffer. Both the 36-character canonical form and + //! the same form enclosed in braces are accepted. + //! @param[out] out_guid The decoded GUID. This value is not modified if decoding fails. + //! @return Returns @ref ok on success, @ref E_BAD_ARGUMENTS if @p buffer is `nullptr`, or @ref E_BAD_DATA if + //! the buffer is not a canonical GUID representation. + inline constexpr RV decode_guid(const c8* buffer, usize buf_size, Guid& out_guid) + { + if(!buffer) return E_BAD_ARGUMENTS; + bool braced = buf_size == GUID_STRING_LENGTH + 2 && buffer[0] == '{' && + buffer[GUID_STRING_LENGTH + 1] == '}'; + if(buf_size != GUID_STRING_LENGTH && !braced) return E_BAD_DATA; + const c8* value = braced ? buffer + 1 : buffer; + for(usize i = 0; i < GUID_STRING_LENGTH; ++i) + { + if(i == 8 || i == 13 || i == 18 || i == 23) + { + if(value[i] != '-') return E_BAD_DATA; + } + else if(!((value[i] >= '0' && value[i] <= '9') || + (value[i] >= 'a' && value[i] <= 'f') || + (value[i] >= 'A' && value[i] <= 'F'))) + { + return E_BAD_DATA; + } + } + out_guid = Guid(value); + return ok; + } +} diff --git a/Modules/Luna/Runtime/Path.hpp b/Modules/Luna/Runtime/Path.hpp index 90b33f4f..eeefcf53 100644 --- a/Modules/Luna/Runtime/Path.hpp +++ b/Modules/Luna/Runtime/Path.hpp @@ -276,6 +276,10 @@ namespace Luna { iter = m_nodes.erase(iter2, iter2 + 2); } + else + { + ++iter; + } } else if ((!strcmp((*iter).c_str(), "."))) { diff --git a/Modules/Luna/Runtime/Source/File.cpp b/Modules/Luna/Runtime/Source/File.cpp index 3543f1b5..ef00d7a0 100644 --- a/Modules/Luna/Runtime/Source/File.cpp +++ b/Modules/Luna/Runtime/Source/File.cpp @@ -53,9 +53,27 @@ namespace Luna { return encode_platform_result(Platform::copy_file(from_path, to_path)); } - LUNA_RUNTIME_API RV move_file(const c8* from_path, const c8* to_path) + LUNA_RUNTIME_API RV move_file(const c8* from_path, const c8* to_path, FileMoveFlag flags) { - return encode_platform_result(Platform::move_file(from_path, to_path)); + if(!from_path || !to_path || !from_path[0] || !to_path[0]) return E_BAD_ARGUMENTS; + if((flags & ~(FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy)) != FileMoveFlag::none) return E_BAD_ARGUMENTS; + if(test_flags(flags, FileMoveFlag::allow_overwrite)) + { + if(!strcmp(from_path, to_path)) return E_BAD_ARGUMENTS; + lutry + { + lulet(source, get_file_attribute(from_path)); + auto destination = get_file_attribute(to_path); + if(succeeded(destination)) + { + if(test_flags(source.attributes, FileAttributeFlag::directory) || + test_flags(destination.get().attributes, FileAttributeFlag::directory)) return E_IS_DIRECTORY; + } + else if(unwrap_errcode(destination.errcode()) != E_NOT_FOUND) return destination.errcode(); + } + lucatchret; + } + return encode_platform_result(Platform::move_file(from_path, to_path, flags)); } LUNA_RUNTIME_API RV delete_file(const c8* filename) { @@ -92,4 +110,4 @@ namespace Luna { Platform::release_process_path(path); } -} \ No newline at end of file +} diff --git a/Modules/Luna/Runtime/Source/Platform/File.hpp b/Modules/Luna/Runtime/Source/Platform/File.hpp index 92faa23d..b27bb0d8 100644 --- a/Modules/Luna/Runtime/Source/Platform/File.hpp +++ b/Modules/Luna/Runtime/Source/Platform/File.hpp @@ -149,17 +149,23 @@ namespace Luna //! * @ref Result::bad_platform_call for all errors that cannot be identified. Result copy_file(const c8* from_path, const c8* to_path); - //! Moves the file or directory from the source path to the destination path. This call can also be used to rename a file. - //! @param[in] from_path Source file or directory path. If `from_path` does not existm this operation failed with @ref E_NOT_FOUND. - //! @param[in] to_path Destination file or directory path. If `to_path` already exists, this operation fails with @ref E_ALREADY_EXISTS - //! and the existing file will not be modified. + //! Moves or renames a file or directory using native platform operations. + //! @param[in] from_path The source path. + //! @param[in] to_path The destination path. Its parent directory must exist. + //! @param[in] flags Controls replacement and native copy/delete fallback. + //! @details The Runtime caller validates arguments and rejects replacement involving directories. + //! POSIX always uses rename. Windows permits cross-volume file copying unless + //! @ref FileMoveFlag::no_copy is set. Without @ref FileMoveFlag::allow_overwrite, + //! an existing destination causes @ref Result::already_exists. //! @par Possible Errors //! * @ref Result::bad_arguments //! * @ref Result::already_exists //! * @ref Result::access_denied //! * @ref Result::not_found + //! * @ref Result::not_supported + //! * @ref Result::busy //! * @ref Result::bad_platform_call for all errors that cannot be identified. - Result move_file(const c8* from_path, const c8* to_path); + Result move_file(const c8* from_path, const c8* to_path, FileMoveFlag flags); //! Deletes the specified file or directory. //! @param[in] file_path The file or directory to delete. If this is a non-empty directory, all its contexts will also be deleted. @@ -254,4 +260,4 @@ namespace Luna //! Releases the path string returned by @ref get_process_path. void release_process_path(const c8* path); } -} \ No newline at end of file +} diff --git a/Modules/Luna/Runtime/Source/Platform/POSIX/File.cpp b/Modules/Luna/Runtime/Source/Platform/POSIX/File.cpp index 10fff1da..5741521e 100644 --- a/Modules/Luna/Runtime/Source/Platform/POSIX/File.cpp +++ b/Modules/Luna/Runtime/Source/Platform/POSIX/File.cpp @@ -470,7 +470,7 @@ namespace Luna int r = stat(path, &s); if (r != 0) { - return Result::bad_platform_call; + return encode_errno(errno); } out_attribute.size = s.st_size; out_attribute.last_access_time = s.st_atime; @@ -592,18 +592,17 @@ namespace Luna } return res; } - Result move_file(const c8* from_path, const c8* to_path) + Result move_file(const c8* from_path, const c8* to_path, FileMoveFlag flags) { - FileAttribute attr; - if (get_file_attribute(to_path, attr) == Result::success) + if(!test_flags(flags, FileMoveFlag::allow_overwrite)) { - return Result::already_exists; - } - int res = ::rename(from_path, to_path); - if(res != 0) - { - return encode_errno(errno); + // Count symbolic links as existing entries even if their targets are absent. + struct stat destination; + if(::lstat(to_path, &destination) == 0) return Result::already_exists; + if(errno != ENOENT) return encode_errno(errno); } + // POSIX has no copy/delete fallback, regardless of the no_copy option. + if(::rename(from_path, to_path) != 0) return encode_errno(errno); return Result::success; } Result delete_file(const c8* path) diff --git a/Modules/Luna/Runtime/Source/Platform/Windows/ErrCode.hpp b/Modules/Luna/Runtime/Source/Platform/Windows/ErrCode.hpp index 751ef733..da8fd3f1 100644 --- a/Modules/Luna/Runtime/Source/Platform/Windows/ErrCode.hpp +++ b/Modules/Luna/Runtime/Source/Platform/Windows/ErrCode.hpp @@ -23,6 +23,10 @@ namespace Luna case ERROR_PATH_NOT_FOUND: return Result::not_found; case ERROR_TOO_MANY_OPEN_FILES: return Result::out_of_resource; case ERROR_ACCESS_DENIED: return Result::access_denied; + case ERROR_SHARING_VIOLATION: + case ERROR_LOCK_VIOLATION: return Result::busy; + case ERROR_FILE_EXISTS: + case ERROR_ALREADY_EXISTS: return Result::already_exists; case ERROR_INVALID_HANDLE: return Result::bad_arguments; case ERROR_NOT_ENOUGH_MEMORY: case ERROR_OUTOFMEMORY: return Result::out_of_memory; diff --git a/Modules/Luna/Runtime/Source/Platform/Windows/File.cpp b/Modules/Luna/Runtime/Source/Platform/Windows/File.cpp index 7d993c6b..65b918a7 100644 --- a/Modules/Luna/Runtime/Source/Platform/Windows/File.cpp +++ b/Modules/Luna/Runtime/Source/Platform/Windows/File.cpp @@ -556,20 +556,19 @@ namespace Luna } return Result::success; } - Result move_file(const c8* from_path, const c8* to_path) + Result move_file(const c8* from_path, const c8* to_path, FileMoveFlag flags) { lucheck(from_path && to_path); wchar_t* from_buffer = utf8_to_wchar_buffered(from_path); wchar_t* to_buffer = utf8_to_wchar_buffered(to_path); - BOOL r = MoveFileExW(from_buffer, to_buffer, MOVEFILE_COPY_ALLOWED); + DWORD native_flags = test_flags(flags, FileMoveFlag::no_copy) ? 0 : MOVEFILE_COPY_ALLOWED; + if(test_flags(flags, FileMoveFlag::allow_overwrite)) native_flags |= MOVEFILE_REPLACE_EXISTING; + BOOL result = ::MoveFileExW(from_buffer, to_buffer, native_flags); + DWORD error = result ? ERROR_SUCCESS : ::GetLastError(); memfree(from_buffer); memfree(to_buffer); - if(!r) - { - DWORD err = ::GetLastError(); - return translate_last_error(err); - } - return Result::success; + if(error == ERROR_NOT_SAME_DEVICE) return Result::not_supported; + return translate_last_error(error); } static Result delete_single_file(const c8* path) { diff --git a/Modules/Luna/VFS/Driver.hpp b/Modules/Luna/VFS/Driver.hpp deleted file mode 100644 index 83539e01..00000000 --- a/Modules/Luna/VFS/Driver.hpp +++ /dev/null @@ -1,108 +0,0 @@ -/*! -* This file is a portion of LunaSDK. -* For conditions of distribution and use, see the disclaimer -* and license in LICENSE.txt -* -* @file Driver.hpp -* @author JXMaster -* @date 2022/5/24 -*/ -#pragma once -#include -#include - -#ifndef LUNA_VFS_API -#define LUNA_VFS_API -#endif - -namespace Luna -{ - namespace VFS - { - //! @addtogroup VFS - //! @{ - - //! Describes one virtual file system (VFS) driver. - //! @details One VFS driver describes functions to operate files on a certain kind of media. For example, one ZIP driver may provide - //! functions to read files from one ZIP archive, and the file devices created from the driver represents one real ZIP file opened for - //! reading. - struct DriverDesc - { - //! The user-defined driver data pointer that will be passed to all driver callback functions. - void* driver_data; - //! Called when the driver is unregistered from VFS. - //! @details The user should release all dynamic memory attached to @ref driver_data if any. - //! @param[in] driver_data The user-provided driver data. - void (*on_driver_unregister)(void* driver_data); - //! Called when one new device is mounted. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] driver_path The driver native path passed to @ref mount. - //! @param[in] mount_dir The directory used as the root directory of the mounted file device. - //! @param[in] params_type The type of the additional driver parameter object passed to @ref mount. - //! @param[in] params_data The pointer to the additional driver parameter object passed to @ref mount. - //! @return Returns the mount data that identifies the mounted device. - R(*on_mount)(void* driver_data, const c8* driver_path, const Path& mount_dir, typeinfo_t params_type, void* params_data); - //! Called when one device is unmounted. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] mount_data The mount data returned by @ref on_mount. - RV(*on_unmount)(void* driver_data, void* mount_data); - //! Called when @ref VFS::open_file is called on one file or directory belongs to one device of this driver. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] mount_data The mount data returned by @ref on_mount for the device. - //! @param[in] path The path of the file to open relative to the mount root path. - //! @param[in] flags The file open flags. - //! @param[in] creation The file creation flags. - R>(*on_open_file)(void* driver_data, void* mount_data, const Path& path, FileOpenFlag flags, FileCreationMode creation); - //! Called when @ref VFS::get_file_attribute is called on one file or directory belongs to one device of this driver. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] mount_data The mount data returned by @ref on_mount for the device. - //! @param[in] path The path of the file or directory to check relative to the mount root path. - //! @return Returns the file attribute object. - R(*on_get_file_attribute)(void* driver_data, void* mount_data, const Path& path); - //! Called when @ref VFS::copy_file is called on two files that both belong to devices of this driver. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] from_mount_data The mount data returned by @ref on_mount for the device that the file is copied from. - //! @param[in] to_mount_data The mount data returned by @ref on_mount for the device that the file is copied to. - //! @param[in] from_path The path of the file to copy from relative to the mount root path. - //! @param[in] to_path The path of the file to copy to relative to the mount root path. - RV(*on_copy_file)(void* driver_data, void* from_mount_data, void* to_mount_data, const Path& from_path, const Path& to_path); - //! Called when @ref VFS::move_file is called on two files that both belong to devices of this driver. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] from_mount_data The mount data returned by @ref on_mount for the device that the file is moved from. - //! @param[in] to_mount_data The mount data returned by @ref on_mount for the device that the file is moved to. - //! @param[in] from_path The path of the file to move from relative to the mount root path. - //! @param[in] to_path The path of the file to move to relative to the mount root path. - RV(*on_move_file)(void* driver_data, void* from_mount_data, void* to_mount_data, const Path& from_path, const Path& to_path); - //! Called when @ref VFS::delete_file is called on one file that belongs to devices of this driver. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] mount_data The mount data returned by @ref on_mount for the device. - //! @param[in] path The path of the file to delete. - RV(*on_delete_file)(void* driver_data, void* mount_data, const Path& path); - //! Called when @ref VFS::open_dir is called on one directory that belongs to devices of this driver. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] mount_data The mount data returned by @ref on_mount for the device. - //! @param[in] path The path of the directory to open. - //! @return Returns one file iterator used to enumerate files in the opened directory. - R>(*on_open_dir)(void* driver_data, void* mount_data, const Path& path); - //! Called when @ref VFS::create_dir is called on one directory that belongs to devices of this driver. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] mount_data The mount data returned by @ref on_mount for the device. - //! @param[in] path The path of the directory to create. - RV(*on_create_dir)(void* driver_data, void* mount_data, const Path& path); - //! Called when @ref VFS::get_native_path is called on one path that belongs to devices of this driver. - //! @param[in] driver_data The user-provided driver data. - //! @param[in] mount_data The mount data returned by @ref on_mount for the device. - //! @param[in] path The path to convert. - //! @return Returns one path string that represents the converted native path. - R(*on_get_native_path)(void* driver_data, void* mount_data, const Path& path); - }; - - //! Registers one new VFS driver to the system. - //! @param[in] name The name of the driver. If one driver that has the same name already exists in the system, - //! the old driver will be replaced by the new driver. - //! @param[in] desc The descriptor of the driver. - LUNA_VFS_API void register_driver(const Name& name, const DriverDesc& desc); - - //! @} - } -} \ No newline at end of file diff --git a/Modules/Luna/VFS/FileSystem.hpp b/Modules/Luna/VFS/FileSystem.hpp new file mode 100644 index 00000000..483b6230 --- /dev/null +++ b/Modules/Luna/VFS/FileSystem.hpp @@ -0,0 +1,80 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file FileSystem.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include +#include +#include "FileSystem.generated.hpp" + +#ifndef LUNA_VFS_API +#define LUNA_VFS_API +#endif + +namespace Luna::VFS +{ + //! @addtogroup VFS + //! @{ + + //! An independently usable filesystem instance that can be mounted into VFS. + //! @details Paths are relative to the addressed instance's root; copy/move + //! destinations use the destination root. An empty Path names a root. + //! Absolute paths, named roots and unresolved parent components are + //! invalid. Implementations own their storage, synchronization and handle lifetimes. + //! VFS retains mounted instances and may mount one instance at multiple paths. + //! Methods invoked by VFS must not reenter VFS. Destruction releases resources; + //! use checked flush before releasing an instance with deferred writes. + struct [[Luna::interface("{4371C890-22D1-4C8A-9155-6E62D3B9B88B}")]] IFileSystem : virtual Interface + { + //! Opens a file relative to the filesystem root. + //! @param[in] path The relative file path. + //! @param[in] flags The requested access flags. + //! @param[in] creation The file creation policy. + //! @return Returns a retained file handle. Failure must not create a file. + virtual R> open_file(const Path& path, FileOpenFlag flags, FileCreationMode creation) = 0; + //! Gets the attributes of a file or directory relative to the root. + //! @param[in] path The relative path, or an empty Path for the root. + virtual R get_file_attribute(const Path& path) = 0; + //! Enumerates the immediate children of a directory. + //! @param[in] path The relative directory path, or an empty Path for the root. + virtual R> open_dir(const Path& path) = 0; + //! Copies a file without replacing an existing destination. + //! @param[in] from_path The source file path relative to the root. + //! @param[in] to_path The destination file path relative to the destination root. + //! @param[in] to_file_system The destination instance. Null selects this instance. + //! @details E_NOT_SUPPORTED must leave storage unchanged; VFS then attempts + //! a generic streaming copy. Directory copying is not supported. + virtual RV copy_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system = nullptr) { return E_NOT_SUPPORTED; } + //! Moves a file or directory with optional file replacement. + //! @param[in] from_path The source path relative to the root. + //! @param[in] to_path The destination path relative to the destination root. + //! @param[in] to_file_system The destination instance. Null selects this instance. + //! @param[in] flags The replacement and copy fallback policy. Directories cannot replace existing entries. + //! @details E_NOT_SUPPORTED must leave storage unchanged. Unless no_copy is set, VFS may + //! attempt a streaming file copy followed by source deletion into an absent destination. + virtual RV move_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system = nullptr, + FileMoveFlag flags = FileMoveFlag::none) { return E_NOT_SUPPORTED; } + //! Deletes a file or an empty directory relative to the root. + //! @param[in] path The relative file or directory path. + virtual RV delete_file(const Path& path) { return E_NOT_SUPPORTED; } + //! Creates a directory whose parent already exists. + //! @param[in] path The relative directory path. + virtual RV create_dir(const Path& path) { return E_NOT_SUPPORTED; } + //! Translates a relative path to a native storage path if the instance supports it. + //! @param[in] path The relative path, or an empty Path for the root. + //! @return Returns E_NOT_SUPPORTED for entries without independent native paths. + virtual R get_native_path(const Path& path) { return E_NOT_SUPPORTED; } + //! Publishes deferred changes without closing this instance or changing mounts. + //! @details Implementations retain retryable state on failure. Pak instances + //! require all of their file handles to be released, including direct handles + //! and handles from any mount alias. Directory snapshots may remain open. + //! The default implementation has no deferred work and succeeds. + virtual RV flush() { return ok; } + }; + //! @} +} diff --git a/Modules/Luna/VFS/NativeFileSystem.hpp b/Modules/Luna/VFS/NativeFileSystem.hpp new file mode 100644 index 00000000..ddbdeef8 --- /dev/null +++ b/Modules/Luna/VFS/NativeFileSystem.hpp @@ -0,0 +1,25 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file NativeFileSystem.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include "FileSystem.hpp" + +namespace Luna::VFS +{ + //! @addtogroup VFS + //! @{ + //! Creates a filesystem instance rooted at an existing native directory. + //! @param[in] native_path The native directory path, resolved at creation time. + //! @return Returns an instance usable directly or through VFS::mount. + //! @details Later working-directory changes do not change this instance's root. + //! Files are ordinary Runtime IFile objects; filesystem flush has no deferred + //! package-level work. Native permissions and symbolic-link behavior apply. + LUNA_VFS_API R> new_native_file_system(const c8* native_path); + //! @} +} diff --git a/Modules/Luna/VFS/PakFileSystem.hpp b/Modules/Luna/VFS/PakFileSystem.hpp new file mode 100644 index 00000000..9e4552df --- /dev/null +++ b/Modules/Luna/VFS/PakFileSystem.hpp @@ -0,0 +1,64 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file PakFileSystem.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include "FileSystem.hpp" +#include +#include "PakFileSystem.generated.hpp" + +namespace Luna::VFS +{ + //! @addtogroup VFS + //! @{ + + //! Supplies streams and publication for a Pak filesystem using application-owned storage. + //! @details The filesystem retains this object. Callbacks run synchronously and must + //! not reenter the filesystem or VFS. Editable instances exclusively own their storage. + //! Returned stream lifetimes own cleanup, including incomplete outputs. A failed + //! publication must preserve the output for retry and define retention at shutdown. + //! All methods are pure virtual. Read-only providers must explicitly implement + //! unsupported write operations by returning @ref E_NOT_SUPPORTED. + struct [[Luna::interface("{C12A1299-B599-4C2D-8F35-66E4F6AA24A5}")]] IPakStorage : virtual Interface + { + //! Opens the existing package as a readable, seekable stream. + //! @details Its bytes and size must remain unchanged while Pak retains it. + virtual R> open_source() = 0; + //! Creates a fresh readable, writable, seekable, resizable output stream. + //! @details It must not alias any retained input or staging stream, even through + //! another handle. Called only for dirty editable packages. + virtual R> create_output() = 0; + //! Publishes one complete output previously returned by create_output. + //! @param[in] output The stream that Pak has already adopted as its next source. + //! @details Success makes this the current package in storage. Failure must leave + //! the previous publication unchanged and keep this output usable for retry. The + //! filesystem retains it and retries the same stream. Success must also preserve the + //! output's bytes and readable/seekable stream identity. + virtual RV publish(ISeekableStream* output) = 0; + }; + + //! Creates a filesystem instance for an existing native Pak file. + //! @param[in] native_path Native package path, resolved at creation time. + //! @param[in] mode Read-only or read/write access to the package. + //! @param[in] options Compression defaults and writable-file staging configuration. + //! @return Returns an instance usable directly or through VFS::mount. + //! @details Native writeback uses sibling temporary files and checked replacement. + //! Failed publication retains the output for retry and blocks further edits. + //! Call IFileSystem::flush to publish edits. Destruction discards unsaved edits. + LUNA_VFS_API R> new_pak_file_system(const c8* native_path, + Pak::OpenMode mode = Pak::OpenMode::read, const Pak::Options& options = Pak::Options()); + //! Creates a filesystem instance for an existing package in custom storage. + //! @param[in] storage The retained storage provider. Must not be null. + //! @param[in] mode Read-only or read/write access to the package. + //! @param[in] options Compression defaults and writable-file staging configuration. + //! @return Returns an instance with the same file, flush and lifetime semantics as + //! the native-file overload. Creating or mounting it does not register assets. + LUNA_VFS_API R> new_pak_file_system(IPakStorage* storage, + Pak::OpenMode mode = Pak::OpenMode::read, const Pak::Options& options = Pak::Options()); + //! @} +} diff --git a/Modules/Luna/VFS/Source/Drivers/PlatformFSDriver.cpp b/Modules/Luna/VFS/Source/Drivers/PlatformFSDriver.cpp deleted file mode 100644 index 82b2acc6..00000000 --- a/Modules/Luna/VFS/Source/Drivers/PlatformFSDriver.cpp +++ /dev/null @@ -1,122 +0,0 @@ -/*! -* This file is a portion of LunaSDK. -* For conditions of distribution and use, see the disclaimer -* and license in LICENSE.txt -* -* @file PlatformFSDriver.cpp -* @author JXMaster -* @date 2022/5/24 -*/ -#include -#define LUNA_VFS_API LUNA_EXPORT -#include "PlatformFSDriver.hpp" -#include "../../VFS.hpp" -#include "../../Driver.hpp" -namespace Luna -{ - namespace VFS - { - struct PlatformFileSystemMountData - { - Path native_path; - - Path make_native_path(const Path& path) - { - Path ret = native_path; - ret.append(path); - return ret; - } - String make_native_path_str(const Path& path) - { - auto ret_path = make_native_path(path); - auto ret = ret_path.encode(); - return ret; - } - }; - - static R fs_mount(void* driver_data, const c8* driver_path, const Path& mount_dir, typeinfo_t params_type, void* params_data) - { - PlatformFileSystemMountData* data = memnew(); - data->native_path = driver_path; - return data; - } - static RV fs_unmount(void* driver_data, void* mount_data) - { - memdelete((PlatformFileSystemMountData*)mount_data); - return ok; - } - static R> fs_open_file(void* driver_data, void* mount_data, const Path& path, FileOpenFlag flags, FileCreationMode creation) - { - auto data = (PlatformFileSystemMountData*)mount_data; - auto native_path = data->make_native_path_str(path); - return Luna::open_file(native_path.c_str(), flags, creation); - } - static R fs_get_file_attribute(void* driver_data, void* mount_data, const Path& path) - { - auto data = (PlatformFileSystemMountData*)mount_data; - auto native_path = data->make_native_path_str(path); - return Luna::get_file_attribute(native_path.c_str()); - } - static RV fs_copy_file(void* driver_data, void* from_mount_data, void* to_mount_data, const Path& from_path, const Path& to_path) - { - auto from_data = (PlatformFileSystemMountData*)from_mount_data; - auto to_data = (PlatformFileSystemMountData*)to_mount_data; - auto from = from_data->make_native_path_str(from_path); - auto to = to_data->make_native_path_str(to_path); - return Luna::copy_file(from.c_str(), to.c_str()); - } - static RV fs_move_file(void* driver_data, void* from_mount_data, void* to_mount_data, const Path& from_path, const Path& to_path) - { - auto from_data = (PlatformFileSystemMountData*)from_mount_data; - auto to_data = (PlatformFileSystemMountData*)to_mount_data; - auto from = from_data->make_native_path_str(from_path); - auto to = to_data->make_native_path_str(to_path); - return Luna::move_file(from.c_str(), to.c_str()); - } - static RV fs_delete_file(void* driver_data, void* mount_data, const Path& path) - { - auto data = (PlatformFileSystemMountData*)mount_data; - auto native_path = data->make_native_path_str(path); - return Luna::delete_file(native_path.c_str()); - } - static R> fs_open_dir(void* driver_data, void* mount_data, const Path& path) - { - auto data = (PlatformFileSystemMountData*)mount_data; - auto native_path = data->make_native_path_str(path); - return Luna::open_dir(native_path.c_str()); - } - static RV fs_create_dir(void* driver_data, void* mount_data, const Path& path) - { - auto data = (PlatformFileSystemMountData*)mount_data; - auto native_path = data->make_native_path_str(path); - return Luna::create_dir(native_path.c_str()); - } - static R fs_get_native_path(void* driver_data, void* mount_data, const Path& path) - { - auto data = (PlatformFileSystemMountData*)mount_data; - auto native_path = data->make_native_path_str(path); - return Name(native_path); - } - void register_platform_filesystem_driver() - { - DriverDesc desc; - desc.driver_data = nullptr; - desc.on_driver_unregister = nullptr; - desc.on_mount = fs_mount; - desc.on_unmount = fs_unmount; - desc.on_open_file = fs_open_file; - desc.on_get_file_attribute = fs_get_file_attribute; - desc.on_copy_file = fs_copy_file; - desc.on_move_file = fs_move_file; - desc.on_delete_file = fs_delete_file; - desc.on_open_dir = fs_open_dir; - desc.on_create_dir = fs_create_dir; - desc.on_get_native_path = fs_get_native_path; - register_driver(get_platform_filesystem_driver(), desc); - } - LUNA_VFS_API Name get_platform_filesystem_driver() - { - return "Platform File System"; - } - } -} \ No newline at end of file diff --git a/Modules/Luna/VFS/Source/Drivers/PlatformFSDriver.hpp b/Modules/Luna/VFS/Source/Drivers/PlatformFSDriver.hpp deleted file mode 100644 index 2f9f1ab8..00000000 --- a/Modules/Luna/VFS/Source/Drivers/PlatformFSDriver.hpp +++ /dev/null @@ -1,18 +0,0 @@ -/*! -* This file is a portion of LunaSDK. -* For conditions of distribution and use, see the disclaimer -* and license in LICENSE.txt -* -* @file PlatformFSDriver.hpp -* @author JXMaster -* @date 2022/5/24 -*/ -#pragma once - -namespace Luna -{ - namespace VFS - { - void register_platform_filesystem_driver(); - } -} \ No newline at end of file diff --git a/Modules/Luna/VFS/Source/Handles.cpp b/Modules/Luna/VFS/Source/Handles.cpp new file mode 100644 index 00000000..882a6678 --- /dev/null +++ b/Modules/Luna/VFS/Source/Handles.cpp @@ -0,0 +1,107 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Handles.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include "VFS.hpp" +#include + +namespace Luna::VFS +{ + MountedFile::~MountedFile() + { + if(!m_mutex) return; + MutexGuard guard(m_mutex); + if(m_mount) + { + auto& files = m_mount->m_files; + auto found = find(files.begin(), files.end(), this); + if(found != files.end()) files.erase(found); + } + invalidate(); + } + void MountedFile::invalidate() + { + m_file = nullptr; + m_mount = nullptr; + } + RV MountedFile::read(void* buffer, usize size, usize* count) + { + MutexGuard guard(m_mutex); + if(count) *count = 0; + return m_file ? m_file->read(buffer, size, count) : RV(E_BAD_CALLING_TIME); + } + RV MountedFile::write(const void* buffer, usize size, usize* count) + { + MutexGuard guard(m_mutex); + if(count) *count = 0; + return m_file ? m_file->write(buffer, size, count) : RV(E_BAD_CALLING_TIME); + } + R MountedFile::tell() + { + MutexGuard guard(m_mutex); + return m_file ? m_file->tell() : R(E_BAD_CALLING_TIME); + } + RV MountedFile::seek(i64 offset, SeekMode mode) + { + MutexGuard guard(m_mutex); + return m_file ? m_file->seek(offset, mode) : RV(E_BAD_CALLING_TIME); + } + u64 MountedFile::get_size() + { + MutexGuard guard(m_mutex); + return m_file ? m_file->get_size() : 0; + } + RV MountedFile::set_size(u64 size) + { + MutexGuard guard(m_mutex); + return m_file ? m_file->set_size(size) : RV(E_BAD_CALLING_TIME); + } + void MountedFile::flush() + { + MutexGuard guard(m_mutex); + if(m_file) m_file->flush(); + } + + MountedIterator::~MountedIterator() + { + if(!m_mutex) return; + MutexGuard guard(m_mutex); + if(m_mount) + { + auto& iterators = m_mount->m_iterators; + auto found = find(iterators.begin(), iterators.end(), this); + if(found != iterators.end()) iterators.erase(found); + } + invalidate(); + } + void MountedIterator::invalidate() + { + m_iterator = nullptr; + m_mount = nullptr; + } + bool MountedIterator::is_valid() + { + MutexGuard guard(m_mutex); + return m_iterator && m_iterator->is_valid(); + } + const c8* MountedIterator::get_filename() + { + MutexGuard guard(m_mutex); + return m_iterator ? m_iterator->get_filename() : nullptr; + } + FileAttributeFlag MountedIterator::get_attributes() + { + MutexGuard guard(m_mutex); + return m_iterator ? m_iterator->get_attributes() : FileAttributeFlag::none; + } + bool MountedIterator::move_next() + { + MutexGuard guard(m_mutex); + return m_iterator && m_iterator->move_next(); + } +} diff --git a/Modules/Luna/VFS/Source/NativeFileSystem.cpp b/Modules/Luna/VFS/Source/NativeFileSystem.cpp new file mode 100644 index 00000000..83837bd8 --- /dev/null +++ b/Modules/Luna/VFS/Source/NativeFileSystem.cpp @@ -0,0 +1,98 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file NativeFileSystem.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include +#define LUNA_VFS_API LUNA_EXPORT +#include "NativeFileSystem.hpp" +#include "Paths.hpp" + +namespace Luna::VFS +{ + R NativeFileSystem::resolve(const Path& path) + { + auto valid = validate_relative_path(path); + if(failed(valid)) return valid.errcode(); + Path absolute = m_root; + absolute.append(path); + return absolute.encode(); + } + R> NativeFileSystem::open_file(const Path& path, FileOpenFlag flags, FileCreationMode creation) + { + auto native = resolve(path); + if(failed(native)) return native.errcode(); + return Luna::open_file(native.get().c_str(), flags, creation); + } + R NativeFileSystem::get_file_attribute(const Path& path) + { + auto native = resolve(path); + if(failed(native)) return native.errcode(); + return Luna::get_file_attribute(native.get().c_str()); + } + R> NativeFileSystem::open_dir(const Path& path) + { + auto native = resolve(path); + if(failed(native)) return native.errcode(); + return Luna::open_dir(native.get().c_str()); + } + RV NativeFileSystem::copy_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system) + { + Ref destination(to_file_system ? to_file_system : this); + auto native = destination.as(); + if(!native) return E_NOT_SUPPORTED; + auto from = resolve(from_path); + if(failed(from)) return from.errcode(); + auto to = native->resolve(to_path); + if(failed(to)) return to.errcode(); + return Luna::copy_file(from.get().c_str(), to.get().c_str()); + } + RV NativeFileSystem::move_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system, FileMoveFlag flags) + { + Ref destination(to_file_system ? to_file_system : this); + auto native = destination.as(); + if(!native) return E_NOT_SUPPORTED; + auto from = resolve(from_path); + if(failed(from)) return from.errcode(); + auto to = native->resolve(to_path); + if(failed(to)) return to.errcode(); + return Luna::move_file(from.get().c_str(), to.get().c_str(), flags); + } + RV NativeFileSystem::delete_file(const Path& path) + { + auto native = resolve(path); + if(failed(native)) return native.errcode(); + return Luna::delete_file(native.get().c_str()); + } + RV NativeFileSystem::create_dir(const Path& path) + { + auto native = resolve(path); + if(failed(native)) return native.errcode(); + return Luna::create_dir(native.get().c_str()); + } + R NativeFileSystem::get_native_path(const Path& path) + { + auto native = resolve(path); + if(failed(native)) return native.errcode(); + return Name(native.get()); + } + LUNA_VFS_API R> new_native_file_system(const c8* native_path) + { + Ref result; + lutry + { + lulet(root, absolute_native_path(native_path)); + lulet(attr, Luna::get_file_attribute(root.encode().c_str())); + if(!test_flags(attr.attributes, FileAttributeFlag::directory)) return E_NOT_DIRECTORY; + auto instance = new_object(); + instance->m_root = move(root); + result = move(instance); + } + lucatchret; + return result; + } +} diff --git a/Modules/Luna/VFS/Source/NativeFileSystem.hpp b/Modules/Luna/VFS/Source/NativeFileSystem.hpp new file mode 100644 index 00000000..825c654d --- /dev/null +++ b/Modules/Luna/VFS/Source/NativeFileSystem.hpp @@ -0,0 +1,30 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file NativeFileSystem.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include "../NativeFileSystem.hpp" +#include "NativeFileSystem.generated.hpp" + +namespace Luna::VFS +{ + struct [[Luna::struct("{9FD7D494-E45D-401C-A8E9-DB67C6484DAC}")]] NativeFileSystem : IFileSystem + { + luiimpl(); + Path m_root; + R resolve(const Path& path); + R> open_file(const Path& path, FileOpenFlag flags, FileCreationMode creation) override; + R get_file_attribute(const Path& path) override; + R> open_dir(const Path& path) override; + RV copy_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system) override; + RV move_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system, FileMoveFlag flags) override; + RV delete_file(const Path& path) override; + RV create_dir(const Path& path) override; + R get_native_path(const Path& path) override; + }; +} diff --git a/Modules/Luna/VFS/Source/NativePakStorage.cpp b/Modules/Luna/VFS/Source/NativePakStorage.cpp new file mode 100644 index 00000000..2a4460b1 --- /dev/null +++ b/Modules/Luna/VFS/Source/NativePakStorage.cpp @@ -0,0 +1,183 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file NativePakStorage.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include "PakFileSystemImpl.hpp" +#include +#include +#include + +namespace Luna::VFS +{ + NativePakStream::~NativePakStream() + { + close_file(); + if(m_delete_on_close) + { + auto result = Luna::delete_file(m_path.c_str()); + if(failed(result) && unwrap_errcode(result.errcode()) != E_NOT_FOUND) + { + log_error("VFS", "Failed to remove incomplete Pak output %s: %s", m_path.c_str(), explain(result.errcode())); + } + } + } + + RV NativePakStream::ensure_file() + { + lutry + { + if(!m_file) + { + auto flags = FileOpenFlag::read; + if(m_writable) flags |= FileOpenFlag::write; + luset(m_file, Luna::open_file(m_path.c_str(), flags, FileCreationMode::open_existing)); + } + luexp(m_file->seek((i64)m_position, SeekMode::begin)); + } + lucatchret; + return ok; + } + + void NativePakStream::close_file() + { + if(m_file && m_writable) m_file->flush(); + m_file = nullptr; + } + + RV NativePakStream::read(void* buffer, usize size, usize* count) + { + if(count) *count = 0; + auto ready = ensure_file(); + if(failed(ready)) return ready; + usize amount = 0; + auto result = m_file->read(buffer, min(size, 1024 * 1024), &amount); + m_position += amount; + if(count) *count = amount; + return result; + } + + RV NativePakStream::write(const void* buffer, usize size, usize* count) + { + if(count) *count = 0; + if(!m_writable) return E_ACCESS_DENIED; + if(size > (u64)I64_MAX - m_position) return E_OUT_OF_RANGE; + auto ready = ensure_file(); + if(failed(ready)) return ready; + usize written = 0; + while(written < size) + { + usize amount = 0; + auto result = m_file->write((const byte_t*)buffer + written, min(size - written, 1024 * 1024), &amount); + written += amount; + m_position += amount; + m_size = max(m_size, m_position); + if(count) *count = written; + if(failed(result)) return result; + if(!amount) return E_IO_ERROR; + } + return ok; + } + + RV NativePakStream::seek(i64 offset, SeekMode mode) + { + u64 base; + switch(mode) + { + case SeekMode::begin: base = 0; break; + case SeekMode::current: base = m_position; break; + case SeekMode::end: base = m_size; break; + default: return E_BAD_ARGUMENTS; + } + if(base > I64_MAX || offset < -(i64)base || (offset > 0 && base > (u64)(I64_MAX - offset))) return E_OUT_OF_RANGE; + m_position = (u64)((i64)base + offset); + return ok; + } + + RV NativePakStream::set_size(u64 size) + { + if(!m_writable) return E_ACCESS_DENIED; + if(size > I64_MAX) return E_OUT_OF_RANGE; + auto ready = ensure_file(); + if(failed(ready)) return ready; + auto result = m_file->set_size(size); + m_size = m_file->get_size(); + return result; + } + + R> NativePakStorage::open_source() + { + Ref result; + lutry + { + lulet(attr, Luna::get_file_attribute(m_path.c_str())); + if(test_flags(attr.attributes, FileAttributeFlag::directory)) return E_IS_DIRECTORY; + lulet(file, Luna::open_file(m_path.c_str(), FileOpenFlag::read, FileCreationMode::open_existing)); + auto stream = new_object(); + stream->m_owner = this; + stream->m_path = m_path; + stream->m_size = file->get_size(); + stream->m_file = move(file); + result = move(stream); + } + lucatchret; + return result; + } + + R> NativePakStorage::create_output() + { + for(u32 attempt = 0; attempt < 16; ++attempt) + { + c8 guid[GUID_STRING_LENGTH + 1]{}; + lupanic_if_failed(encode_guid(random_guid(), guid, GUID_STRING_LENGTH)); + String filename(".luna-pak-"); + filename.append(guid); + filename.append(".tmp"); + Path path(m_path); + path.pop_back(); + path.push_back(Name(filename)); + String encoded = path.encode(); + auto opened = Luna::open_file(encoded.c_str(), FileOpenFlag::read | FileOpenFlag::write, FileCreationMode::create_new); + if(failed(opened)) + { + if(unwrap_errcode(opened.errcode()) == E_ALREADY_EXISTS) continue; + return opened.errcode(); + } + auto stream = new_object(); + stream->m_owner = this; + stream->m_path = move(encoded); + stream->m_file = move(opened.get()); + stream->m_writable = true; + stream->m_delete_on_close = true; + return Ref(stream); + } + return E_ALREADY_EXISTS; + } + + RV NativePakStorage::publish(ISeekableStream* output) + { + Ref reference(output); + auto stream = reference.as(); + if(!stream || stream->m_owner != this) return E_BAD_ARGUMENTS; + // Pak retains this logical stream. Close its native handle while keeping its + // cursor/size so Windows can rename it; reads reopen the selected path on demand. + stream->m_delete_on_close = false; + stream->close_file(); + stream->m_writable = false; + auto published = Luna::move_file(stream->m_path.c_str(), m_path.c_str(), + FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy); + if(failed(published)) + { + return set_error(unwrap_errcode(published.errcode()), "Failed to publish Pak %s; complete output retained at %s: %s", + m_path.c_str(), stream->m_path.c_str(), explain(published.errcode())); + } + stream->m_path = m_path; + // Publication has committed. A later failure to reopen is a read error, not + // a failed publication that could safely retry renaming the old temporary path. + return ok; + } +} diff --git a/Modules/Luna/VFS/Source/PakFileSystem.cpp b/Modules/Luna/VFS/Source/PakFileSystem.cpp new file mode 100644 index 00000000..1e1f9bed --- /dev/null +++ b/Modules/Luna/VFS/Source/PakFileSystem.cpp @@ -0,0 +1,199 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file PakFileSystem.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include +#define LUNA_VFS_API LUNA_EXPORT +#include "PakFileSystemImpl.hpp" +#include "Paths.hpp" + +namespace Luna::VFS +{ + static String pak_path(const Path& path) + { + return path.empty() ? String() : path.encode(); + } + + LUNA_VFS_API R> new_pak_file_system(IPakStorage* storage, Pak::OpenMode mode, const Pak::Options& options) + { + if(!storage) return E_BAD_ARGUMENTS; + if(mode != Pak::OpenMode::read && mode != Pak::OpenMode::read_write) return E_BAD_ARGUMENTS; + Ref result; + lutry + { + auto instance = new_object(); + instance->m_mutex = new_mutex(); + instance->m_storage = storage; + lulet(source, storage->open_source()); + if(!source) return E_BAD_DATA; + luset(instance->m_package, Pak::open_pak(source, mode, options)); + result = move(instance); + } + lucatchret; + return result; + } + + LUNA_VFS_API R> new_pak_file_system(const c8* native_path, Pak::OpenMode mode, const Pak::Options& options) + { + auto path = absolute_native_path(native_path); + if(failed(path)) return path.errcode(); + auto storage = new_object(); + storage->m_path = path.get().encode(); + return new_pak_file_system(storage, mode, options); + } + + RV PakFileSystem::flush() + { + MutexGuard guard(m_mutex); + if(m_open_files) return E_BUSY; + if(m_pending) + { + auto result = m_storage->publish(m_pending); + if(failed(result)) return result; + m_pending = nullptr; + return ok; + } + if(m_package->is_read_only() || !m_package->is_dirty()) return ok; + auto created = m_storage->create_output(); + if(failed(created)) return created.errcode(); + if(!created.get()) return E_BAD_DATA; + auto result = m_package->flush(created.get()); + if(failed(result)) + { + Error error = result.errcode() == E_ERROR_OBJECT ? get_error() : Error(result.errcode(), String(explain(result.errcode()))); + created.get() = nullptr; + get_error() = move(error); + return E_ERROR_OBJECT; + } + m_pending = move(created.get()); + result = m_storage->publish(m_pending); + if(failed(result)) return result; + m_pending = nullptr; + return ok; + } + + R> PakFileSystem::open_file(const Path& path, FileOpenFlag flags, FileCreationMode creation) + { + MutexGuard guard(m_mutex); + Ref result; + lutry + { + luexp(validate_relative_path(path)); + if(m_pending && test_flags(flags, FileOpenFlag::write)) return E_BUSY; + lulet(file, m_package->open_file(pak_path(path).c_str(), flags, creation)); + auto wrapper = new_object(); + wrapper->m_file = move(file); + wrapper->m_file_system = this; + ++m_open_files; + result = move(wrapper); + } + lucatchret; + return result; + } + + R PakFileSystem::get_file_attribute(const Path& path) + { + MutexGuard guard(m_mutex); + auto valid = validate_relative_path(path); + if(failed(valid)) return valid.errcode(); + return m_package->get_file_attribute(pak_path(path).c_str()); + } + + R> PakFileSystem::open_dir(const Path& path) + { + MutexGuard guard(m_mutex); + auto valid = validate_relative_path(path); + if(failed(valid)) return valid.errcode(); + return m_package->open_dir(pak_path(path).c_str()); + } + + RV PakFileSystem::copy_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system) + { + if(to_file_system && to_file_system != this) return E_NOT_SUPPORTED; + MutexGuard guard(m_mutex); + auto from = validate_relative_path(from_path); + if(failed(from)) return from; + auto to = validate_relative_path(to_path); + if(failed(to)) return to; + if(m_pending) return E_BUSY; + return m_package->copy_file(pak_path(from_path).c_str(), pak_path(to_path).c_str()); + } + + RV PakFileSystem::move_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system, FileMoveFlag flags) + { + if(to_file_system && to_file_system != this) return E_NOT_SUPPORTED; + MutexGuard guard(m_mutex); + auto from = validate_relative_path(from_path); + if(failed(from)) return from; + auto to = validate_relative_path(to_path); + if(failed(to)) return to; + if(m_pending) return E_BUSY; + return m_package->move_file(pak_path(from_path).c_str(), pak_path(to_path).c_str(), flags); + } + + RV PakFileSystem::delete_file(const Path& path) + { + MutexGuard guard(m_mutex); + auto valid = validate_relative_path(path); + if(failed(valid)) return valid; + if(m_pending) return E_BUSY; + return m_package->delete_file(pak_path(path).c_str()); + } + + RV PakFileSystem::create_dir(const Path& path) + { + MutexGuard guard(m_mutex); + auto valid = validate_relative_path(path); + if(failed(valid)) return valid; + if(m_pending) return E_BUSY; + return m_package->create_dir(pak_path(path).c_str()); + } + + PakFileSystemFile::~PakFileSystemFile() + { + if(!m_file_system) return; + MutexGuard guard(m_file_system->m_mutex); + m_file = nullptr; + --m_file_system->m_open_files; + } + RV PakFileSystemFile::read(void* buffer, usize size, usize* count) + { + MutexGuard guard(m_file_system->m_mutex); + return m_file->read(buffer, size, count); + } + RV PakFileSystemFile::write(const void* buffer, usize size, usize* count) + { + MutexGuard guard(m_file_system->m_mutex); + return m_file->write(buffer, size, count); + } + R PakFileSystemFile::tell() + { + MutexGuard guard(m_file_system->m_mutex); + return m_file->tell(); + } + RV PakFileSystemFile::seek(i64 offset, SeekMode mode) + { + MutexGuard guard(m_file_system->m_mutex); + return m_file->seek(offset, mode); + } + u64 PakFileSystemFile::get_size() + { + MutexGuard guard(m_file_system->m_mutex); + return m_file->get_size(); + } + RV PakFileSystemFile::set_size(u64 size) + { + MutexGuard guard(m_file_system->m_mutex); + return m_file->set_size(size); + } + void PakFileSystemFile::flush() + { + MutexGuard guard(m_file_system->m_mutex); + m_file->flush(); + } +} diff --git a/Modules/Luna/VFS/Source/PakFileSystemImpl.hpp b/Modules/Luna/VFS/Source/PakFileSystemImpl.hpp new file mode 100644 index 00000000..db5e9921 --- /dev/null +++ b/Modules/Luna/VFS/Source/PakFileSystemImpl.hpp @@ -0,0 +1,77 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file PakFileSystemImpl.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include "../PakFileSystem.hpp" +#include +#include "PakFileSystemImpl.generated.hpp" + +namespace Luna::VFS +{ + struct NativePakStorage; + struct [[Luna::struct("{D410CF9C-1D2A-4696-BFBD-CC728DEC0B9D}")]] NativePakStream : ISeekableStream + { + luiimpl(); + NativePakStorage* m_owner = nullptr; + String m_path; + Ref m_file; + u64 m_position = 0; + u64 m_size = 0; + bool m_writable = false; + bool m_delete_on_close = false; + ~NativePakStream(); + RV ensure_file(); + void close_file(); + RV read(void* buffer, usize size, usize* count) override; + RV write(const void* buffer, usize size, usize* count) override; + R tell() override { return m_position; } + RV seek(i64 offset, SeekMode mode) override; + u64 get_size() override { return m_size; } + RV set_size(u64 size) override; + }; + struct [[Luna::struct("{D0F5F0C4-C056-49F4-A064-DDDC585A1314}")]] NativePakStorage : IPakStorage + { + luiimpl(); + String m_path; + R> open_source() override; + R> create_output() override; + RV publish(ISeekableStream* output) override; + }; + struct [[Luna::struct("{C7A7E0F2-C07C-42D9-B034-0EFE4A014583}")]] PakFileSystem : IFileSystem + { + luiimpl(); + Ref m_mutex; + Ref m_storage; + Ref m_package; + Ref m_pending; + usize m_open_files = 0; + R> open_file(const Path& path, FileOpenFlag flags, FileCreationMode creation) override; + R get_file_attribute(const Path& path) override; + R> open_dir(const Path& path) override; + RV copy_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system) override; + RV move_file(const Path& from_path, const Path& to_path, IFileSystem* to_file_system, FileMoveFlag flags) override; + RV delete_file(const Path& path) override; + RV create_dir(const Path& path) override; + RV flush() override; + }; + struct [[Luna::struct("{AE9D3960-CAA5-4D95-B9E3-68D011D0AB14}")]] PakFileSystemFile : IFile + { + luiimpl(); + Ref m_file_system; + Ref m_file; + ~PakFileSystemFile(); + RV read(void* buffer, usize size, usize* count) override; + RV write(const void* buffer, usize size, usize* count) override; + R tell() override; + RV seek(i64 offset, SeekMode mode) override; + u64 get_size() override; + RV set_size(u64 size) override; + void flush() override; + }; +} diff --git a/Modules/Luna/VFS/Source/Paths.cpp b/Modules/Luna/VFS/Source/Paths.cpp new file mode 100644 index 00000000..ef737d38 --- /dev/null +++ b/Modules/Luna/VFS/Source/Paths.cpp @@ -0,0 +1,49 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Paths.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include "Paths.hpp" +#include + +namespace Luna::VFS +{ + RV validate_path(const Path& path) + { + if((u32)path.flags() & ~(u32)PathFlag::absolute) return E_BAD_ARGUMENTS; + for(const auto& node : path) + { + if(node.empty() || strlen(node.c_str()) != node.size() || !strcmp(node.c_str(), ".") || + !strcmp(node.c_str(), "..") || strchr(node.c_str(), '/') || strchr(node.c_str(), '\\')) return E_BAD_ARGUMENTS; + } + return ok; + } + + RV validate_relative_path(const Path& path) + { + if(!path.root().empty() || path.flags() != PathFlag::none) return E_BAD_ARGUMENTS; + return validate_path(path); + } + + R absolute_native_path(const c8* native_path) + { + if(!native_path || !native_path[0]) return E_BAD_ARGUMENTS; + Path path(native_path); + if(!test_flags(path.flags(), PathFlag::absolute)) + { + if(!path.root().empty()) return E_BAD_ARGUMENTS; + auto cwd = get_current_dir(); + if(!cwd) return E_BAD_PLATFORM_CALL; + Path absolute(cwd); + release_current_dir(cwd); + absolute.append(path); + absolute.normalize(); + path = move(absolute); + } + return path; + } +} diff --git a/Modules/Luna/VFS/Source/Paths.hpp b/Modules/Luna/VFS/Source/Paths.hpp new file mode 100644 index 00000000..bf5e98c1 --- /dev/null +++ b/Modules/Luna/VFS/Source/Paths.hpp @@ -0,0 +1,19 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Paths.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include +#include + +namespace Luna::VFS +{ + RV validate_path(const Path& path); + RV validate_relative_path(const Path& path); + R absolute_native_path(const c8* path); +} diff --git a/Modules/Luna/VFS/Source/VFS.cpp b/Modules/Luna/VFS/Source/VFS.cpp index 5e47eb6d..9ebfd25e 100644 --- a/Modules/Luna/VFS/Source/VFS.cpp +++ b/Modules/Luna/VFS/Source/VFS.cpp @@ -2,7 +2,7 @@ * This file is a portion of LunaSDK. * For conditions of distribution and use, see the disclaimer * and license in LICENSE.txt -* +* * @file VFS.cpp * @author JXMaster * @date 2022/5/24 @@ -10,328 +10,416 @@ #include #define LUNA_VFS_API LUNA_EXPORT #include "VFS.hpp" -#include -#include +#include "VFS.meta.generated.hpp" #include -#include "Drivers/PlatformFSDriver.hpp" +#include +#include "Paths.hpp" +#include -namespace Luna +namespace Luna::VFS { - namespace VFS + static Vector> g_mounts; + static Ref g_mutex; + static bool g_closing = false; + + static Error capture_error(ResultCode code) + { + if(code == E_ERROR_OBJECT) return get_error(); + return Error(code, String(explain(code))); + } + + LUNA_VFS_API RV mount(IFileSystem* file_system, const Path& mount_path) { - HashMap> g_drivers; - Ref g_driver_mutex; - Vector g_mounts; - Ref g_mounts_mutex; - LUNA_VFS_API void register_driver(const Name& name, const DriverDesc& desc) + if(!g_mutex) return E_BAD_CALLING_TIME; + if(!file_system) return E_BAD_ARGUMENTS; + MutexGuard guard(g_mutex); + if(g_closing) return E_BAD_CALLING_TIME; + lutry { - MutexGuard guard(g_driver_mutex); - auto iter = g_drivers.find(name); - // If the driver is registered, unregister it first. - if (iter != g_drivers.end()) + luexp(validate_path(mount_path)); + for(const auto& existing : g_mounts) { - if(iter->second.get()->on_driver_unregister) iter->second.get()->on_driver_unregister(iter->second.get()->driver_data); - g_drivers.erase(iter); + if(existing->m_mount_path == mount_path) return E_ALREADY_EXISTS; } - UniquePtr d(memnew()); - *(d.get()) = desc; - g_drivers.insert(make_pair(name, move(d))); + auto device = new_object(); + device->m_mutex = new_mutex(); + device->m_mount_path = mount_path; + device->m_file_system = file_system; + g_mounts.push_back(move(device)); } + lucatchret; + return ok; + } - inline DriverDesc* find_driver(const Name& driver) + static R find_mount(const Path& path) + { + if(g_closing) return E_BAD_CALLING_TIME; + auto valid = validate_path(path); + if(failed(valid)) return valid.errcode(); + for(usize i = 0; i < g_mounts.size(); ++i) { - MutexGuard guard(g_driver_mutex); - auto iter = g_drivers.find(driver); - if (iter == g_drivers.end()) return nullptr; - return iter->second.get(); + if(g_mounts[i]->m_mount_path == path) return i; } + return E_NOT_FOUND; + } - LUNA_VFS_API RV mount(const Name& driver, const c8* driver_path, const Path& mount_path, - typeinfo_t params_type, void* params_data) + static Vector> unique_file_systems() + { + Vector> result; + for(const auto& mount : g_mounts) { - MutexGuard guard(g_mounts_mutex); - DriverDesc* d = find_driver(driver); - if (!d) return VFS::E_DRIVER_NOT_FOUND; - MountPair p; - p.m_mount_path = mount_path; - p.m_driver = d; - for (auto& i : g_mounts) + bool found = false; + for(const auto& existing : result) { - if (p.m_mount_path == i.m_mount_path) + if(existing == mount->m_file_system) { - return E_ALREADY_EXISTS; + found = true; + break; } } - lutry - { - luset(p.m_mount_data, d->on_mount(d->driver_data, driver_path, mount_path, params_type, params_data)); - } - lucatchret; - g_mounts.push_back(move(p)); - return ok; + if(!found) result.push_back(mount->m_file_system); } - LUNA_VFS_API RV unmount(const Path& mount_path) + return result; + } + + LUNA_VFS_API RV flush_all() + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + if(g_closing) return E_BAD_CALLING_TIME; + Error first_error; + for(const auto& file_system : unique_file_systems()) { - MutexGuard guard(g_mounts_mutex); - auto iter = g_mounts.begin(); - while (iter != g_mounts.end()) - { - if (iter->m_mount_path == mount_path) - { - lutry - { - luexp(iter->m_driver->on_unmount(iter->m_driver->driver_data, iter->m_mount_data)); - } - lucatchret; - g_mounts.erase(iter); - return ok; - } - } - return E_NOT_FOUND; + auto result = file_system->flush(); + if(failed(result) && succeeded(first_error.code)) first_error = capture_error(result.errcode()); + } + if(failed(first_error.code)) + { + get_error() = move(first_error); + return E_ERROR_OBJECT; + } + return ok; + } + + LUNA_VFS_API RV unmount(const Path& mount_path) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + lutry + { + lulet(index, find_mount(mount_path)); + auto device = g_mounts[index]; + MutexGuard device_guard(device->m_mutex); + if(!device->m_files.empty() || !device->m_iterators.empty()) return E_BUSY; + luexp(device->m_file_system->flush()); + g_mounts.erase(g_mounts.begin() + index); } - LUNA_VFS_API RV remount(const Path& from_path, const Path& to_path) + lucatchret; + return ok; + } + + LUNA_VFS_API RV remount(const Path& from_path, const Path& to_path) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + lutry { - MutexGuard guard(g_mounts_mutex); - auto iter = g_mounts.begin(); - while (iter != g_mounts.end()) + lulet(index, find_mount(from_path)); + luexp(validate_path(to_path)); + if(from_path == to_path) return ok; + for(const auto& device : g_mounts) { - if (iter->m_mount_path == from_path) - { - iter->m_mount_path = to_path; - return ok; - } + if(device->m_mount_path == to_path) return E_ALREADY_EXISTS; } - return E_NOT_FOUND; + g_mounts[index]->m_mount_path = to_path; + } + lucatchret; + return ok; + } + + static R> route_path(const Path& path, Path& relative) + { + if(g_closing) return E_BAD_CALLING_TIME; + auto valid = validate_path(path); + if(failed(valid)) return valid.errcode(); + Ref best; + for(const auto& device : g_mounts) + { + const auto& base = device->m_mount_path; + if(path.root() != base.root() || path.flags() != base.flags()) continue; + if(path.is_subpath_of(base) && (!best || base.size() > best->m_mount_path.size())) best = device; } - static R route_path(const Path& filename, Path& relative_path) + if(!best) return E_NOT_FOUND; + relative.assign_relative(best->m_mount_path, path); + relative.root().reset(); + return best; + } + + static RV copy_between_file_systems(IFileSystem* source, IFileSystem* destination, const Path& from_path, const Path& to_path) + { + Ref input; + Ref output; + bool created = false; + lutry { - auto iter = g_mounts.rbegin(); - while (iter != g_mounts.rend()) + lulet(attr, source->get_file_attribute(from_path)); + if(test_flags(attr.attributes, FileAttributeFlag::directory)) return E_IS_DIRECTORY; + luset(input, source->open_file(from_path, + FileOpenFlag::read, FileCreationMode::open_existing)); + if(!input) luthrow(E_BAD_DATA); + luset(output, destination->open_file(to_path, + FileOpenFlag::write, FileCreationMode::create_new)); + if(!output) luthrow(E_BAD_DATA); + created = true; + byte_t buffer[65536]; + u64 remaining = input->get_size(); + while(remaining) { - if (filename.is_subpath_of(iter->m_mount_path)) + usize count = 0; + usize amount = (usize)min(remaining, sizeof(buffer)); + luexp(input->read(buffer, amount, &count)); + if(!count || count > amount) luthrow(E_BAD_DATA); + remaining -= count; + usize written = 0; + while(written < count) { - // matched. - auto driver = iter->m_driver; - relative_path = Path(); - relative_path.assign_relative(iter->m_mount_path, filename); - return *iter; - } - else - { - ++iter; + usize progress = 0; + luexp(output->write(buffer + written, count - written, &progress)); + if(!progress || progress > count - written) luthrow(E_IO_ERROR); + written += progress; } } - // Not found. - return E_NOT_FOUND; + usize count = 0; + luexp(input->read(buffer, 1, &count)); + if(count) luthrow(E_BAD_DATA); } - static RV copy_file_between_driver(MountPair* from, MountPair* to, const Path& from_path, const Path& to_path) + lucatch { - // try open file. - Ref from_file; - Ref to_file; - u64 file_sz; - lutry + Error error = capture_error(luerr); + input = nullptr; + output = nullptr; + if(created) { - luset(from_file, from->m_driver->on_open_file(from->m_driver->driver_data, from->m_mount_data, from_path, FileOpenFlag::read, FileCreationMode::open_existing)); - luset(to_file, to->m_driver->on_open_file(to->m_driver->driver_data, to->m_mount_data, to_path, FileOpenFlag::write, FileCreationMode::create_new)); - file_sz = from_file->get_size(); - luexp(to_file->set_size(file_sz)); - // preparing buffer. - void* alloc_buf = nullptr; - usize alloc_size = max(file_sz, 16_mb); - alloc_buf = memalloc(alloc_size); - if (!alloc_buf) - { - luthrow(E_OUT_OF_MEMORY); - } - Blob buf; - buf.attach((byte_t*)alloc_buf, alloc_size, 0); - if (file_sz >= 16_mb) + auto cleanup = destination->delete_file(to_path); + if(failed(cleanup) && unwrap_errcode(cleanup.errcode()) != E_NOT_FOUND) { - for (u64 i = 0; i < file_sz; i += 16_mb) - { - usize bytes_to_read = min(16_mb, file_sz - i); - usize bytes_read; - luexp(from_file->read(buf.data(), bytes_to_read, &bytes_read)); - luassert(bytes_to_read == bytes_read); - luexp(to_file->write(buf.data(), bytes_to_read, &bytes_read)); - luassert(bytes_to_read == bytes_read); - } + error.message.append("; failed to remove the partial destination: "); + error.message.append(explain(cleanup.errcode())); } - else - { - usize bytes_to_read = (usize)file_sz; - usize bytes_read; - luexp(from_file->read(buf.data(), bytes_to_read, &bytes_read)); - luassert(bytes_to_read == bytes_read); - luexp(to_file->write(buf.data(), bytes_to_read, &bytes_read)); - luassert(bytes_to_read == bytes_read); - } - } - lucatch - { - from_file = nullptr; - to_file = nullptr; - auto _ = to->m_driver->on_delete_file(to->m_driver->driver_data, to->m_mount_data, to_path); - return luerr; } - return ok; + get_error() = move(error); + return E_ERROR_OBJECT; } - LUNA_VFS_API R> open_file(const Path& path, FileOpenFlag flags, FileCreationMode creation) + return ok; + } + + LUNA_VFS_API R> open_file(const Path& path, FileOpenFlag flags, FileCreationMode creation) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + Ref result; + lutry { - MutexGuard _guard(g_mounts_mutex); - Path relative_path; - Ref ret; - lutry - { - lulet(mnt, route_path(path, relative_path)); - luset(ret, mnt.m_driver->on_open_file(mnt.m_driver->driver_data, mnt.m_mount_data, relative_path, flags, creation)); - } - lucatchret; - return ret; + Path relative; + lulet(device, route_path(path, relative)); + MutexGuard device_guard(device->m_mutex); + lulet(file, device->m_file_system->open_file(relative, flags, creation)); + if(!file) return E_BAD_DATA; + auto wrapped = new_object(); + wrapped->m_mutex = device->m_mutex; + wrapped->m_mount = device; + wrapped->m_file = move(file); + device->m_files.push_back(wrapped.get()); + result = move(wrapped); } - LUNA_VFS_API R get_file_attribute(const Path& path) + lucatchret; + return result; + } + + LUNA_VFS_API R get_file_attribute(const Path& path) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + FileAttribute result{}; + lutry { - MutexGuard _guard(g_mounts_mutex); - Path relative_path; - FileAttribute ret; - lutry - { - lulet(mnt, route_path(path, relative_path)); - luset(ret, mnt.m_driver->on_get_file_attribute(mnt.m_driver->driver_data, mnt.m_mount_data, relative_path)); - } - lucatchret; - return ret; + Path relative; + lulet(device, route_path(path, relative)); + MutexGuard device_guard(device->m_mutex); + luset(result, device->m_file_system->get_file_attribute(relative)); } - LUNA_VFS_API RV copy_file(const Path& from_file_path, const Path& to_file_path) + lucatchret; + return result; + } + + LUNA_VFS_API RV copy_file(const Path& from_path, const Path& to_path) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + lutry { - MutexGuard _guard(g_mounts_mutex); - Path from_path; - Path to_path; - lutry - { - lulet(from, route_path(from_file_path, from_path)); - lulet(to, route_path(to_file_path, to_path)); - if (from.m_driver == to.m_driver) - { - return from.m_driver->on_copy_file(from.m_driver->driver_data, from.m_mount_data, to.m_mount_data, from_path, to_path); - } - // Force copy. - luexp(copy_file_between_driver(&from, &to, from_path, to_path)); - } - lucatchret; - return ok; + Path source_path, destination_path; + lulet(from, route_path(from_path, source_path)); + lulet(to, route_path(to_path, destination_path)); + MutexGuard source_guard(from->m_mutex); + MutexGuard destination_guard(to->m_mutex); + auto result = from->m_file_system->copy_file(source_path, destination_path, to->m_file_system); + if(succeeded(result) || unwrap_errcode(result.errcode()) != E_NOT_SUPPORTED) return result; + luexp(copy_between_file_systems(from->m_file_system, to->m_file_system, source_path, destination_path)); } - LUNA_VFS_API RV move_file(const Path& from_file_path, const Path& to_file_path) + lucatchret; + return ok; + } + + LUNA_VFS_API RV move_file(const Path& from_path, const Path& to_path, FileMoveFlag flags) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + if((flags & ~(FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy)) != FileMoveFlag::none) return E_BAD_ARGUMENTS; + MutexGuard guard(g_mutex); + lutry { - MutexGuard _guard(g_mounts_mutex); - Path from_path; - Path to_path; - lutry + Path source_path, destination_path; + lulet(from, route_path(from_path, source_path)); + lulet(to, route_path(to_path, destination_path)); + MutexGuard source_guard(from->m_mutex); + MutexGuard destination_guard(to->m_mutex); + auto result = from->m_file_system->move_file(source_path, destination_path, to->m_file_system, flags); + if(succeeded(result) || unwrap_errcode(result.errcode()) != E_NOT_SUPPORTED) return result; + if(test_flags(flags, FileMoveFlag::no_copy)) return result; + if(test_flags(flags, FileMoveFlag::allow_overwrite)) { - lulet(from, route_path(from_file_path, from_path)); - lulet(to, route_path(to_file_path, to_path)); - if (from.m_driver == to.m_driver) - { - return from.m_driver->on_move_file(from.m_driver->driver_data, from.m_mount_data, to.m_mount_data, from_path, to_path); - } - // copy and delete. - luexp(copy_file_between_driver(&from, &to, from_path, to_path)); - luexp(from.m_driver->on_delete_file(from.m_driver->driver_data, from.m_mount_data, from_path)); + auto destination = to->m_file_system->get_file_attribute(destination_path); + if(succeeded(destination)) return E_NOT_SUPPORTED; + if(unwrap_errcode(destination.errcode()) != E_NOT_FOUND) return destination.errcode(); } - lucatchret; - return ok; + luexp(copy_between_file_systems(from->m_file_system, to->m_file_system, source_path, destination_path)); + luexp(from->m_file_system->delete_file(source_path)); } - LUNA_VFS_API RV delete_file(const Path& file_path) + lucatchret; + return ok; + } + + LUNA_VFS_API RV delete_file(const Path& path) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + lutry { - MutexGuard _guard(g_mounts_mutex); - Path relative_path; - lutry - { - lulet(mnt, route_path(file_path, relative_path)); - luexp(mnt.m_driver->on_delete_file(mnt.m_driver->driver_data, mnt.m_mount_data, relative_path)); - } - lucatchret; - return ok; + Path relative; + lulet(device, route_path(path, relative)); + MutexGuard device_guard(device->m_mutex); + luexp(device->m_file_system->delete_file(relative)); } - LUNA_VFS_API R> open_dir(const Path& dir_path) + lucatchret; + return ok; + } + + LUNA_VFS_API R> open_dir(const Path& path) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + Ref result; + lutry { - MutexGuard _guard(g_mounts_mutex); - Path relative_path; - Ref ret; - lutry - { - lulet(mnt, route_path(dir_path, relative_path)); - luset(ret, mnt.m_driver->on_open_dir(mnt.m_driver->driver_data, mnt.m_mount_data, relative_path)); - } - lucatchret; - return ret; + Path relative; + lulet(device, route_path(path, relative)); + MutexGuard device_guard(device->m_mutex); + lulet(iterator, device->m_file_system->open_dir(relative)); + if(!iterator) return E_BAD_DATA; + auto wrapped = new_object(); + wrapped->m_mutex = device->m_mutex; + wrapped->m_mount = device; + wrapped->m_iterator = move(iterator); + device->m_iterators.push_back(wrapped.get()); + result = move(wrapped); } - LUNA_VFS_API RV create_dir(const Path& dir_path) + lucatchret; + return result; + } + + LUNA_VFS_API RV create_dir(const Path& path) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + lutry { - MutexGuard _guard(g_mounts_mutex); - Path relative_path; - lutry - { - lulet(mnt, route_path(dir_path, relative_path)); - luexp(mnt.m_driver->on_create_dir(mnt.m_driver->driver_data, mnt.m_mount_data, relative_path)); - } - lucatchret; - return ok; + Path relative; + lulet(device, route_path(path, relative)); + MutexGuard device_guard(device->m_mutex); + luexp(device->m_file_system->create_dir(relative)); } - LUNA_VFS_API R get_native_path(const Path& vfs_path) + lucatchret; + return ok; + } + + LUNA_VFS_API R get_native_path(const Path& path) + { + if(!g_mutex) return E_BAD_CALLING_TIME; + MutexGuard guard(g_mutex); + Name result; + lutry { - MutexGuard _guard(g_mounts_mutex); - Path relative_path; - Name ret; - lutry - { - lulet(mnt, route_path(vfs_path, relative_path)); - luset(ret, mnt.m_driver->on_get_native_path(mnt.m_driver->driver_data, mnt.m_mount_data, relative_path)); - } - lucatchret; - return ret; + Path relative; + lulet(device, route_path(path, relative)); + MutexGuard device_guard(device->m_mutex); + luset(result, device->m_file_system->get_native_path(relative)); } + lucatchret; + return result; + } - struct VFSModule : public Module + struct VFSModule : Module + { + const c8* get_name() override { return "VFS"; } + RV on_register() override + { + return add_dependency_module(this, module_pak()); + } + RV on_init() override + { + Meta::register_VFS_types(); + g_closing = false; + g_mutex = new_mutex(); + return ok; + } + void on_close() override { - virtual const c8* get_name() override { return "VFS"; } - virtual RV on_register() override + if(!g_mutex) return; + MutexGuard guard(g_mutex); + g_closing = true; + for(auto& device : g_mounts) { - if (!register_error_category(ERROR_CATEGORY, "VFS") || - !register_error_code(VFS::E_DRIVER_NOT_FOUND, "driver_not_found", "The specified VFS driver was not found.")) + MutexGuard device_guard(device->m_mutex); + while(!device->m_files.empty()) { - return set_error(E_ALREADY_EXISTS, "VFS error metadata conflicts with an existing registration."); + auto file = device->m_files.back(); + device->m_files.pop_back(); + file->invalidate(); } - return ok; - } - virtual RV on_init() override - { - g_driver_mutex = new_mutex(); - g_mounts_mutex = new_mutex(); - register_platform_filesystem_driver(); - return ok; - } - virtual void on_close() override - { - g_mounts.clear(); - g_mounts.shrink_to_fit(); - g_mounts_mutex = nullptr; - for (auto& i : g_drivers) + while(!device->m_iterators.empty()) { - if (i.second.get()->on_driver_unregister) i.second.get()->on_driver_unregister(i.second.get()->driver_data); + auto iterator = device->m_iterators.back(); + device->m_iterators.pop_back(); + iterator->invalidate(); } - g_drivers.clear(); - g_drivers.shrink_to_fit(); - g_driver_mutex = nullptr; } - }; - } + for(const auto& file_system : unique_file_systems()) + { + auto flushed = file_system->flush(); + if(failed(flushed)) log_error("VFS", "Failed to flush a filesystem at shutdown: %s", explain(flushed.errcode())); + } + g_mounts.clear(); + g_mounts.shrink_to_fit(); + g_mutex = nullptr; + } + }; +} +namespace Luna +{ LUNA_VFS_API Module* module_vfs() { - static VFS::VFSModule m; - return &m; + static VFS::VFSModule module; + return &module; } - } diff --git a/Modules/Luna/VFS/Source/VFS.hpp b/Modules/Luna/VFS/Source/VFS.hpp index 2c5e5a94..ebcfbb4e 100644 --- a/Modules/Luna/VFS/Source/VFS.hpp +++ b/Modules/Luna/VFS/Source/VFS.hpp @@ -9,18 +9,51 @@ */ #pragma once #include "../VFS.hpp" -#include "../Driver.hpp" +#include +#include "VFS.generated.hpp" namespace Luna { namespace VFS { - // Mount path. - struct MountPair + struct MountedFile; + struct MountedIterator; + struct [[Luna::struct("{B343C178-0CFE-47B5-B20F-E3BD4687A015}")]] Mount { + Ref m_mutex; Path m_mount_path; - DriverDesc* m_driver; - void* m_mount_data; + Ref m_file_system; + Vector m_files; + Vector m_iterators; + }; + struct [[Luna::struct("{0B4F9060-E0D2-4696-BC40-AC9CB8A1C2B2}")]] MountedFile : IFile + { + luiimpl(); + Ref m_mutex; + Ref m_mount; + Ref m_file; + ~MountedFile(); + void invalidate(); + RV read(void* buffer, usize size, usize* count) override; + RV write(const void* buffer, usize size, usize* count) override; + R tell() override; + RV seek(i64 offset, SeekMode mode) override; + u64 get_size() override; + RV set_size(u64 size) override; + void flush() override; + }; + struct [[Luna::struct("{FC45F8A4-2465-4767-97B8-3A24591707C0}")]] MountedIterator : IFileIterator + { + luiimpl(); + Ref m_mutex; + Ref m_mount; + Ref m_iterator; + ~MountedIterator(); + void invalidate(); + bool is_valid() override; + const c8* get_filename() override; + FileAttributeFlag get_attributes() override; + bool move_next() override; }; } -} \ No newline at end of file +} diff --git a/Modules/Luna/VFS/VFS.Target.cs b/Modules/Luna/VFS/VFS.Target.cs index 17e54803..d29b1113 100644 --- a/Modules/Luna/VFS/VFS.Target.cs +++ b/Modules/Luna/VFS/VFS.Target.cs @@ -9,7 +9,9 @@ public VFSTargetRules() rulesPath: "Modules/Luna/VFS/VFS.Target.cs") { Headers("*.hpp", "Source/**.hpp"); + MetaHeaders("FileSystem.hpp", "PakFileSystem.hpp", "Source/VFS.hpp", + "Source/NativeFileSystem.hpp", "Source/PakFileSystemImpl.hpp"); Sources("Source/**.cpp"); - DependsOn("Runtime"); + DependsOn("Runtime", "Pak"); } } diff --git a/Modules/Luna/VFS/VFS.hpp b/Modules/Luna/VFS/VFS.hpp index c5f99054..445badf7 100644 --- a/Modules/Luna/VFS/VFS.hpp +++ b/Modules/Luna/VFS/VFS.hpp @@ -9,8 +9,7 @@ * @brief The virtual file system API. */ #pragma once -#include -#include +#include "FileSystem.hpp" #ifndef LUNA_VFS_API #define LUNA_VFS_API @@ -23,26 +22,34 @@ namespace Luna //! @addtogroup VFS VFS //! Virtual file system (VFS) module provides a virtual and uniform file system across all platforms, so that //! the user can use one uniform path and API to open one file on all platforms without caring where the file - //! is actually in. VFS supports user-implemented file system drivers and devices, which allows the file system - //! to be extended to support reading files from zip archives, online server, etc. + //! is actually in. VFS mounts independently created IFileSystem instances to + //! support native directories, packages and application-defined storage. //! @{ - //! Mounts one virtual file device as one directory in the virtual file system. - //! @param[in] driver The name of the VFS driver for the virtual file device. - //! @param[in] driver_path The path passed to the driver, which is usually the native path mapped to the mount path. + //! Mounts an existing filesystem instance as a directory in VFS. + //! @param[in] file_system The filesystem instance to retain. Must not be null. //! @param[in] mount_path The directory used as the root directory of the mounted file device. - //! @param[in] params_type The type of the additional driver parameter object. See driver docs for details. - //! @param[in] params_data The pointer to the additional driver parameter object. See driver docs for details. - LUNA_VFS_API RV mount(const Name& driver, const c8* driver_path, const Path& mount_path, - typeinfo_t params_type = nullptr, void* params_data = nullptr); + //! @details No storage is created or opened here. One instance may have multiple + //! mount aliases. Routing uses the longest matching prefix in the same root and + //! absolute/relative namespace. Filesystem methods invoked by VFS must not reenter VFS. + LUNA_VFS_API RV mount(IFileSystem* file_system, const Path& mount_path); //! Unmounts the virtual file device in the mounting directory. //! @param[in] mount_path The mounting directory specified when mounting or remounting the file device. + //! @details Requires all files and directory iterators of this mount to be released. + //! Calls IFileSystem::flush before removing the mount; errors retain it for retry. + //! The filesystem remains usable through other mounts or caller-held references. LUNA_VFS_API RV unmount(const Path& mount_path); + //! Calls IFileSystem::flush once for each distinct mounted instance and returns + //! the first failure with its diagnostic preserved. Later instances are still + //! attempted after a failure. Flush a specific instance directly through its interface. + LUNA_VFS_API RV flush_all(); + //! Changes the mounting directory of the file device. //! @param[in] from_path The current mounting directory of the device. //! @param[in] to_path The new mounting directory to change to. + //! @details An occupied destination returns E_ALREADY_EXISTS. LUNA_VFS_API RV remount(const Path& from_path, const Path& to_path); //! Opens one file. @@ -86,7 +93,10 @@ namespace Luna //! * E_ACCESS_DENIED //! * E_NOT_FOUND //! * E_BAD_PLATFORM_CALL for all errors that cannot be identified. - LUNA_VFS_API RV move_file(const Path& from_path, const Path& to_path); + //! @param[in] flags Controls file replacement and copy fallback. The default rejects existing destinations. + //! @details no_copy forbids streaming fallback. Unsupported replacement of an existing destination + //! returns E_NOT_SUPPORTED without deleting it. Replacement requires compatible filesystem implementations. + LUNA_VFS_API RV move_file(const Path& from_path, const Path& to_path, FileMoveFlag flags = FileMoveFlag::none); //! Deletes the specified file or directory //! @param[in] path The file to delete. //! If this specifies one directory, the directory must be empty. @@ -112,34 +122,12 @@ namespace Luna //! * E_BAD_PLATFORM_CALL for all errors that cannot be identified. LUNA_VFS_API RV create_dir(const Path& path); - //! Translates one VFS path to one native driver path. + //! Translates one VFS path to one native storage path. //! @param[in] vfs_path The virtual file system path to translate. - //! @return Returns the translated native path of the virtual path. The translated path is driver-specific. + //! @return Returns the translated native path, or E_NOT_SUPPORTED if the + //! filesystem entry has no independent native path. LUNA_VFS_API R get_native_path(const Path& vfs_path); - //! Gets the name of the VFS driver that maps platform's native file system to vritual file system. - //! @return Returns the name of the platform's native file system driver. - LUNA_VFS_API Name get_platform_filesystem_driver(); - - //! @} - } - - //! @addtogroup VFS - //! @{ - //! @defgroup VFSResultCodes VFS Result Codes - //! @} - - namespace VFS - { - //! @addtogroup VFSResultCodes - //! @{ - - //! The VFS error category identifier. - inline constexpr errcat_t ERROR_CATEGORY = make_error_category(ErrorDomain::LUNA_SDK, LunaErrorCategory::VFS); - - //! The specified VFS driver is not found. - inline constexpr ResultCode E_DRIVER_NOT_FOUND = make_error_code(ErrorDomain::LUNA_SDK, LunaErrorCategory::VFS, -1); - //! @} } diff --git a/Modules/Luna/VG/Source/ShapeDrawList.cpp b/Modules/Luna/VG/Source/ShapeDrawList.cpp index a0172f2b..7ec04826 100644 --- a/Modules/Luna/VG/Source/ShapeDrawList.cpp +++ b/Modules/Luna/VG/Source/ShapeDrawList.cpp @@ -22,10 +22,14 @@ namespace Luna ShapeDrawCall& ShapeDrawList::get_current_draw_call() { - if (m_draw_call_dirty || m_draw_calls.empty()) + IShapeBuffer* shape_buffer = get_effective_shape_buffer(); + if (m_force_new_draw_call || m_draw_calls.empty() || + m_draw_call_buffers.back() != shape_buffer || + m_draw_calls.back().texture != m_texture || + !sampler_desc_equal(m_draw_calls.back().sampler, m_sampler)) { - new_draw_call(); - m_draw_call_dirty = false; + new_draw_call(shape_buffer); + m_force_new_draw_call = false; } return m_draw_calls.back(); } @@ -74,7 +78,7 @@ namespace Luna m_rounded_clip_radii = Float4U(0.0f); m_state_index_dirty = true; m_current_state_index = 0; - m_draw_call_dirty = false; + m_force_new_draw_call = false; } void ShapeDrawList::draw_shape(u32 begin_command, u32 num_commands, const Float2U& min_position, const Float2U& max_position, @@ -134,14 +138,7 @@ namespace Luna } for(usize i = 0; i < m_draw_calls.size(); ++i) { - if(!m_draw_call_buffers[i]) - { - luset(m_draw_calls[i].shape_buffer, m_internal_shape_buffer->build(m_device)); - } - else - { - luset(m_draw_calls[i].shape_buffer, m_draw_call_buffers[i]->build(m_device)); - } + luset(m_draw_calls[i].shape_buffer, m_draw_call_buffers[i]->build(m_device)); } } lucatchret; diff --git a/Modules/Luna/VG/Source/ShapeDrawListImpl.hpp b/Modules/Luna/VG/Source/ShapeDrawListImpl.hpp index 84cb99e3..9961f34d 100644 --- a/Modules/Luna/VG/Source/ShapeDrawListImpl.hpp +++ b/Modules/Luna/VG/Source/ShapeDrawListImpl.hpp @@ -46,13 +46,17 @@ namespace Luna // If `true`, then the current state index must be resolved again. bool m_state_index_dirty; u32 m_current_state_index; - // If `true`, then a new resource draw call must be created. - bool m_draw_call_dirty; + // If `true`, then a new draw call must be created even if the resource state is unchanged. + bool m_force_new_draw_call; - void new_draw_call() + IShapeBuffer* get_effective_shape_buffer() + { + return m_shape_buffer ? m_shape_buffer.get() : m_internal_shape_buffer.get(); + } + void new_draw_call(IShapeBuffer* shape_buffer) { m_draw_calls.emplace_back(); - m_draw_call_buffers.push_back(m_shape_buffer); + m_draw_call_buffers.push_back(shape_buffer); ShapeDrawCall& dc = m_draw_calls.back(); dc.texture = m_texture; dc.sampler = m_sampler; @@ -68,7 +72,23 @@ namespace Luna RHI::TextureAddressMode::repeat, RHI::TextureAddressMode::repeat); } - + static bool sampler_desc_equal(const RHI::SamplerDesc& lhs, const RHI::SamplerDesc& rhs) + { + return lhs.min_filter == rhs.min_filter && + lhs.mag_filter == rhs.mag_filter && + lhs.mip_filter == rhs.mip_filter && + lhs.address_u == rhs.address_u && + lhs.address_v == rhs.address_v && + lhs.address_w == rhs.address_w && + lhs.anisotropy_enable == rhs.anisotropy_enable && + lhs.compare_enable == rhs.compare_enable && + lhs.compare_function == rhs.compare_function && + lhs.border_color == rhs.border_color && + lhs.max_anisotropy == rhs.max_anisotropy && + lhs.min_lod == rhs.min_lod && + lhs.max_lod == rhs.max_lod; + } + ShapeDrawList() : m_instance_buffer_size(0), m_instance_buffer_capacity(0), @@ -81,7 +101,7 @@ namespace Luna m_rounded_clip_radii(0.0f), m_state_index_dirty(true), m_current_state_index(0), - m_draw_call_dirty(false) + m_force_new_draw_call(false) { m_internal_shape_buffer = new_shape_buffer(); } @@ -94,11 +114,7 @@ namespace Luna virtual void set_shape_buffer(IShapeBuffer* shape_buffer) override { lutsassert(); - if (m_shape_buffer != shape_buffer) - { - m_draw_call_dirty = true; - m_shape_buffer = shape_buffer; - } + m_shape_buffer = shape_buffer; } virtual IShapeBuffer* get_shape_buffer() override { @@ -107,11 +123,7 @@ namespace Luna virtual void set_texture(RHI::ITexture* tex) override { lutsassert(); - if (m_texture != tex) - { - m_draw_call_dirty = true; - m_texture = tex; - } + m_texture = tex; } virtual RHI::ITexture* get_texture() override { @@ -129,11 +141,7 @@ namespace Luna { d = *desc; } - if (d != m_sampler) - { - m_draw_call_dirty = true; - m_sampler = d; - } + m_sampler = d; } virtual RHI::SamplerDesc get_sampler() override { @@ -186,7 +194,7 @@ namespace Luna virtual void draw_call_barrier() override { lutsassert(); - m_draw_call_dirty = true; + m_force_new_draw_call = true; } virtual void draw_shape(u32 begin_command, u32 num_commands, const Float2U& min_position, const Float2U& max_position, diff --git a/Modules/Luna/Zip/Source/Archive.cpp b/Modules/Luna/Zip/Source/Archive.cpp new file mode 100644 index 00000000..db974225 --- /dev/null +++ b/Modules/Luna/Zip/Source/Archive.cpp @@ -0,0 +1,568 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Archive.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include +#define LUNA_ZIP_API LUNA_EXPORT +#include "Archive.hpp" +#include +#include +#include +#include +#ifdef _MSC_VER +// libzip's Windows random source uses CryptoAPI. Keep this requirement with +// the object that uses libzip, including when Zip itself is a static archive. +#pragma comment(lib, "advapi32.lib") +#endif + +namespace Luna::Zip +{ + static ResultCode zip_failure(zip_error_t* error) + { + ResultCode code = E_IO_ERROR; + switch(zip_error_code_zip(error)) + { + case ZIP_ER_MEMORY: code = E_OUT_OF_MEMORY; break; + case ZIP_ER_ZLIB: + code = zip_error_code_system(error) == Z_DATA_ERROR ? E_BAD_DATA : E_IO_ERROR; + break; + case ZIP_ER_NOENT: + case ZIP_ER_DELETED: code = E_NOT_FOUND; break; + case ZIP_ER_EXISTS: code = E_ALREADY_EXISTS; break; + case ZIP_ER_INVAL: code = E_BAD_ARGUMENTS; break; + case ZIP_ER_RDONLY: code = E_ACCESS_DENIED; break; + case ZIP_ER_INUSE: code = E_BUSY; break; + case ZIP_ER_ZIPCLOSED: code = E_BAD_CALLING_TIME; break; + case ZIP_ER_MULTIDISK: + case ZIP_ER_COMPNOTSUPP: + case ZIP_ER_ENCRNOTSUPP: + case ZIP_ER_NOPASSWD: + case ZIP_ER_WRONGPASSWD: + case ZIP_ER_OPNOTSUPP: + case ZIP_ER_NOT_ALLOWED: code = E_NOT_SUPPORTED; break; + case ZIP_ER_CRC: + case ZIP_ER_EOF: + case ZIP_ER_NOZIP: + case ZIP_ER_INCONS: + case ZIP_ER_COMPRESSED_DATA: + case ZIP_ER_DATA_LENGTH: + case ZIP_ER_TRUNCATED_ZIP: code = E_BAD_DATA; break; + default: break; + } + return set_error(code, "Zip: %s", zip_error_strerror(error)); + } + + struct StreamSource + { + Ref input; + Ref output; + zip_error_t error; + zip_source_t* native = nullptr; + u64 size = 0; + u64 read_position = 0; + u64 write_position = 0; + u64 write_size = 0; + bool writable = false; + bool accept_empty = false; + bool reading = false; + + StreamSource() { zip_error_init(&error); } + ~StreamSource() { zip_error_fini(&error); } + + zip_int64_t fail(int code) + { + zip_error_set(&error, code, code == ZIP_ER_READ || code == ZIP_ER_WRITE || code == ZIP_ER_SEEK ? EIO : 0); + return -1; + } + + zip_int64_t read(void* buffer, u64 length) + { + length = min(length, size - read_position); + if(!length) return 0; + if(!input || failed(input->seek((i64)read_position, SeekMode::begin))) return fail(ZIP_ER_SEEK); + u64 done = 0; + while(done < length) + { + usize count = 0; + usize request = (usize)min(length - done, 1024 * 1024); + auto result = input->read((byte_t*)buffer + (usize)done, request, &count); + if(failed(result) || count > request) return fail(ZIP_ER_READ); + if(!count) return fail(ZIP_ER_EOF); + done += count; + read_position += count; + } + return (zip_int64_t)done; + } + + zip_int64_t write(const void* buffer, u64 length) + { + if(!output || length > ZIP_INT64_MAX - write_position) return fail(ZIP_ER_WRITE); + if(failed(output->seek((i64)write_position, SeekMode::begin))) return fail(ZIP_ER_SEEK); + u64 done = 0; + while(done < length) + { + usize count = 0; + usize request = (usize)min(length - done, 1024 * 1024); + auto result = output->write((const byte_t*)buffer + (usize)done, request, &count); + if(failed(result) || !count || count > request) return fail(ZIP_ER_WRITE); + done += count; + write_position += count; + write_size = max(write_size, write_position); + } + return (zip_int64_t)done; + } + }; + + static zip_int64_t stream_callback(void* userdata, void* data, zip_uint64_t length, zip_source_cmd_t command) + { + auto source = (StreamSource*)userdata; + switch(command) + { + case ZIP_SOURCE_SUPPORTS: + return (source->writable ? ZIP_SOURCE_SUPPORTS_WRITABLE : ZIP_SOURCE_SUPPORTS_SEEKABLE) + | ZIP_SOURCE_MAKE_COMMAND_BITMASK(ZIP_SOURCE_ACCEPT_EMPTY) + | ZIP_SOURCE_MAKE_COMMAND_BITMASK(ZIP_SOURCE_SUPPORTS_REOPEN); + case ZIP_SOURCE_ACCEPT_EMPTY: return source->accept_empty ? 1 : 0; + case ZIP_SOURCE_SUPPORTS_REOPEN: return 1; + case ZIP_SOURCE_OPEN: + if(source->input && source->input->get_size() != source->size) return source->fail(ZIP_ER_CHANGED); + source->read_position = 0; + source->reading = true; + zip_error_set(&source->error, ZIP_ER_OK, 0); + return 0; + case ZIP_SOURCE_READ: return source->read(data, length); + case ZIP_SOURCE_CLOSE: source->reading = false; return 0; + case ZIP_SOURCE_STAT: + { + if(length < sizeof(zip_stat_t)) return source->fail(ZIP_ER_INVAL); + auto stat = (zip_stat_t*)data; + zip_stat_init(stat); + stat->size = source->size; + stat->comp_size = source->size; + stat->comp_method = ZIP_CM_STORE; + stat->encryption_method = ZIP_EM_NONE; + stat->valid = ZIP_STAT_SIZE | ZIP_STAT_COMP_SIZE | ZIP_STAT_COMP_METHOD | ZIP_STAT_ENCRYPTION_METHOD; + return sizeof(zip_stat_t); + } + case ZIP_SOURCE_ERROR: return zip_error_to_data(&source->error, data, length); + case ZIP_SOURCE_FREE: memdelete(source); return 0; + case ZIP_SOURCE_TELL: return (zip_int64_t)source->read_position; + case ZIP_SOURCE_SEEK: + { + auto offset = zip_source_seek_compute_offset(source->read_position, source->size, data, length, &source->error); + if(offset < 0) return -1; + source->read_position = (u64)offset; + return 0; + } + case ZIP_SOURCE_BEGIN_WRITE: + if(!source->output) return source->fail(ZIP_ER_WRITE); + if(failed(source->output->set_size(0))) return source->fail(ZIP_ER_WRITE); + source->write_position = 0; + source->write_size = 0; + zip_error_set(&source->error, ZIP_ER_OK, 0); + return 0; + case ZIP_SOURCE_WRITE: return source->write(data, length); + case ZIP_SOURCE_TELL_WRITE: return (zip_int64_t)source->write_position; + case ZIP_SOURCE_SEEK_WRITE: + { + // libzip rewrites headers inside data already emitted by this source. + auto offset = zip_source_seek_compute_offset(source->write_position, source->write_size, data, length, &source->error); + if(offset < 0) return -1; + source->write_position = (u64)offset; + return 0; + } + case ZIP_SOURCE_COMMIT_WRITE: + if(!source->output || failed(source->output->set_size(source->write_size))) return source->fail(ZIP_ER_WRITE); + source->input = source->output; + source->size = source->write_size; + source->read_position = 0; + source->output = nullptr; + return 0; + case ZIP_SOURCE_ROLLBACK_WRITE: + // The separate destination is disposable; the original source stays intact. + source->write_position = 0; + source->write_size = 0; + return 0; + case ZIP_SOURCE_REMOVE: + // Empty archives are always kept, so removal is never a save operation. + return source->fail(ZIP_ER_OPNOTSUPP); + default: return source->fail(ZIP_ER_OPNOTSUPP); + } + } + + static R make_source(ISeekableStream* input, bool writable, bool create, StreamSource** context = nullptr) + { + if(!input && !create) return E_BAD_ARGUMENTS; + if(input && input->get_size() > ZIP_INT64_MAX) return E_FILE_TOO_BIG; + auto source = memnew(); + source->input = input; + source->size = input ? input->get_size() : 0; + source->writable = writable; + source->accept_empty = create; + auto native = zip_source_function_create(stream_callback, source, &source->error); + if(!native) + { + auto error = zip_failure(&source->error); + memdelete(source); + return error; + } + if(context) *context = source; + source->native = native; + return native; + } + + static RV validate_compression(CompressionMethod method, u32 level) + { + if(method != CompressionMethod::store && method != CompressionMethod::deflate) return E_NOT_SUPPORTED; + if(level > 9 || (method == CompressionMethod::store && level)) return E_BAD_ARGUMENTS; + return ok; + } + + Archive::~Archive() + { + if(m_archive) zip_discard(m_archive); + } + + RV Archive::init(ISeekableStream* input, OpenMode mode, bool create) + { + if(mode != OpenMode::read && mode != OpenMode::read_write) return E_BAD_ARGUMENTS; + m_writable = mode == OpenMode::read_write; + auto source = make_source(input, m_writable, create, &m_source); + if(failed(source)) return source.errcode(); + zip_error_t error; + zip_error_init(&error); + m_archive = zip_open_from_source(source.get(), create ? ZIP_CREATE | ZIP_TRUNCATE : (m_writable ? 0 : ZIP_RDONLY), &error); + if(!m_archive) + { + auto result = zip_failure(&error); + zip_error_fini(&error); + zip_source_free(source.get()); + m_source = nullptr; + return result; + } + zip_error_fini(&error); + m_content_changed = create; + if(m_writable && zip_set_archive_flag(m_archive, ZIP_AFL_CREATE_OR_KEEP_FILE_FOR_EMPTY_ARCHIVE, 1) < 0) + return zip_failure(zip_get_error(m_archive)); + return ok; + } + + RV Archive::check_edit() + { + if(!m_archive) return E_BAD_CALLING_TIME; + if(!m_writable) return E_ACCESS_DENIED; + if(m_readers) return E_BUSY; + return ok; + } + + bool Archive::is_open() { return m_archive != nullptr; } + + R Archive::get_entry(u64 index) + { + if(!m_archive) return E_BAD_CALLING_TIME; + if(index >= (u64)zip_get_num_entries(m_archive, 0)) return E_NOT_FOUND; + zip_stat_t stat; + if(zip_stat_index(m_archive, index, 0, &stat) < 0) return zip_failure(zip_get_error(m_archive)); + EntryInfo result; + result.index = index; + result.name = stat.name; + result.size = stat.size; + result.has_compressed_size = (stat.valid & ZIP_STAT_COMP_SIZE) != 0; + result.compressed_size = result.has_compressed_size ? stat.comp_size : 0; + result.has_crc32 = (stat.valid & ZIP_STAT_CRC) != 0; + result.crc32 = result.has_crc32 ? stat.crc : 0; + result.compression = (CompressionMethod)stat.comp_method; + result.directory = !result.name.empty() && result.name.back() == '/'; + result.encrypted = stat.encryption_method != ZIP_EM_NONE; + return result; + } + + R> Archive::get_entries() + { + if(!m_archive) return E_BAD_CALLING_TIME; + Vector result; + auto count = zip_get_num_entries(m_archive, 0); + if(count < 0) return zip_failure(zip_get_error(m_archive)); + for(u64 i = 0; i < (u64)count; ++i) + { + // Avoid constructing an error object for ordinary tombstones. + if(!zip_get_name(m_archive, i, 0)) + { + if(zip_error_code_zip(zip_get_error(m_archive)) == ZIP_ER_DELETED) continue; + return zip_failure(zip_get_error(m_archive)); + } + auto entry = get_entry(i); + if(failed(entry)) return entry.errcode(); + result.push_back(move(entry.get())); + } + return result; + } + + R Archive::find_entry(const c8* name) + { + if(!m_archive) return E_BAD_CALLING_TIME; + if(!name || !name[0]) return E_BAD_ARGUMENTS; + auto index = zip_name_locate(m_archive, name, ZIP_FL_ENC_UTF_8); + if(index < 0) return zip_failure(zip_get_error(m_archive)); + return (u64)index; + } + + R> Archive::open_entry(u64 index) + { + auto entry = get_entry(index); + if(failed(entry)) return entry.errcode(); + if(entry.get().directory) return E_IS_DIRECTORY; + if(entry.get().encrypted) return E_NOT_SUPPORTED; + auto compression = validate_compression(entry.get().compression, 0); + if(failed(compression)) return compression.errcode(); + auto reader = new_object(); + auto staged = m_staged_entries.find(index); + if(staged != m_staged_entries.end()) + { + // libzip's changed-entry reader uses the destination compression method + // in zip_stat_index, although this source still contains raw bytes. + reader->m_staged_source = memnew(); + reader->m_staged_source->input = staged->second; + reader->m_staged_source->size = entry.get().size; + } + else + { + reader->m_file = zip_fopen_index(m_archive, index, 0); + if(!reader->m_file) return zip_failure(zip_get_error(m_archive)); + } + reader->m_owner = this; + ++m_readers; + return Ref(reader); + } + + R Archive::add_file(const c8* name, ISeekableStream* input, CompressionMethod method, u32 level) + { + auto editable = check_edit(); + if(failed(editable)) return editable.errcode(); + if(!name || !name[0] || name[strlen(name) - 1] == '/') return E_BAD_ARGUMENTS; + auto compression = validate_compression(method, level); + if(failed(compression)) return compression.errcode(); + auto source = make_source(input, false, false); + if(failed(source)) return source.errcode(); + auto index = zip_file_add(m_archive, name, source.get(), ZIP_FL_ENC_UTF_8); + if(index < 0) + { + zip_source_free(source.get()); + return zip_failure(zip_get_error(m_archive)); + } + m_content_changed = true; + if(zip_set_file_compression(m_archive, (u64)index, (zip_int32_t)method, level) < 0) + { + auto error = zip_failure(zip_get_error(m_archive)); + zip_delete(m_archive, (u64)index); + return error; + } + m_entry_sources.push_back(input); + m_staged_entries.insert_or_assign((u64)index, Ref(input)); + return (u64)index; + } + + R Archive::add_directory(const c8* name) + { + auto editable = check_edit(); + if(failed(editable)) return editable.errcode(); + if(!name || !name[0]) return E_BAD_ARGUMENTS; + auto index = zip_dir_add(m_archive, name, ZIP_FL_ENC_UTF_8); + if(index < 0) return zip_failure(zip_get_error(m_archive)); + m_content_changed = true; + return (u64)index; + } + + RV Archive::replace_file(u64 index, ISeekableStream* input) + { + auto editable = check_edit(); + if(failed(editable)) return editable.errcode(); + auto entry = get_entry(index); + if(failed(entry)) return entry.errcode(); + if(entry.get().directory) return E_IS_DIRECTORY; + if(entry.get().encrypted) return E_NOT_SUPPORTED; + auto compression = validate_compression(entry.get().compression, 0); + if(failed(compression)) return compression.errcode(); + auto source = make_source(input, false, false); + if(failed(source)) return source.errcode(); + if(zip_file_replace(m_archive, index, source.get(), 0) < 0) + { + zip_source_free(source.get()); + return zip_failure(zip_get_error(m_archive)); + } + m_content_changed = true; + m_entry_sources.push_back(input); + m_staged_entries.insert_or_assign(index, Ref(input)); + if(zip_set_file_compression(m_archive, index, (zip_int32_t)entry.get().compression, 0) < 0) + return zip_failure(zip_get_error(m_archive)); + return ok; + } + + RV Archive::rename_entry(u64 index, const c8* name) + { + auto editable = check_edit(); + if(failed(editable)) return editable.errcode(); + if(!name || !name[0]) return E_BAD_ARGUMENTS; + auto entry = get_entry(index); + if(failed(entry)) return entry.errcode(); + if((name[strlen(name) - 1] == '/') != entry.get().directory) return E_BAD_ARGUMENTS; + if(zip_file_rename(m_archive, index, name, ZIP_FL_ENC_UTF_8) < 0) return zip_failure(zip_get_error(m_archive)); + return ok; + } + + RV Archive::delete_entry(u64 index) + { + auto editable = check_edit(); + if(failed(editable)) return editable.errcode(); + auto entry = get_entry(index); + if(failed(entry)) return entry.errcode(); + if(zip_delete(m_archive, index) < 0) return zip_failure(zip_get_error(m_archive)); + m_staged_entries.erase(index); + m_content_changed = true; + return ok; + } + + RV Archive::set_compression(u64 index, CompressionMethod method, u32 level) + { + auto editable = check_edit(); + if(failed(editable)) return editable.errcode(); + auto compression = validate_compression(method, level); + if(failed(compression)) return compression.errcode(); + auto entry = get_entry(index); + if(failed(entry)) return entry.errcode(); + if(entry.get().directory) return E_IS_DIRECTORY; + if(entry.get().encrypted) return E_NOT_SUPPORTED; + if(zip_set_file_compression(m_archive, index, (zip_int32_t)method, level) < 0) return zip_failure(zip_get_error(m_archive)); + m_content_changed = true; + return ok; + } + + RV Archive::save(ISeekableStream* destination) + { + auto editable = check_edit(); + if(failed(editable)) return editable.errcode(); + if(!destination || m_source->input == destination) return E_BAD_ARGUMENTS; + for(auto& source : m_entry_sources) + { + if(destination == source.get()) return E_BAD_ARGUMENTS; + } + bool changed = m_content_changed; + if(!changed) + { + auto count = zip_get_num_entries(m_archive, 0); + for(u64 i = 0; i < (u64)count; ++i) + { + auto current = zip_get_name(m_archive, i, 0); + auto original = zip_get_name(m_archive, i, ZIP_FL_UNCHANGED); + if(!current || !original) return zip_failure(zip_get_error(m_archive)); + if(strcmp(current, original)) { changed = true; break; } + } + } + m_source->output = destination; + if(!changed) + { + // zip_close skips unchanged archives. Copy while the handle is still live, + // so an output failure also preserves this session for retry. + if(stream_callback(m_source, nullptr, 0, ZIP_SOURCE_BEGIN_WRITE) < 0) + { + m_source->output = nullptr; + return zip_failure(&m_source->error); + } + m_source->read_position = 0; + byte_t buffer[64 * 1024]; + while(m_source->read_position < m_source->size) + { + auto count = m_source->read(buffer, sizeof(buffer)); + if(count < 0 || m_source->write(buffer, (u64)count) < 0) + { + m_source->output = nullptr; + return zip_failure(&m_source->error); + } + } + if(stream_callback(m_source, nullptr, 0, ZIP_SOURCE_COMMIT_WRITE) < 0) + { + m_source->output = nullptr; + return zip_failure(&m_source->error); + } + zip_discard(m_archive); + } + else if(zip_close(m_archive) < 0) + { + m_source->output = nullptr; + // libzip closes its input before invoking COMMIT_WRITE. Restore the + // read session after rollback, and clear any source EOF/error state. + if(m_source->reading) zip_source_close(m_source->native); + if(zip_source_open(m_source->native) < 0) return zip_failure(zip_source_error(m_source->native)); + return zip_failure(zip_get_error(m_archive)); + } + m_archive = nullptr; + m_source = nullptr; + m_entry_sources.clear(); + m_staged_entries.clear(); + return ok; + } + + RV Archive::discard() + { + if(m_readers) return E_BUSY; + if(m_archive) zip_discard(m_archive); + m_archive = nullptr; + m_source = nullptr; + m_entry_sources.clear(); + m_staged_entries.clear(); + return ok; + } + + EntryStream::~EntryStream() + { + if(m_file) zip_fclose(m_file); + if(m_staged_source) memdelete(m_staged_source); + if(m_owner) --m_owner->m_readers; + } + + RV EntryStream::read(void* buffer, usize size, usize* read_bytes) + { + if(read_bytes) *read_bytes = 0; + if(size && !buffer) return E_BAD_ARGUMENTS; + if(!size) return ok; + usize done = 0; + while(done < size) + { + usize request = min(size - done, 1024 * 1024); + auto count = m_staged_source ? m_staged_source->read((byte_t*)buffer + done, request) + : zip_fread(m_file, (byte_t*)buffer + done, request); + if(count < 0) return zip_failure(m_staged_source ? &m_staged_source->error : zip_file_get_error(m_file)); + if(!count) break; + done += (usize)count; + if(read_bytes) *read_bytes = done; + } + return ok; + } + + RV EntryStream::write(const void*, usize, usize* write_bytes) + { + if(write_bytes) *write_bytes = 0; + return E_ACCESS_DENIED; + } + + LUNA_ZIP_API R> open_archive(ISeekableStream* source, OpenMode mode) + { + auto archive = new_object(); + auto result = archive->init(source, mode, false); + if(failed(result)) return result.errcode(); + return Ref(archive); + } + + LUNA_ZIP_API R> new_archive() + { + auto archive = new_object(); + auto result = archive->init(nullptr, OpenMode::read_write, true); + if(failed(result)) return result.errcode(); + return Ref(archive); + } +} diff --git a/Modules/Luna/Zip/Source/Archive.hpp b/Modules/Luna/Zip/Source/Archive.hpp new file mode 100644 index 00000000..8ad51ea2 --- /dev/null +++ b/Modules/Luna/Zip/Source/Archive.hpp @@ -0,0 +1,59 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Archive.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include "../Zip.hpp" +#include +#include +#include "Archive.generated.hpp" + +namespace Luna::Zip +{ + struct StreamSource; + struct [[Luna::struct("{581638DC-22BC-4C8B-8F4D-6389C298E48C}")]] Archive : IArchive + { + luiimpl(); + zip_t* m_archive = nullptr; + StreamSource* m_source = nullptr; + Vector> m_entry_sources; + HashMap> m_staged_entries; + usize m_readers = 0; + bool m_writable = false; + // Data/compression edits and tombstones cannot be undone through this API. + // Renames can cancel each other, so save compares names separately. + bool m_content_changed = false; + ~Archive(); + RV init(ISeekableStream* source, OpenMode mode, bool create); + RV check_edit(); + bool is_open() override; + R> get_entries() override; + R get_entry(u64 index) override; + R find_entry(const c8* name) override; + R> open_entry(u64 index) override; + R add_file(const c8* name, ISeekableStream* source, CompressionMethod compression, u32 level) override; + R add_directory(const c8* name) override; + RV replace_file(u64 index, ISeekableStream* source) override; + RV rename_entry(u64 index, const c8* name) override; + RV delete_entry(u64 index) override; + RV set_compression(u64 index, CompressionMethod compression, u32 level) override; + RV save(ISeekableStream* destination) override; + RV discard() override; + }; + + struct [[Luna::struct("{5D516453-2B4E-49AD-A65B-FB3AD864C829}")]] EntryStream : IStream + { + luiimpl(); + Ref m_owner; + zip_file_t* m_file = nullptr; + StreamSource* m_staged_source = nullptr; + ~EntryStream(); + RV read(void* buffer, usize size, usize* read_bytes) override; + RV write(const void* buffer, usize size, usize* write_bytes) override; + }; +} diff --git a/Modules/Luna/Zip/Source/Zip.cpp b/Modules/Luna/Zip/Source/Zip.cpp new file mode 100644 index 00000000..25b3393c --- /dev/null +++ b/Modules/Luna/Zip/Source/Zip.cpp @@ -0,0 +1,34 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Zip.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include +#define LUNA_ZIP_API LUNA_EXPORT +#include "Archive.hpp" +#include "Zip.meta.generated.hpp" +#include + +namespace Luna +{ + struct ZipModule : Module + { + const c8* get_name() override { return "Zip"; } + RV on_init() override + { + Meta::register_Zip_types(); + return ok; + } + void on_close() override {} + }; + + LUNA_ZIP_API Module* module_zip() + { + static ZipModule module; + return &module; + } +} diff --git a/Modules/Luna/Zip/Zip.Target.cs b/Modules/Luna/Zip/Zip.Target.cs new file mode 100644 index 00000000..6867fd2f --- /dev/null +++ b/Modules/Luna/Zip/Zip.Target.cs @@ -0,0 +1,12 @@ +namespace LunaBuild.Core.Targets; + +public sealed class ZipTargetRules : TargetRules +{ + public ZipTargetRules() : base("Zip", "Modules/Luna/Zip", "Modules/Luna/Zip/Zip.Target.cs") + { + Headers("*.hpp", "Source/*.hpp"); + MetaHeaders("Zip.hpp", "Source/Archive.hpp"); + Sources("Source/*.cpp"); + DependsOn("Runtime", "libzip"); + } +} diff --git a/Modules/Luna/Zip/Zip.hpp b/Modules/Luna/Zip/Zip.hpp new file mode 100644 index 00000000..577e4d95 --- /dev/null +++ b/Modules/Luna/Zip/Zip.hpp @@ -0,0 +1,158 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Zip.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include +#include +#include +#include +#include "Zip.generated.hpp" + +#ifndef LUNA_ZIP_API +#define LUNA_ZIP_API +#endif + +namespace Luna +{ + namespace Zip + { + //! @addtogroup Zip Zip + //! ZIP and ZIP64 archive operations independent of VFS and Asset. + //! @{ + + //! Selects whether an existing archive can be edited. + enum class OpenMode : u8 + { + //! Allows enumeration and entry reading. + read, + //! Also allows staged editing and saving to a separate stream. + read_write + }; + + //! ZIP compression method identifiers supported for encoding and decoding. + enum class CompressionMethod : u16 + { + //! Stores bytes without compression. + store = 0, + //! Compresses bytes with Deflate. + deflate = 8 + }; + + //! Describes one entry in the current archive session. + struct EntryInfo + { + //! Session-local index. It is invalid after successful save or discard. + u64 index = 0; + //! Entry name, decoded to UTF-8. This is not a validated filesystem path. + String name; + //! Uncompressed byte count. + u64 size = 0; + //! Stored byte count; meaningful only when @ref has_compressed_size is true. + u64 compressed_size = 0; + //! CRC32 of uncompressed data; meaningful only when @ref has_crc32 is true. + u32 crc32 = 0; + //! ZIP compression identifier; imported entries may contain other identifiers. + CompressionMethod compression = CompressionMethod::store; + //! Whether the entry name ends with '/'. + bool directory = false; + //! Whether the entry is encrypted. Encryption is not supported by this module yet. + bool encrypted = false; + //! Whether the current stored size is known. Staged entries may not have one. + bool has_compressed_size = false; + //! Whether the current CRC32 is known. Staged entries may not have one. + bool has_crc32 = false; + }; + + //! A ZIP archive session. Edits remain staged until an explicit save. + //! @details All operations on an archive, its readers, and shared source streams + //! must be externally serialized. Sources are retained and must not be externally + //! modified during the session. Releasing a session discards unsaved edits. + struct [[Luna::interface("{4E72DC50-B990-47F8-B21D-2305DBAA3A8D}")]] IArchive : virtual Interface + { + //! Tests whether this session has not been saved or discarded. + virtual bool is_open() = 0; + //! Lists current entries, skipping deleted entries. Indices may have gaps. + virtual R> get_entries() = 0; + //! Gets one entry's current information. + //! @param[in] index The index returned by enumeration, lookup, or addition. + virtual R get_entry(u64 index) = 0; + //! Finds an entry by its exact, case-sensitive UTF-8 name. + //! @param[in] name The entry name. Duplicate imported names remain separately + //! addressable by index; name lookup returns the first matching entry. + virtual R find_entry(const c8* name) = 0; + //! Opens a sequential, read-only stream of uncompressed entry bytes. + //! @param[in] index The entry index. + //! @details Store and Deflate are supported; encrypted or unsupported methods + //! return E_NOT_SUPPORTED. Directory entries return E_IS_DIRECTORY. + //! The reader retains this archive. While any reader exists, mutations, + //! save and discard return E_BUSY. Read through EOF to verify a stored CRC + //! when present. Newly staged data has no stored CRC to compare yet. + virtual R> open_entry(u64 index) = 0; + //! Adds a file from the complete contents of a seekable source stream. + //! @param[in] name A non-empty UTF-8 entry name not ending with '/'. + //! @param[in] source The source, retained and read lazily from offset zero. + //! @param[in] compression Store or Deflate. + //! @param[in] level Deflate level 1-9, or 0 for its default. Store requires 0. + //! @return Returns the new session-local index. Existing names fail with E_ALREADY_EXISTS. + virtual R add_file(const c8* name, ISeekableStream* source, + CompressionMethod compression = CompressionMethod::deflate, u32 level = 0) = 0; + //! Adds an explicit directory entry. A trailing '/' is appended if necessary. + //! @param[in] name The non-empty UTF-8 directory name. + //! @return Returns the new index. This does not create parent entries. + virtual R add_directory(const c8* name) = 0; + //! Replaces a file's data, preserving its current compression method. + //! @param[in] index The existing file index. + //! @param[in] source The complete replacement source, retained until released by the archive. + //! @details Compression level uses the library default; use set_compression + //! afterward to select a particular level. Entry extra fields may be removed + //! by libzip because they can depend on the replaced data. + virtual RV replace_file(u64 index, ISeekableStream* source) = 0; + //! Renames exactly one entry; directory descendants are not renamed. + //! @param[in] index The entry index. + //! @param[in] name The new UTF-8 name. Its trailing '/' must preserve the entry kind. + virtual RV rename_entry(u64 index, const c8* name) = 0; + //! Deletes exactly one entry. Deleting a directory marker is not recursive. + //! @param[in] index The entry index. + virtual RV delete_entry(u64 index) = 0; + //! Changes a file's encoding on the next save. + //! @param[in] index The file index. + //! @param[in] compression Store or Deflate. + //! @param[in] level Deflate level 1-9 or default 0; Store requires 0. + virtual RV set_compression(u64 index, CompressionMethod compression, u32 level = 0) = 0; + //! Writes the complete edited ZIP to a separate output stream. + //! @param[in] destination A writable, seekable, resizable stream. Its contents + //! are replaced and its cursor is left at an unspecified position. + //! @details The output must not alias the input or any entry source, even through + //! a different handle. Pointer-identical aliases are rejected. Success closes + //! this session. Failure leaves the session open for retry, but the output may + //! be incomplete. No filesystem replacement or durable synchronization is implied. + //! No write occurs before this call. Read-only sessions return E_ACCESS_DENIED. + virtual RV save(ISeekableStream* destination) = 0; + //! Discards unsaved edits and closes the session without writing any stream. + //! @details Already closed sessions succeed. Open readers cause E_BUSY. + virtual RV discard() = 0; + }; + + //! Opens an existing ZIP from a retained seekable stream. + //! @param[in] source The entire stream, interpreted from offset zero independently + //! of its initial cursor. Its contents and size must remain unchanged while retained. + //! @param[in] mode Whether edits may be staged. Even read_write requires only read + //! and seek access to this input; saving always uses a different output. + //! @return Returns a new session, or an error for malformed/truncated ZIP input. + LUNA_ZIP_API R> open_archive(ISeekableStream* source, OpenMode mode = OpenMode::read); + //! Creates an empty editable ZIP session. No stream is written until save. + LUNA_ZIP_API R> new_archive(); + //! @} + } + + struct Module; + //! Gets the Zip module. Initialize it before using Zip interfaces and release all + //! archive/reader objects before Runtime shutdown. + LUNA_ZIP_API Module* module_zip(); +} diff --git a/Programs/GameGUIEditor/GameGUIEditor.Target.cs b/Programs/GameGUIEditor/GameGUIEditor.Target.cs new file mode 100644 index 00000000..eb0e69b6 --- /dev/null +++ b/Programs/GameGUIEditor/GameGUIEditor.Target.cs @@ -0,0 +1,40 @@ +namespace LunaBuild.Core.Targets; + +public sealed class GameGUIEditorTargetRules : TargetRules +{ + public GameGUIEditorTargetRules() + : base( + name: "GameGUIEditor", + targetDirectory: "Programs/GameGUIEditor", + rulesPath: "Programs/GameGUIEditor/GameGUIEditor.Target.cs") + { + SupportedPlatforms(BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Linux); + Kind = BuildTargetKind.Application; + Sources("Source/**.cpp"); + DependsOn( + "Runtime", + "Window", + "RHI", + "RHIUtility", + "Font", + "VG", + "GUI", + "EditorGUI", + "GUIWindow", + "GameGUI", + "GameGUIEditorService", + "Frontend", + "Asset", + "VariantUtils", + "VFS"); + } + + protected override void Configure(BuildWorkspace workspace, BuildOptions options) + { + if(Platform == BuildPlatform.MacOS) + { + AppleBundle("com.lunasdk.GameGUIEditor", "GameGUIEditor"); + AppleInfoPlist("MacOSInfo.plist"); + } + } +} diff --git a/Programs/GameGUIEditor/MacOSInfo.plist b/Programs/GameGUIEditor/MacOSInfo.plist new file mode 100644 index 00000000..d238da20 --- /dev/null +++ b/Programs/GameGUIEditor/MacOSInfo.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleDisplayName + $(PRODUCT_DISPLAY_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHighResolutionCapable + + + diff --git a/Programs/GameGUIEditor/Service/Authoring.hpp b/Programs/GameGUIEditor/Service/Authoring.hpp new file mode 100644 index 00000000..90334074 --- /dev/null +++ b/Programs/GameGUIEditor/Service/Authoring.hpp @@ -0,0 +1,268 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Authoring.hpp +* @author JXMaster +* @date 2026/8/29 +*/ +#pragma once +#include +#include "Authoring.generated.hpp" + +#ifndef LUNA_GAME_GUI_EDITOR_SERVICE_API +#define LUNA_GAME_GUI_EDITOR_SERVICE_API +#endif + +namespace Luna +{ + namespace GameGUIEditor + { + //! Current GameGUI authoring document format version. + inline constexpr u32 CURRENT_AUTHORING_FORMAT_VERSION = 1; + + //! Gets the named data unit that stores editable GameGUI source. + //! @return Returns `Authoring`. + LUNA_GAME_GUI_EDITOR_SERVICE_API Name get_authoring_data_unit(); + + //! Gets the Asset loader name used by the authoring data unit. + //! @return Returns the stable authoring loader name. + LUNA_GAME_GUI_EDITOR_SERVICE_API Name get_authoring_asset_loader(); + + //! Describes one ordered child edge in an authoring document. + struct AuthoringChildLink + { + //! Asset-local ID of the child node. + Guid child; + //! Semantic slot selected by the parent node type. + Name slot; + //! Parent-owned layout attachment data. + Variant attachment; + }; + + //! Stores one losslessly editable node record. + struct AuthoringNodeRecord + { + //! Stable asset-local node ID. + Guid id; + //! Stable registered node type ID. + Guid type; + //! Version of the authoring property payload. + u32 type_version = 1; + //! Optional author-facing node name. + Name name; + //! Raw authoring property payload. + Variant properties = Variant(VariantType::object); + //! Ordered outgoing child edges. + Vector children; + }; + + //! Stores the versioned, losslessly editable representation of one GameGUI asset. + struct [[Luna::struct("{088762B2-DC23-4185-AD75-58C4EB1DC454}")]] AuthoringDocument + { + //! Authoring document format version. + u32 format_version = CURRENT_AUTHORING_FORMAT_VERSION; + //! Asset-local root node ID. + Guid root; + //! Flat canonical node records. + Vector nodes; + //! Forward-compatible document payload retained by the editor. + Variant extensions; + }; + + //! Migrates one known authoring payload between adjacent versions. + using AuthoringNodeMigrateCallback = RV(*)(Variant& properties, u32 from_version, + u32 to_version, object_t userdata); + + //! Identifies the Inspector section that contains one editable property. + enum class EditingPropertySection : u8 + { + //! Geometry and layout behavior. + layout, + //! Visual appearance. + style, + //! Node-specific content, resources and actions. + property + }; + + //! Identifies the editor used to manipulate one authoring property. + enum class EditingPropertyEditor : u8 + { + //! Boolean checkbox. + boolean, + //! Scalar floating-point drag editor. + number, + //! UTF-8 text input. + string, + //! Interned-name text input. + name, + //! Single-selection enumeration. + enumeration, + //! Two-component floating-point editor. + float2, + //! Four-component floating-point editor. + float4, + //! Four-component normalized color editor. + color, + //! Auto, fixed or percentage size editor backed by two mutually exclusive properties. + size, + //! Asset GUID editor. + asset, + //! Raw JSON value editor. + json, + //! Ordered static element visual-effect list. + visual_effects + }; + + //! Describes one enumeration item exposed by an editing property. + struct EditingEnumItemDesc + { + //! Value stored in the authoring document. + Name value; + //! Human-readable item label. + String display_name; + }; + + //! Describes one field in an editor-only property schema. + struct EditingPropertyDesc + { + //! Property name in the target Variant object. + Name id; + //! Secondary property used by compound editors such as percentage size. + Name alternate_id; + //! Human-readable field label. + String display_name; + //! Optional tooltip or help text. + String description; + //! Inspector section containing this field. + EditingPropertySection section = EditingPropertySection::property; + //! Editor used to manipulate the field. + EditingPropertyEditor editor = EditingPropertyEditor::json; + //! Value displayed when the property is absent. + Variant default_value; + //! Whether @ref default_value is defined, including an explicit null default. + bool has_default = false; + //! Whether the property may be absent from the authoring document. + bool optional = true; + //! Whether numeric values are clamped to @ref minimum and @ref maximum. + bool bounded = false; + //! Inclusive numeric minimum when @ref bounded is true. + f64 minimum = 0.0; + //! Inclusive numeric maximum when @ref bounded is true. + f64 maximum = 0.0; + //! Suggested drag increment for numeric editors. + f64 step = 0.1; + //! Items available to an enumeration editor. + Vector enumeration_items; + //! Required asset type for an asset editor. An empty name accepts any type. + Name asset_type; + }; + + //! Ordered collection of fields that edit one Variant object. + struct EditingSchema + { + //! Fields displayed by the Inspector in registration order. + Vector properties; + }; + + //! Describes editor-only metadata for one GameGUI node type. + struct AuthoringNodeTypeDesc + { + //! Stable runtime node type ID. + Guid type; + //! Stable programmatic type name. + Name name; + //! Author-facing display name. + Name display_name; + //! Author-facing category. + Name category; + //! Current authoring property payload version. + u32 current_version = 1; + //! Schema for values stored in @ref AuthoringNodeRecord::properties. + EditingSchema property_schema; + //! Schema for child-link attachments owned by nodes of this type. + EditingSchema child_attachment_schema; + //! Child-slot schema consumed by editors and tools. + Variant slot_schema; + //! Default property payload for newly created nodes. + Variant default_properties = Variant(VariantType::object); + //! Optional adjacent-version migration callback. + AuthoringNodeMigrateCallback migrate = nullptr; + //! Optional user object retained by the registry. + ObjRef userdata; + }; + + //! Registers editor-only metadata for one node type. + //! @param[in] desc The descriptor to copy into the authoring registry. + //! @return Returns @ref E_ALREADY_EXISTS when its type or name is already registered. + LUNA_GAME_GUI_EDITOR_SERVICE_API RV register_authoring_node_type( + const AuthoringNodeTypeDesc& desc); + + //! Gets editor-only metadata for one node type. + //! @param[in] type The stable runtime node type ID. + //! @return Returns a descriptor copy, or @ref E_NOT_FOUND. + LUNA_GAME_GUI_EDITOR_SERVICE_API R get_authoring_node_type( + const Guid& type); + + //! Enumerates all editor-only node type descriptors. + //! @param[out] descriptors Descriptor copies are appended to this vector. + LUNA_GAME_GUI_EDITOR_SERVICE_API void get_authoring_node_types( + Vector& descriptors); + + //! Finds one node in an authoring document. + //! @param[in] document The document to search. + //! @param[in] id The asset-local node ID. + //! @return Returns the matching node, or `nullptr`. + LUNA_GAME_GUI_EDITOR_SERVICE_API const AuthoringNodeRecord* find_authoring_node( + const AuthoringDocument& document, const Guid& id); + + //! Finds one mutable node in an authoring document. + //! @param[in] document The document to search. + //! @param[in] id The asset-local node ID. + //! @return Returns the matching node, or `nullptr`. + LUNA_GAME_GUI_EDITOR_SERVICE_API AuthoringNodeRecord* find_authoring_node( + AuthoringDocument& document, const Guid& id); + + //! Validates authoring document topology without requiring node providers. + //! @param[in] document The authoring document to validate. + //! @param[out] diagnostics Optional destination for validation diagnostics. + //! @return Returns success only when the document is a single rooted tree. + LUNA_GAME_GUI_EDITOR_SERVICE_API RV validate_authoring_document( + const AuthoringDocument& document, Vector* diagnostics = nullptr); + + //! Encodes an authoring document for JSON or Frontend transport. + //! @param[in] document The current authoring document to encode. + //! @return Returns its canonical Variant representation. + LUNA_GAME_GUI_EDITOR_SERVICE_API R encode_authoring_document( + const AuthoringDocument& document); + + //! Decodes and migrates known records while retaining unsupported records losslessly. + //! @param[in] data The canonical Variant representation. + //! @param[out] diagnostics Optional destination for migration and validation diagnostics. + //! @return Returns the current in-memory authoring document. + LUNA_GAME_GUI_EDITOR_SERVICE_API R> decode_authoring_document( + const Variant& data, Vector* diagnostics = nullptr); + + //! Cooks an authoring snapshot into the latest runtime-only document representation. + //! @param[in] document The source authoring snapshot. + //! @param[out] diagnostics Optional destination for cooking diagnostics. + //! @return Returns a current runtime document, or an error if a required provider is unavailable. + LUNA_GAME_GUI_EDITOR_SERVICE_API R> cook_authoring_document( + const AuthoringDocument& document, Vector* diagnostics = nullptr); + + //! Adds the Authoring data-unit descriptor to an asset when it is missing. + //! @param[in] asset The GameGUI asset to update. + //! @return Returns errors from data-unit enumeration, insertion or metadata save. + LUNA_GAME_GUI_EDITOR_SERVICE_API RV ensure_authoring_data_unit(Asset::asset_t asset); + + //! Cooks the loaded Authoring data unit of one asset and saves its main data unit. + //! @param[in] asset The GameGUI asset to cook. + //! @return Returns errors from authoring load, cooking, main-unit publication or save. + LUNA_GAME_GUI_EDITOR_SERVICE_API RV cook_asset(Asset::asset_t asset); + + //! Registers the built-in authoring loader and built-in node metadata. + //! @return Returns success when authoring support is ready for use. + LUNA_GAME_GUI_EDITOR_SERVICE_API RV initialize_authoring(); + } +} diff --git a/Programs/GameGUIEditor/Service/GameGUIEditorService.Target.cs b/Programs/GameGUIEditor/Service/GameGUIEditorService.Target.cs new file mode 100644 index 00000000..d5469071 --- /dev/null +++ b/Programs/GameGUIEditor/Service/GameGUIEditorService.Target.cs @@ -0,0 +1,16 @@ +namespace LunaBuild.Core.Targets; + +public sealed class GameGUIEditorServiceTargetRules : TargetRules +{ + public GameGUIEditorServiceTargetRules() + : base( + name: "GameGUIEditorService", + targetDirectory: "Programs/GameGUIEditor/Service", + rulesPath: "Programs/GameGUIEditor/Service/GameGUIEditorService.Target.cs") + { + Headers("*.hpp", "Source/**.hpp"); + MetaHeaders("Authoring.hpp", "Source/WorkingDirectories.hpp", "Source/PreviewResources.hpp"); + Sources("Source/**.cpp"); + DependsOn("Runtime", "GameGUI", "Asset", "Frontend", "VariantUtils", "VFS"); + } +} diff --git a/Programs/GameGUIEditor/Service/GameGUIEditorService.hpp b/Programs/GameGUIEditor/Service/GameGUIEditorService.hpp new file mode 100644 index 00000000..f35fc48f --- /dev/null +++ b/Programs/GameGUIEditor/Service/GameGUIEditorService.hpp @@ -0,0 +1,101 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file GameGUIEditorService.hpp +* @author JXMaster +* @date 2026/8/26 +*/ +#pragma once +#include "Authoring.hpp" +#include +#include +#include + +#ifndef LUNA_GAME_GUI_EDITOR_SERVICE_API +#define LUNA_GAME_GUI_EDITOR_SERVICE_API +#endif + +namespace Luna +{ + namespace GameGUIEditor + { + //! Opens native_path and returns its working_directory_id and private vfs_path. + inline constexpr const c8* OPEN_DIRECTORY_URL = "/GameGUIEditor/WorkingDirectories/Open"; + //! Lists opened directories and their cached folders and asset records (all asset types). + inline constexpr const c8* LIST_DIRECTORIES_URL = "/GameGUIEditor/WorkingDirectories/List"; + //! Explicitly reloads metadata and saved dependencies; rejects unsaved owned documents. + inline constexpr const c8* REFRESH_DIRECTORY_URL = "/GameGUIEditor/WorkingDirectories/Refresh"; + //! Resolves an absolute native .json path within an opened directory, without mounting or loading. + inline constexpr const c8* RESOLVE_PATH_URL = "/GameGUIEditor/WorkingDirectories/ResolvePath"; + //! Freezes working_directory_id and returns close_token plus documents to confirm. + inline constexpr const c8* PREPARE_CLOSE_DIRECTORY_URL = "/GameGUIEditor/WorkingDirectories/PrepareClose"; + //! Cancels close_token and permits normal editing again; does not discard documents. + inline constexpr const c8* CANCEL_CLOSE_DIRECTORY_URL = "/GameGUIEditor/WorkingDirectories/CancelClose"; + //! Closes a prepared directory after checking close_token and a documents array of + //! document_id, expected_revision and explicit discard decisions. Preview holders must + //! be released before calling. Failure retains documents and retryable directory state. + inline constexpr const c8* CLOSE_DIRECTORY_URL = "/GameGUIEditor/WorkingDirectories/Close"; + //! Creates an untitled document in working_directory_id, optionally under relative_directory. + //! The ID may be omitted only when exactly one directory is open. + inline constexpr const c8* CREATE_DOCUMENT_URL = "/GameGUIEditor/Documents/Create"; + //! Stable Frontend URL used to open one GameGUI asset. + inline constexpr const c8* OPEN_DOCUMENT_URL = "/GameGUIEditor/Documents/Open"; + //! Stable Frontend URL used to list open documents. + inline constexpr const c8* LIST_DOCUMENTS_URL = "/GameGUIEditor/Documents/List"; + //! Stable Frontend URL used to fetch an immutable document snapshot. + inline constexpr const c8* GET_SNAPSHOT_URL = "/GameGUIEditor/Documents/GetSnapshot"; + //! Stable Frontend URL used to apply one atomic semantic command batch. + inline constexpr const c8* APPLY_COMMANDS_URL = "/GameGUIEditor/Documents/ApplyCommands"; + //! Stable Frontend URL used to undo one document history state. + inline constexpr const c8* UNDO_URL = "/GameGUIEditor/Documents/Undo"; + //! Stable Frontend URL used to redo one document history state. + inline constexpr const c8* REDO_URL = "/GameGUIEditor/Documents/Redo"; + //! Stable Frontend URL used to save a bound document. + inline constexpr const c8* SAVE_URL = "/GameGUIEditor/Documents/Save"; + //! Saves and binds a document to an asset-base VFS path in an opened directory. + //! Replacing another existing asset requires overwrite=true; an asset open in another + //! document cannot be overwritten. During prepared close, saves require close_token. + inline constexpr const c8* SAVE_AS_URL = "/GameGUIEditor/Documents/SaveAs"; + //! Stable Frontend URL used to cook the current authoring snapshot into the main data unit. + inline constexpr const c8* COOK_URL = "/GameGUIEditor/Documents/Cook"; + //! Stable Frontend URL used to close one document. + inline constexpr const c8* CLOSE_DOCUMENT_URL = "/GameGUIEditor/Documents/Close"; + //! Stable Frontend URL used to enumerate registered GameGUI node schemas. + inline constexpr const c8* GET_NODE_TYPES_URL = "/GameGUIEditor/NodeTypes/List"; + + class ServiceImpl; + + //! Owns the headless GameGUI editor document service and its Frontend interface. + //! @remark The service and its Frontend are not thread-safe. All calls must be serialized + //! on the owning application thread. + class LUNA_GAME_GUI_EDITOR_SERVICE_API Service + { + public: + Service(const Service&) = delete; + Service& operator=(const Service&) = delete; + ~Service(); + + //! Gets the protocol-independent Frontend exposed by this service. + Frontend::IFrontend* frontend() const; + + //! Gets the revision of the editor-wide preview dependency graph. Changes on edits, + //! saves, document close and directory changes, but not on read-only queries. + u64 preview_revision() const; + //! Prepares an immutable root and no-I/O resource snapshot for one document. + //! May load dependencies and cook Authoring in memory; call before GUI generation. + //! Does not save data or publish working copies into Asset's main data unit. + R prepare_preview(u64 document_id); + + private: + friend LUNA_GAME_GUI_EDITOR_SERVICE_API R> new_service(); + Service(); + RV init(); + ServiceImpl* m_impl; + }; + + //! Creates a headless GameGUI editor service and installs all Frontend resources. + LUNA_GAME_GUI_EDITOR_SERVICE_API R> new_service(); + } +} diff --git a/Programs/GameGUIEditor/Service/Source/Authoring.cpp b/Programs/GameGUIEditor/Service/Source/Authoring.cpp new file mode 100644 index 00000000..ad88180e --- /dev/null +++ b/Programs/GameGUIEditor/Service/Source/Authoring.cpp @@ -0,0 +1,951 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Authoring.cpp +* @author JXMaster +* @date 2026/8/29 +*/ +#include +#define LUNA_GAME_GUI_EDITOR_SERVICE_API LUNA_EXPORT +#include "../Authoring.hpp" +#include "PreviewResources.hpp" +#include "GameGUIEditorService.meta.generated.hpp" +#include +#include +#include +#include +#include + +namespace Luna +{ + namespace GameGUIEditor + { + namespace + { + SpinLock g_authoring_registry_lock; + HashMap g_authoring_node_types; + bool g_authoring_initialized = false; + + String guid_string(const Guid& guid) + { + c8 buffer[GUID_STRING_LENGTH]; + RV result = encode_guid(guid, buffer, sizeof(buffer)); + luassert(succeeded(result)); + return String(buffer, sizeof(buffer)); + } + + R decode_guid_value(const Variant& value, const c8* field_name) + { + if(value.type() != VariantType::string) + return set_error(E_BAD_DATA, "GameGUI authoring %s must be a GUID string.", field_name); + Guid guid; + RV result = decode_guid(value.c_str(), value.str().size(), guid); + if(failed(result)) + return set_error(E_BAD_DATA, "GameGUI authoring %s must be a GUID string.", field_name); + return guid; + } + + void add_diagnostic(Vector* diagnostics, + GameGUI::DiagnosticSeverity severity, const Guid& node, const c8* message) + { + if(!diagnostics) return; + GameGUI::Diagnostic diagnostic; + diagnostic.severity = severity; + diagnostic.node = node; + diagnostic.message = message; + diagnostics->push_back(move(diagnostic)); + } + + R decode_child_link(const Variant& value, bool legacy) + { + AuthoringChildLink link; + lutry + { + if(legacy && value.type() == VariantType::string) + { + luset(link.child, decode_guid_value(value, "child")); + } + else + { + if(value.type() != VariantType::object) + luthrow(set_error(E_BAD_DATA, "GameGUI authoring child link must be an object.")); + luset(link.child, decode_guid_value(value["child"], "child")); + link.slot = value["slot"].str(); + if(value.contains("attachment")) link.attachment = value["attachment"]; + } + } + lucatchret; + return link; + } + + R decode_node(const Variant& value, bool legacy) + { + AuthoringNodeRecord node; + lutry + { + if(value.type() != VariantType::object) + luthrow(set_error(E_BAD_DATA, "GameGUI authoring node must be an object.")); + luset(node.id, decode_guid_value(value["id"], "node id")); + luset(node.type, decode_guid_value(value["type"], "node type")); + node.type_version = legacy ? 1 : (u32)value["type_version"].unum(1); + if(!node.type_version) + luthrow(set_error(E_BAD_DATA, "GameGUI authoring node version cannot be zero.")); + node.name = value["name"].str(); + if(value.contains("properties")) node.properties = value["properties"]; + const Variant& children = value["children"]; + if(children.valid() && children.type() != VariantType::array) + luthrow(set_error(E_BAD_DATA, "GameGUI authoring children must be an array.")); + for(const Variant& child : children.values()) + { + lulet(link, decode_child_link(child, legacy)); + node.children.push_back(move(link)); + } + } + lucatchret; + return node; + } + + Ref clone_authoring_document(const AuthoringDocument& source) + { + Ref result = new_object(); + result->format_version = source.format_version; + result->root = source.root; + result->nodes = source.nodes; + result->extensions = source.extensions; + return result; + } + + RV migrate_known_nodes(AuthoringDocument& document, + Vector* diagnostics) + { + for(AuthoringNodeRecord& node : document.nodes) + { + auto desc_result = get_authoring_node_type(node.type); + if(!desc_result.valid()) + { + add_diagnostic(diagnostics, GameGUI::DiagnosticSeverity::warning, node.id, + "The authoring node provider is unavailable; the raw record was retained."); + continue; + } + AuthoringNodeTypeDesc desc = move(desc_result.get()); + if(node.type_version > desc.current_version) + { + add_diagnostic(diagnostics, GameGUI::DiagnosticSeverity::warning, node.id, + "The authoring node payload is newer than its installed provider; the raw record was retained."); + continue; + } + while(node.type_version < desc.current_version) + { + if(!desc.migrate) + return set_error(E_NOT_SUPPORTED, + "GameGUI authoring node `%s` requires a migration callback.", desc.name.c_str()); + u32 from_version = node.type_version; + lutry + { + luexp(desc.migrate(node.properties, from_version, from_version + 1, + desc.userdata.get())); + } + lucatchret; + ++node.type_version; + } + } + return ok; + } + + R load_authoring(object_t userdata, Asset::asset_t asset, + const Name& data_unit, const Path& path) + { + lutry + { + Path source_path = path; + source_path.append_extension("json"); + lulet(file, VFS::open_file(source_path, FileOpenFlag::read, + FileCreationMode::open_existing)); + lulet(data, VariantUtils::read_json(file, + VariantUtils::JSONReadOptions::strict())); + lulet(document, decode_authoring_document(data)); + return ObjRef(document.object()); + } + lucatchret; + return E_FAILURE; + } + + R load_default_authoring(object_t userdata, Asset::asset_t asset, + const Name& data_unit) + { + return ObjRef(new_object().object()); + } + + RV save_authoring(object_t userdata, Asset::asset_t asset, const Name& data_unit, + const Path& path, object_t data) + { + AuthoringDocument* document = cast_object(data); + if(!document) return E_BAD_ARGUMENTS; + lutry + { + luexp(validate_authoring_document(*document)); + lulet(encoded, encode_authoring_document(*document)); + Path source_path = path; + source_path.append_extension("json"); + lulet(file, VFS::open_file(source_path, FileOpenFlag::write, + FileCreationMode::create_always)); + VariantUtils::JSONWriteOptions options; + options.indent = true; + options.encode_blobs = false; + options.allow_non_finite_numbers = false; + luexp(VariantUtils::write_json(file, encoded, options)); + } + lucatchret; + return ok; + } + + RV set_authoring(object_t userdata, Asset::asset_t asset, const Name& data_unit, + object_t data) + { + return data && !cast_object(data) ? RV(E_BAD_ARGUMENTS) : ok; + } + + void get_authoring_references(object_t userdata, Asset::asset_t asset, + const Name& data_unit, Vector& assets) + { + auto document = Asset::get_asset_data_unit_object(asset, data_unit); + if(!document.valid() || !document.get()) return; + Vector collected; + for(const AuthoringNodeRecord& source : document.get()->nodes) + { + auto runtime_type = GameGUI::get_node_type(source.type); + if(!runtime_type.valid() || !runtime_type.get().collect_assets) continue; + GameGUI::NodeRecord node; + node.id = source.id; + node.type = source.type; + node.name = source.name; + node.properties = source.properties; + for(const AuthoringChildLink& child : source.children) + { + GameGUI::ChildLink runtime_child; + runtime_child.child = child.child; + runtime_child.slot = child.slot; + runtime_child.attachment = child.attachment; + node.children.push_back(move(runtime_child)); + } + runtime_type.get().collect_assets(node, collected, + runtime_type.get().userdata.get()); + } + for(Asset::asset_t referred : collected) + { + bool duplicate = false; + for(Asset::asset_t existing : assets) + { + if(existing == referred) + { + duplicate = true; + break; + } + } + if(referred && !duplicate) assets.push_back(referred); + } + } + + Variant make_slot_schema(const c8* kind) + { + Variant schema(VariantType::object); + schema["kind"] = kind; + return schema; + } + + Variant make_float2(f64 x, f64 y) + { + Variant value(VariantType::array); + value.push_back(x); + value.push_back(y); + return value; + } + + Variant make_float4(f64 x, f64 y, f64 z, f64 w) + { + Variant value(VariantType::array); + value.push_back(x); + value.push_back(y); + value.push_back(z); + value.push_back(w); + return value; + } + + EditingPropertyDesc make_property(const c8* id, const c8* display_name, + EditingPropertySection section, EditingPropertyEditor editor) + { + EditingPropertyDesc property; + property.id = id; + property.display_name = display_name; + property.section = section; + property.editor = editor; + return property; + } + + void set_default(EditingPropertyDesc& property, Variant&& value) + { + property.default_value = move(value); + property.has_default = true; + } + + void add_common_layout_schema(EditingSchema& schema) + { + EditingPropertyDesc property = make_property("width", "Width", + EditingPropertySection::layout, EditingPropertyEditor::size); + property.alternate_id = "width_percent"; + property.step = 1.0; + property.description = "Controls automatic, fixed-pixel or parent-relative width."; + schema.properties.push_back(move(property)); + + property = make_property("height", "Height", + EditingPropertySection::layout, EditingPropertyEditor::size); + property.alternate_id = "height_percent"; + property.step = 1.0; + property.description = "Controls automatic, fixed-pixel or parent-relative height."; + schema.properties.push_back(move(property)); + + property = make_property("margin", "Margin", + EditingPropertySection::layout, EditingPropertyEditor::float4); + set_default(property, make_float4(0.0, 0.0, 0.0, 0.0)); + property.step = 1.0; + property.description = "Left, top, right and bottom outer spacing."; + schema.properties.push_back(move(property)); + + property = make_property("padding", "Padding", + EditingPropertySection::layout, EditingPropertyEditor::float4); + set_default(property, make_float4(0.0, 0.0, 0.0, 0.0)); + property.step = 1.0; + property.description = "Left, top, right and bottom inner spacing."; + schema.properties.push_back(move(property)); + + property = make_property("flex_grow", "Flex Grow", + EditingPropertySection::layout, EditingPropertyEditor::number); + set_default(property, Variant(0.0)); + property.step = 0.1; + property.description = "Relative share of positive free space."; + schema.properties.push_back(move(property)); + + property = make_property("flex_shrink", "Flex Shrink", + EditingPropertySection::layout, EditingPropertyEditor::number); + set_default(property, Variant(1.0)); + property.step = 0.1; + property.description = "Relative share of negative free space."; + schema.properties.push_back(move(property)); + } + + void add_flex_layout_schema(EditingSchema& schema) + { + EditingPropertyDesc property = make_property("axis", "Axis", + EditingPropertySection::layout, EditingPropertyEditor::enumeration); + set_default(property, Variant("y")); + property.enumeration_items.push_back({"y", "Vertical"}); + property.enumeration_items.push_back({"x", "Horizontal"}); + schema.properties.push_back(move(property)); + + property = make_property("reverse", "Reverse", + EditingPropertySection::layout, EditingPropertyEditor::boolean); + set_default(property, Variant(false)); + schema.properties.push_back(move(property)); + + property = make_property("wrap", "Wrap", + EditingPropertySection::layout, EditingPropertyEditor::enumeration); + set_default(property, Variant("none")); + property.enumeration_items.push_back({"none", "No Wrap"}); + property.enumeration_items.push_back({"wrap", "Wrap"}); + property.enumeration_items.push_back({"wrap_reverse", "Wrap Reverse"}); + property.description = "Controls whether children wrap onto additional lines."; + schema.properties.push_back(move(property)); + + property = make_property("main_alignment", "Main Alignment", + EditingPropertySection::layout, EditingPropertyEditor::enumeration); + set_default(property, Variant("start")); + property.enumeration_items.push_back({"start", "Start"}); + property.enumeration_items.push_back({"center", "Center"}); + property.enumeration_items.push_back({"end", "End"}); + property.enumeration_items.push_back({"space_between", "Space Between"}); + property.enumeration_items.push_back({"space_around", "Space Around"}); + property.enumeration_items.push_back({"space_evenly", "Space Evenly"}); + property.description = "Distributes children along the primary layout axis."; + schema.properties.push_back(move(property)); + + property = make_property("cross_alignment", "Cross Alignment", + EditingPropertySection::layout, EditingPropertyEditor::enumeration); + set_default(property, Variant("stretch")); + property.enumeration_items.push_back({"start", "Start"}); + property.enumeration_items.push_back({"center", "Center"}); + property.enumeration_items.push_back({"end", "End"}); + property.enumeration_items.push_back({"stretch", "Stretch"}); + property.description = "Aligns children across each flex line."; + schema.properties.push_back(move(property)); + + property = make_property("line_alignment", "Line Alignment", + EditingPropertySection::layout, EditingPropertyEditor::enumeration); + set_default(property, Variant("start")); + property.enumeration_items.push_back({"start", "Start"}); + property.enumeration_items.push_back({"center", "Center"}); + property.enumeration_items.push_back({"end", "End"}); + property.enumeration_items.push_back({"stretch", "Stretch"}); + property.enumeration_items.push_back({"space_between", "Space Between"}); + property.enumeration_items.push_back({"space_around", "Space Around"}); + property.enumeration_items.push_back({"space_evenly", "Space Evenly"}); + property.description = "Distributes wrapped lines along the cross axis."; + schema.properties.push_back(move(property)); + + property = make_property("gap", "Gap", + EditingPropertySection::layout, EditingPropertyEditor::number); + set_default(property, Variant(0.0)); + property.step = 1.0; + schema.properties.push_back(move(property)); + + property = make_property("line_gap", "Line Gap", + EditingPropertySection::layout, EditingPropertyEditor::number); + set_default(property, Variant(0.0)); + property.step = 1.0; + schema.properties.push_back(move(property)); + + property = make_property("clip_children", "Clip Children", + EditingPropertySection::layout, EditingPropertyEditor::boolean); + set_default(property, Variant(false)); + schema.properties.push_back(move(property)); + } + + void add_canvas_layout_schema(EditingSchema& schema) + { + EditingPropertyDesc property = make_property("clip_children", "Clip Children", + EditingPropertySection::layout, EditingPropertyEditor::boolean); + set_default(property, Variant(false)); + schema.properties.push_back(move(property)); + } + + void add_canvas_attachment_schema(EditingSchema& schema) + { + EditingPropertyDesc property = make_property("anchor_min", "Anchor Min", + EditingPropertySection::layout, EditingPropertyEditor::float2); + set_default(property, make_float2(0.0, 0.0)); + property.step = 0.01; + property.description = "Normalized minimum anchor in the parent canvas."; + schema.properties.push_back(move(property)); + + property = make_property("anchor_max", "Anchor Max", + EditingPropertySection::layout, EditingPropertyEditor::float2); + set_default(property, make_float2(0.0, 0.0)); + property.step = 0.01; + property.description = "Normalized maximum anchor in the parent canvas."; + schema.properties.push_back(move(property)); + + property = make_property("offset", "Offset", + EditingPropertySection::layout, EditingPropertyEditor::float4); + set_default(property, make_float4(0.0, 0.0, 0.0, 0.0)); + property.step = 1.0; + property.description = "Left, top, right and bottom offsets from the anchors."; + schema.properties.push_back(move(property)); + + property = make_property("pivot", "Pivot", + EditingPropertySection::layout, EditingPropertyEditor::float2); + set_default(property, make_float2(0.0, 0.0)); + property.step = 0.01; + property.description = "Normalized pivot used by canvas placement."; + schema.properties.push_back(move(property)); + } + + void add_panel_style_schema(EditingSchema& schema) + { + EditingPropertyDesc property = make_property("color", "Color", + EditingPropertySection::style, EditingPropertyEditor::color); + set_default(property, make_float4(0.22, 0.22, 0.22, 1.0)); + schema.properties.push_back(move(property)); + + property = make_property("radius", "Corner Radius", + EditingPropertySection::style, EditingPropertyEditor::number); + set_default(property, Variant(0.0)); + property.bounded = true; + property.minimum = 0.0; + property.maximum = 1024.0; + property.step = 1.0; + schema.properties.push_back(move(property)); + } + + void add_text_style_schema(EditingSchema& schema) + { + EditingPropertyDesc property = make_property("text_color", "Text Color", + EditingPropertySection::style, EditingPropertyEditor::color); + set_default(property, make_float4(1.0, 1.0, 1.0, 1.0)); + schema.properties.push_back(move(property)); + + property = make_property("font", "Font", + EditingPropertySection::style, EditingPropertyEditor::name); + set_default(property, Variant("")); + schema.properties.push_back(move(property)); + + property = make_property("font_size", "Font Size", + EditingPropertySection::style, EditingPropertyEditor::number); + set_default(property, Variant(16.0)); + property.bounded = true; + property.minimum = 1.0; + property.maximum = 512.0; + property.step = 1.0; + schema.properties.push_back(move(property)); + } + + void add_visual_effects_schema(EditingSchema& schema) + { + EditingPropertyDesc property = make_property("visual_effects", "Visual Effects", + EditingPropertySection::style, EditingPropertyEditor::visual_effects); + set_default(property, Variant(VariantType::array)); + property.description = "Ordered static visuals emitted before or after child elements."; + schema.properties.push_back(move(property)); + } + + Variant make_default_layout(f32 height = -1.0f) + { + Variant properties(VariantType::object); + if(height >= 0.0f) properties["height"] = height; + return properties; + } + + RV register_builtin_authoring_types() + { + lutry + { + AuthoringNodeTypeDesc desc; + desc.type = GameGUI::get_flex_node_type(); + desc.name = "Flex"; + desc.display_name = "Flex Layout"; + desc.category = "Layout"; + add_common_layout_schema(desc.property_schema); + add_flex_layout_schema(desc.property_schema); + add_visual_effects_schema(desc.property_schema); + desc.slot_schema = make_slot_schema("ordered_children"); + desc.default_properties = make_default_layout(); + luexp(register_authoring_node_type(desc)); + + desc = AuthoringNodeTypeDesc(); + desc.type = GameGUI::get_canvas_node_type(); + desc.name = "Canvas"; + desc.display_name = "Canvas Layout"; + desc.category = "Layout"; + add_common_layout_schema(desc.property_schema); + add_canvas_layout_schema(desc.property_schema); + add_visual_effects_schema(desc.property_schema); + add_canvas_attachment_schema(desc.child_attachment_schema); + desc.slot_schema = make_slot_schema("canvas_children"); + desc.default_properties = make_default_layout(); + luexp(register_authoring_node_type(desc)); + + desc = AuthoringNodeTypeDesc(); + desc.type = GameGUI::get_panel_node_type(); + desc.name = "Panel"; + desc.display_name = "Panel"; + desc.category = "Visual"; + add_common_layout_schema(desc.property_schema); + add_panel_style_schema(desc.property_schema); + add_visual_effects_schema(desc.property_schema); + desc.slot_schema = make_slot_schema("ordered_children"); + desc.default_properties = make_default_layout(); + luexp(register_authoring_node_type(desc)); + + desc = AuthoringNodeTypeDesc(); + desc.type = GameGUI::get_text_node_type(); + desc.name = "Text"; + desc.display_name = "Text"; + desc.category = "Visual"; + add_common_layout_schema(desc.property_schema); + add_text_style_schema(desc.property_schema); + add_visual_effects_schema(desc.property_schema); + { + EditingPropertyDesc property = make_property("text", "Text", + EditingPropertySection::property, EditingPropertyEditor::string); + set_default(property, Variant("")); + desc.property_schema.properties.push_back(move(property)); + } + desc.default_properties = make_default_layout(24.0f); + desc.default_properties["text"] = "Text"; + luexp(register_authoring_node_type(desc)); + + desc = AuthoringNodeTypeDesc(); + desc.type = GameGUI::get_button_node_type(); + desc.name = "Button"; + desc.display_name = "Button"; + desc.category = "Input"; + add_common_layout_schema(desc.property_schema); + add_panel_style_schema(desc.property_schema); + add_text_style_schema(desc.property_schema); + add_visual_effects_schema(desc.property_schema); + { + EditingPropertyDesc property = make_property("text", "Text", + EditingPropertySection::property, EditingPropertyEditor::string); + set_default(property, Variant("")); + desc.property_schema.properties.push_back(move(property)); + + property = make_property("action", "Action", + EditingPropertySection::property, EditingPropertyEditor::name); + set_default(property, Variant("")); + desc.property_schema.properties.push_back(move(property)); + + property = make_property("action_payload", "Action Payload", + EditingPropertySection::property, EditingPropertyEditor::json); + property.has_default = true; + property.default_value = Variant(); + desc.property_schema.properties.push_back(move(property)); + } + desc.default_properties = make_default_layout(32.0f); + desc.default_properties["text"] = "Button"; + luexp(register_authoring_node_type(desc)); + + desc = AuthoringNodeTypeDesc(); + desc.type = GameGUI::get_asset_instance_node_type(); + desc.name = "AssetInstance"; + desc.display_name = "Asset Instance"; + desc.category = "Composition"; + add_common_layout_schema(desc.property_schema); + add_visual_effects_schema(desc.property_schema); + { + EditingPropertyDesc property = make_property("asset", "Asset", + EditingPropertySection::property, EditingPropertyEditor::asset); + property.asset_type = GameGUI::get_asset_type(); + property.optional = false; + property.description = "GUID of the nested GameGUI asset."; + desc.property_schema.properties.push_back(move(property)); + } + desc.default_properties = make_default_layout(); + luexp(register_authoring_node_type(desc)); + } + lucatchret; + return ok; + } + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API Name get_authoring_data_unit() + { + return "Authoring"; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API Name get_authoring_asset_loader() + { + return "Luna.GameGUIEditor.Authoring"; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API RV register_authoring_node_type( + const AuthoringNodeTypeDesc& desc) + { + if(desc.type == Guid() || desc.name.empty() || desc.current_version == 0 || + desc.default_properties.type() != VariantType::object) + return E_BAD_ARGUMENTS; + const EditingSchema* schemas[] = {&desc.property_schema, + &desc.child_attachment_schema}; + for(const EditingSchema* schema : schemas) + { + for(usize i = 0; i < schema->properties.size(); ++i) + { + const EditingPropertyDesc& property = schema->properties[i]; + if(property.id.empty() || property.display_name.empty()) + return set_error(E_BAD_ARGUMENTS, + "GameGUI editing properties require an ID and display name."); + if(property.editor == EditingPropertyEditor::size && + property.alternate_id.empty()) + { + return set_error(E_BAD_ARGUMENTS, + "GameGUI size editors require an alternate percentage property."); + } + if(property.editor == EditingPropertyEditor::enumeration && + property.enumeration_items.empty()) + { + return set_error(E_BAD_ARGUMENTS, + "GameGUI enumeration editors require at least one item."); + } + if(property.bounded && property.maximum < property.minimum) + return set_error(E_BAD_ARGUMENTS, + "GameGUI editing property bounds are reversed."); + for(usize j = i + 1; j < schema->properties.size(); ++j) + { + if(property.id == schema->properties[j].id) + return set_error(E_ALREADY_EXISTS, + "A GameGUI editing schema contains duplicate property IDs."); + } + } + } + LockGuard guard(g_authoring_registry_lock); + if(g_authoring_node_types.find(desc.type) != g_authoring_node_types.end()) + return E_ALREADY_EXISTS; + for(const auto& existing : g_authoring_node_types) + { + if(existing.second.name == desc.name) return E_ALREADY_EXISTS; + } + g_authoring_node_types.insert(make_pair(desc.type, desc)); + return ok; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API R get_authoring_node_type( + const Guid& type) + { + LockGuard guard(g_authoring_registry_lock); + auto iter = g_authoring_node_types.find(type); + if(iter == g_authoring_node_types.end()) return E_NOT_FOUND; + return iter->second; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API void get_authoring_node_types( + Vector& descriptors) + { + LockGuard guard(g_authoring_registry_lock); + for(const auto& desc : g_authoring_node_types) descriptors.push_back(desc.second); + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API const AuthoringNodeRecord* find_authoring_node( + const AuthoringDocument& document, const Guid& id) + { + for(const AuthoringNodeRecord& node : document.nodes) + { + if(node.id == id) return &node; + } + return nullptr; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API AuthoringNodeRecord* find_authoring_node( + AuthoringDocument& document, const Guid& id) + { + for(AuthoringNodeRecord& node : document.nodes) + { + if(node.id == id) return &node; + } + return nullptr; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API RV validate_authoring_document( + const AuthoringDocument& document, Vector* diagnostics) + { + if(document.format_version != CURRENT_AUTHORING_FORMAT_VERSION) + return set_error(E_BAD_DATA, "The GameGUI authoring document is not current in memory."); + Ref topology = new_object(); + topology->root = document.root; + for(const AuthoringNodeRecord& source : document.nodes) + { + GameGUI::NodeRecord node; + node.id = source.id; + node.type = source.type; + node.name = source.name; + node.properties = source.properties; + for(const AuthoringChildLink& child : source.children) + { + GameGUI::ChildLink link; + link.child = child.child; + link.slot = child.slot; + link.attachment = child.attachment; + node.children.push_back(move(link)); + } + topology->nodes.push_back(move(node)); + } + return GameGUI::validate_document(*topology, diagnostics); + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API R encode_authoring_document( + const AuthoringDocument& document) + { + Variant result(VariantType::object); + result["format_version"] = (u64)CURRENT_AUTHORING_FORMAT_VERSION; + result["root"] = guid_string(document.root).c_str(); + Variant nodes(VariantType::array); + for(const AuthoringNodeRecord& node : document.nodes) + { + Variant encoded(VariantType::object); + encoded["id"] = guid_string(node.id).c_str(); + encoded["type"] = guid_string(node.type).c_str(); + encoded["type_version"] = (u64)node.type_version; + if(!node.name.empty()) encoded["name"] = node.name; + encoded["properties"] = node.properties; + Variant children(VariantType::array); + for(const AuthoringChildLink& link : node.children) + { + Variant child(VariantType::object); + child["child"] = guid_string(link.child).c_str(); + if(!link.slot.empty()) child["slot"] = link.slot; + if(link.attachment.valid()) child["attachment"] = link.attachment; + children.push_back(move(child)); + } + encoded["children"] = move(children); + nodes.push_back(move(encoded)); + } + result["nodes"] = move(nodes); + if(document.extensions.valid()) result["extensions"] = document.extensions; + return result; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API R> decode_authoring_document( + const Variant& data, Vector* diagnostics) + { + if(data.type() != VariantType::object) + return set_error(E_BAD_DATA, "GameGUI authoring document root must be an object."); + u32 version = (u32)data["format_version"].unum(0); + if(version > CURRENT_AUTHORING_FORMAT_VERSION) + return set_error(E_NOT_SUPPORTED, "GameGUI authoring document version %u is newer than %u.", + version, CURRENT_AUTHORING_FORMAT_VERSION); + Ref document = new_object(); + lutry + { + luset(document->root, decode_guid_value(data["root"], "root")); + const Variant& nodes = data["nodes"]; + if(nodes.type() != VariantType::array) + luthrow(set_error(E_BAD_DATA, "GameGUI authoring nodes must be an array.")); + for(const Variant& value : nodes.values()) + { + lulet(node, decode_node(value, version == 0)); + document->nodes.push_back(move(node)); + } + if(data.contains("extensions")) document->extensions = data["extensions"]; + document->format_version = CURRENT_AUTHORING_FORMAT_VERSION; + if(version == 0) + add_diagnostic(diagnostics, GameGUI::DiagnosticSeverity::info, Guid(), + "Migrated GameGUI authoring document version 0 to version 1."); + luexp(validate_authoring_document(*document, diagnostics)); + luexp(migrate_known_nodes(*document, diagnostics)); + } + lucatchret; + return document; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API R> cook_authoring_document( + const AuthoringDocument& source, Vector* diagnostics) + { + lutry + { + Ref document = clone_authoring_document(source); + luexp(validate_authoring_document(*document, diagnostics)); + luexp(migrate_known_nodes(*document, diagnostics)); + Ref cooked = new_object(); + cooked->root = document->root; + for(const AuthoringNodeRecord& source_node : document->nodes) + { + auto authoring_type_result = get_authoring_node_type(source_node.type); + if(!authoring_type_result.valid()) + { + add_diagnostic(diagnostics, GameGUI::DiagnosticSeverity::error, + source_node.id, + "The authoring node provider is unavailable, so this document cannot be cooked."); + luthrow(set_error(E_NOT_FOUND, + "A GameGUI authoring node provider required for cooking is unavailable.")); + } + AuthoringNodeTypeDesc authoring_type = move(authoring_type_result.get()); + if(source_node.type_version != authoring_type.current_version) + { + add_diagnostic(diagnostics, GameGUI::DiagnosticSeverity::error, + source_node.id, + "The authoring node payload version is unsupported by this cooker."); + luthrow(set_error(E_NOT_SUPPORTED, + "GameGUI node `%s` cannot be cooked by this editor.", + authoring_type.name.c_str())); + } + auto runtime_type_result = GameGUI::get_node_type(source_node.type); + if(!runtime_type_result.valid()) + { + add_diagnostic(diagnostics, GameGUI::DiagnosticSeverity::error, + source_node.id, + "The runtime node provider required for cooking is unavailable."); + luthrow(set_error(E_NOT_FOUND, + "A GameGUI runtime node provider required for cooking is unavailable.")); + } + GameGUI::NodeTypeDesc runtime_type = move(runtime_type_result.get()); + GameGUI::NodeRecord node; + node.id = source_node.id; + node.type = source_node.type; + node.name = source_node.name; + node.properties = source_node.properties; + for(const AuthoringChildLink& source_child : source_node.children) + { + GameGUI::ChildLink child; + child.child = source_child.child; + child.slot = source_child.slot; + child.attachment = source_child.attachment; + node.children.push_back(move(child)); + } + if(runtime_type.validate) + { + RV validation = runtime_type.validate(node, runtime_type.userdata.get()); + if(failed(validation)) + { + add_diagnostic(diagnostics, GameGUI::DiagnosticSeverity::error, + source_node.id, explain(validation.errcode())); + luthrow(validation.errcode()); + } + } + cooked->nodes.push_back(move(node)); + } + luexp(GameGUI::validate_document(*cooked, diagnostics)); + return cooked; + } + lucatchret; + return E_FAILURE; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API RV ensure_authoring_data_unit(Asset::asset_t asset) + { + Vector data_units; + lutry + { + luexp(Asset::get_asset_data_units(asset, data_units)); + for(const Asset::AssetDataUnitDesc& desc : data_units) + { + if(desc.id == get_authoring_data_unit()) + { + if(desc.loader != get_authoring_asset_loader()) + luthrow(set_error(E_BAD_DATA, + "The GameGUI Authoring data unit uses an unexpected loader.")); + return ok; + } + } + Asset::AssetDataUnitDesc desc; + desc.id = get_authoring_data_unit(); + desc.loader = get_authoring_asset_loader(); + luexp(Asset::add_asset_data_unit(asset, desc)); + luexp(Asset::save_asset_meta(asset)); + } + lucatchret; + return ok; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API RV initialize_authoring() + { + if(g_authoring_initialized) return ok; + Meta::register_GameGUIEditorService_types(); + register_boxed_type(); + register_boxed_type(); + impl_interface_for_type(); + register_boxed_type(); + Asset::AssetLoaderDesc loader; + loader.name = get_authoring_asset_loader(); + loader.on_load_asset_data_unit = load_authoring; + loader.on_load_asset_data_unit_default_data = load_default_authoring; + loader.on_save_asset_data_unit = save_authoring; + loader.on_set_asset_data_unit = set_authoring; + loader.on_get_referred_assets = get_authoring_references; + Asset::register_asset_loader(loader); + lutry + { + luexp(register_builtin_authoring_types()); + } + lucatchret; + g_authoring_initialized = true; + return ok; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API RV cook_asset(Asset::asset_t asset) + { + if(!asset || Asset::get_asset_type(asset) != GameGUI::get_asset_type()) + return E_BAD_ARGUMENTS; + lutry + { + luexp(ensure_authoring_data_unit(asset)); + luexp(Asset::load_asset_data_unit(asset, get_authoring_data_unit(), true)); + lulet(authoring, Asset::get_asset_data_unit_object(asset, + get_authoring_data_unit())); + if(!authoring) luthrow(E_BAD_DATA); + lulet(cooked, cook_authoring_document(*authoring)); + luexp(Asset::set_asset_data_unit_object(asset, Name(), cooked.object())); + luexp(Asset::save_asset_data_unit(asset, Name())); + } + lucatchret; + return ok; + } + } +} diff --git a/Programs/GameGUIEditor/Service/Source/GameGUIEditorService.cpp b/Programs/GameGUIEditor/Service/Source/GameGUIEditorService.cpp new file mode 100644 index 00000000..2a98debe --- /dev/null +++ b/Programs/GameGUIEditor/Service/Source/GameGUIEditorService.cpp @@ -0,0 +1,1254 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file GameGUIEditorService.cpp +* @author JXMaster +* @date 2026/8/26 +*/ +#include +#define LUNA_GAME_GUI_EDITOR_SERVICE_API LUNA_EXPORT +#include "../GameGUIEditorService.hpp" +#include "PreviewResources.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace Luna +{ + namespace GameGUIEditor + { + namespace + { + struct HistoryEntry + { + Ref document; + u64 state_id = 0; + Name label; + Name coalesce_key; + }; + + struct DocumentState + { + u64 id = 0; + u64 working_directory_id = 0; + Path creation_folder; + u64 preview_epoch = 0; + GameGUI::InstanceDesc preview; + String title; + Asset::asset_t asset; + Vector history; + usize history_index = 0; + u64 next_history_state = 2; + u64 saved_state = 0; + u64 revision = 1; + Vector diagnostics; + + Ref document() const + { + return history[history_index].document; + } + + u64 history_state() const + { + return history[history_index].state_id; + } + + bool dirty() const + { + return saved_state == 0 || history_state() != saved_state; + } + }; + + String guid_string(const Guid& guid) + { + c8 buffer[GUID_STRING_LENGTH]; + RV result = encode_guid(guid, buffer, sizeof(buffer)); + luassert(succeeded(result)); + return String(buffer, sizeof(buffer)); + } + + R guid_param(const Variant& value, const c8* field) + { + if(value.type() != VariantType::string) + { + return set_error(E_BAD_ARGUMENTS, "%s must be a GUID string.", field); + } + Guid result; + if(failed(decode_guid(value.c_str(), value.str().size(), result))) + { + return set_error(E_BAD_ARGUMENTS, "%s must be a canonical GUID string.", field); + } + return result; + } + + Ref clone_document(const AuthoringDocument& source) + { + Ref result = new_object(); + result->format_version = source.format_version; + result->root = source.root; + result->nodes = source.nodes; + result->extensions = source.extensions; + return result; + } + + Variant diagnostic_variant(const GameGUI::Diagnostic& diagnostic) + { + Variant result(VariantType::object); + switch(diagnostic.severity) + { + case GameGUI::DiagnosticSeverity::info: result["severity"] = "info"; break; + case GameGUI::DiagnosticSeverity::warning: result["severity"] = "warning"; break; + default: result["severity"] = "error"; break; + } + if(diagnostic.node != Guid()) result["node"] = guid_string(diagnostic.node).c_str(); + result["message"] = diagnostic.message.c_str(); + Variant chain(VariantType::array); + for(const Guid& guid : diagnostic.asset_mount_chain) + { + chain.push_back(guid_string(guid).c_str()); + } + result["asset_mount_chain"] = move(chain); + return result; + } + + Variant metadata_variant(const DocumentState& state) + { + Variant result(VariantType::object); + result["document_id"] = state.id; + result["working_directory_id"] = state.working_directory_id; + result["creation_folder"] = state.creation_folder.encode().c_str(); + result["title"] = state.title.c_str(); + result["revision"] = state.revision; + result["history_state"] = state.history_state(); + result["dirty"] = state.dirty(); + result["can_undo"] = state.history_index > 0; + result["can_redo"] = state.history_index + 1 < state.history.size(); + if(state.asset) + { + result["asset_guid"] = guid_string(Asset::get_asset_guid(state.asset)).c_str(); + result["asset_path"] = Asset::get_asset_path(state.asset).encode().c_str(); + } + Variant diagnostics(VariantType::array); + for(const GameGUI::Diagnostic& diagnostic : state.diagnostics) + { + diagnostics.push_back(diagnostic_variant(diagnostic)); + } + result["diagnostics"] = move(diagnostics); + return result; + } + + R find_required_node(AuthoringDocument& document, + const Variant& value) + { + auto id_result = guid_param(value, "command.node"); + if(!id_result.valid()) return id_result.errcode(); + Guid id = id_result.get(); + AuthoringNodeRecord* node = find_authoring_node(document, id); + if(!node) return set_error(E_NOT_FOUND, "The GameGUI node does not exist."); + return node; + } + + AuthoringChildLink* find_parent_link(AuthoringDocument& document, + const Guid& child, AuthoringNodeRecord** parent = nullptr) + { + for(AuthoringNodeRecord& candidate : document.nodes) + { + for(AuthoringChildLink& link : candidate.children) + { + if(link.child == child) + { + if(parent) *parent = &candidate; + return &link; + } + } + } + return nullptr; + } + + void collect_subtree(const AuthoringDocument& document, const Guid& node, + Vector& nodes) + { + nodes.push_back(node); + const AuthoringNodeRecord* record = find_authoring_node(document, node); + if(!record) return; + for(const AuthoringChildLink& child : record->children) + { + collect_subtree(document, child.child, nodes); + } + } + + bool contains_guid(const Vector& values, const Guid& value) + { + for(const Guid& candidate : values) + { + if(candidate == value) return true; + } + return false; + } + + RV insert_child(AuthoringNodeRecord& parent, AuthoringChildLink&& link, + usize index) + { + if(index > parent.children.size()) + { + return set_error(E_BAD_ARGUMENTS, "The child insertion index is out of range."); + } + parent.children.insert(parent.children.begin() + index, move(link)); + return ok; + } + + RV apply_command(AuthoringDocument& document, const Variant& command, + Vector& created_nodes) + { + lutry + { + if(command.type() != VariantType::object) + { + return set_error(E_BAD_ARGUMENTS, "Every GameGUI editor command must be an object."); + } + Name kind = command["kind"].str(); + if(kind == Name("set_name")) + { + lulet(node, find_required_node(document, command["node"])); + if(command["name"].type() != VariantType::string) + return set_error(E_BAD_ARGUMENTS, "set_name.name must be a string."); + node->name = command["name"].str(); + return ok; + } + if(kind == Name("set_property")) + { + lulet(node, find_required_node(document, command["node"])); + if(command["property"].type() != VariantType::string || + command["property"].str().empty()) + { + return set_error(E_BAD_ARGUMENTS, + "set_property.property must be a non-empty string."); + } + node->properties[command["property"].str()] = command["value"]; + return ok; + } + if(kind == Name("remove_property")) + { + lulet(node, find_required_node(document, command["node"])); + if(command["property"].type() != VariantType::string || + command["property"].str().empty()) + { + return set_error(E_BAD_ARGUMENTS, + "remove_property.property must be a non-empty string."); + } + node->properties.erase(command["property"].str()); + return ok; + } + if(kind == Name("set_properties")) + { + lulet(node, find_required_node(document, command["node"])); + if(command["properties"].type() != VariantType::object) + { + return set_error(E_BAD_ARGUMENTS, "set_properties.properties must be an object."); + } + node->properties = command["properties"]; + return ok; + } + if(kind == Name("insert_node")) + { + lulet(parent_id, guid_param(command["parent"], "insert_node.parent")); + AuthoringNodeRecord* parent = find_authoring_node(document, parent_id); + if(!parent) return set_error(E_NOT_FOUND, "The insertion parent does not exist."); + lulet(type, guid_param(command["type"], "insert_node.type")); + auto descriptor = get_authoring_node_type(type); + if(!descriptor.valid()) + { + return set_error(E_NOT_FOUND, + "A provider for the inserted GameGUI node type is not registered."); + } + Guid node_id; + if(command.contains("node")) + { + luset(node_id, guid_param(command["node"], "insert_node.node")); + if(node_id == Guid()) + return set_error(E_BAD_ARGUMENTS, "insert_node.node cannot be zero."); + } + else + { + do node_id = random_guid(); + while(find_authoring_node(document, node_id)); + } + if(find_authoring_node(document, node_id)) + return set_error(E_ALREADY_EXISTS, "The inserted GameGUI node ID already exists."); + AuthoringNodeRecord node; + node.id = node_id; + node.type = type; + node.type_version = descriptor.get().current_version; + node.name = command["name"].str(descriptor.get().display_name); + node.properties = command["properties"].type() == VariantType::object ? + command["properties"] : descriptor.get().default_properties; + if(node.properties.type() != VariantType::object) + node.properties = Variant(VariantType::object); + AuthoringChildLink link; + link.child = node_id; + link.slot = command["slot"].str(); + if(command.contains("attachment")) link.attachment = command["attachment"]; + usize index = command.contains("index") ? + (usize)command["index"].unum() : parent->children.size(); + luexp(insert_child(*parent, move(link), index)); + document.nodes.push_back(move(node)); + created_nodes.push_back(node_id); + return ok; + } + if(kind == Name("set_root")) + { + lulet(node, guid_param(command["node"], "set_root.node")); + if(node == document.root) + return set_error(E_BAD_ARGUMENTS, "The GameGUI node is already the root."); + AuthoringNodeRecord* new_root = find_authoring_node(document, node); + if(!new_root) + return set_error(E_NOT_FOUND, "The new root GameGUI node does not exist."); + AuthoringNodeRecord* old_parent = nullptr; + AuthoringChildLink* old_link = find_parent_link(document, node, + &old_parent); + if(!old_link || !old_parent) + { + return set_error(E_NOT_FOUND, + "The new root GameGUI node is not attached to the document tree."); + } + for(usize i = 0; i < old_parent->children.size(); ++i) + { + if(old_parent->children[i].child == node) + { + old_parent->children.erase(old_parent->children.begin() + i); + break; + } + } + AuthoringChildLink old_root_link; + old_root_link.child = document.root; + new_root->children.push_back(move(old_root_link)); + document.root = node; + return ok; + } + if(kind == Name("remove_node")) + { + lulet(node, guid_param(command["node"], "remove_node.node")); + if(node == document.root) + return set_error(E_BAD_ARGUMENTS, "The root GameGUI node cannot be removed."); + AuthoringNodeRecord* parent = nullptr; + AuthoringChildLink* link = find_parent_link(document, node, &parent); + if(!link || !parent) + return set_error(E_NOT_FOUND, "The removed GameGUI node does not exist."); + for(usize i = 0; i < parent->children.size(); ++i) + { + if(parent->children[i].child == node) + { + parent->children.erase(parent->children.begin() + i); + break; + } + } + Vector removed; + collect_subtree(document, node, removed); + Vector kept; + kept.reserve(document.nodes.size() - removed.size()); + for(AuthoringNodeRecord& record : document.nodes) + { + if(!contains_guid(removed, record.id)) kept.push_back(move(record)); + } + document.nodes = move(kept); + return ok; + } + if(kind == Name("move_node")) + { + lulet(node, guid_param(command["node"], "move_node.node")); + lulet(new_parent_id, guid_param(command["parent"], "move_node.parent")); + if(node == document.root) + return set_error(E_BAD_ARGUMENTS, "The root GameGUI node cannot be moved."); + AuthoringNodeRecord* new_parent = find_authoring_node(document, new_parent_id); + if(!new_parent) + return set_error(E_NOT_FOUND, "The new GameGUI parent does not exist."); + AuthoringNodeRecord* old_parent = nullptr; + AuthoringChildLink* old_link = find_parent_link(document, node, &old_parent); + if(!old_link || !old_parent) + return set_error(E_NOT_FOUND, "The moved GameGUI node does not exist."); + AuthoringChildLink link = *old_link; + if(command.contains("slot")) link.slot = command["slot"].str(); + if(command.contains("attachment")) link.attachment = command["attachment"]; + for(usize i = 0; i < old_parent->children.size(); ++i) + { + if(old_parent->children[i].child == node) + { + old_parent->children.erase(old_parent->children.begin() + i); + break; + } + } + usize index = command.contains("index") ? + (usize)command["index"].unum() : new_parent->children.size(); + return insert_child(*new_parent, move(link), index); + } + if(kind == Name("set_attachment")) + { + lulet(node, guid_param(command["node"], "set_attachment.node")); + AuthoringChildLink* link = find_parent_link(document, node); + if(!link) + return set_error(E_NOT_FOUND, "The attached GameGUI node does not exist."); + if(command.contains("slot")) link->slot = command["slot"].str(); + link->attachment = command["attachment"]; + return ok; + } + return set_error(E_BAD_ARGUMENTS, "Unknown GameGUI editor command kind `%s`.", + kind.c_str()); + } + lucatchret; + return E_FAILURE; + } + + const c8* editing_section_name(EditingPropertySection section) + { + switch(section) + { + case EditingPropertySection::layout: return "layout"; + case EditingPropertySection::style: return "style"; + case EditingPropertySection::property: return "property"; + } + return "property"; + } + + const c8* editing_editor_name(EditingPropertyEditor editor) + { + switch(editor) + { + case EditingPropertyEditor::boolean: return "boolean"; + case EditingPropertyEditor::number: return "number"; + case EditingPropertyEditor::string: return "string"; + case EditingPropertyEditor::name: return "name"; + case EditingPropertyEditor::enumeration: return "enumeration"; + case EditingPropertyEditor::float2: return "float2"; + case EditingPropertyEditor::float4: return "float4"; + case EditingPropertyEditor::color: return "color"; + case EditingPropertyEditor::visual_effects: return "visual_effects"; + case EditingPropertyEditor::size: return "size"; + case EditingPropertyEditor::asset: return "asset"; + case EditingPropertyEditor::json: return "json"; + } + return "json"; + } + + Variant editing_schema_variant(const EditingSchema& schema) + { + Variant result(VariantType::object); + Variant properties(VariantType::array); + for(const EditingPropertyDesc& desc : schema.properties) + { + Variant property(VariantType::object); + property["id"] = desc.id; + if(!desc.alternate_id.empty()) property["alternate_id"] = desc.alternate_id; + property["display_name"] = desc.display_name.c_str(); + if(!desc.description.empty()) property["description"] = desc.description.c_str(); + property["section"] = editing_section_name(desc.section); + property["editor"] = editing_editor_name(desc.editor); + property["has_default"] = desc.has_default; + if(desc.has_default) property["default_value"] = desc.default_value; + property["optional"] = desc.optional; + property["bounded"] = desc.bounded; + if(desc.bounded) + { + property["minimum"] = desc.minimum; + property["maximum"] = desc.maximum; + } + property["step"] = desc.step; + if(!desc.enumeration_items.empty()) + { + Variant items(VariantType::array); + for(const EditingEnumItemDesc& item_desc : desc.enumeration_items) + { + Variant item(VariantType::object); + item["value"] = item_desc.value; + item["display_name"] = item_desc.display_name.c_str(); + items.push_back(move(item)); + } + property["items"] = move(items); + } + if(!desc.asset_type.empty()) property["asset_type"] = desc.asset_type; + properties.push_back(move(property)); + } + result["properties"] = move(properties); + return result; + } + + Variant node_type_variant(const AuthoringNodeTypeDesc& desc) + { + Variant result(VariantType::object); + result["type"] = guid_string(desc.type).c_str(); + result["name"] = desc.name; + result["display_name"] = desc.display_name; + result["category"] = desc.category; + result["current_version"] = (u64)desc.current_version; + result["property_schema"] = editing_schema_variant(desc.property_schema); + result["child_attachment_schema"] = + editing_schema_variant(desc.child_attachment_schema); + result["slot_schema"] = desc.slot_schema; + result["default_properties"] = desc.default_properties; + auto runtime = GameGUI::get_node_type(desc.type); + result["nested_document"] = runtime.valid() && runtime.get().nested_document; + return result; + } + } + + class ServiceImpl + { + public: + Ref frontend; + HashMap> documents; + HashMap asset_documents; + Vector document_order; + u64 next_document_id = 1; + u64 next_untitled_id = 1; + WorkingDirectories working_directories; + u64 preview_epoch = 1; + u64 next_close_token = 1; + + void invalidate_previews() + { + ++preview_epoch; + for(auto& entry : documents) + { + entry.second->preview = GameGUI::InstanceDesc(); + entry.second->preview_epoch = 0; + } + } + + void refresh_diagnostics(DocumentState& state) + { + if(state.preview_epoch == preview_epoch) return; + state.preview = GameGUI::InstanceDesc(); + state.diagnostics.clear(); + auto prepared = prepare_editor_preview(*state.document(), state.asset, working_directories, + [this](Asset::asset_t asset) -> Ref + { + auto opened = asset_documents.find(Asset::get_asset_guid(asset)); + if(opened == asset_documents.end()) return nullptr; + return documents.find(opened->second)->second->document(); + }); + if(prepared.valid()) + { + state.preview = move(prepared.get()); + auto instance = GameGUI::new_instance(state.preview); + RV result = instance->prepare(); + for(const auto& diagnostic : instance->get_diagnostics()) state.diagnostics.push_back(diagnostic); + if(failed(result) && state.diagnostics.empty()) + { + GameGUI::Diagnostic diagnostic; + diagnostic.severity = GameGUI::DiagnosticSeverity::error; + diagnostic.message = explain(result.errcode()); + state.diagnostics.push_back(move(diagnostic)); + } + } + else + { + GameGUI::Diagnostic diagnostic; + diagnostic.severity = GameGUI::DiagnosticSeverity::error; + diagnostic.message = explain(prepared.errcode()); + state.diagnostics.push_back(move(diagnostic)); + } + state.preview_epoch = preview_epoch; + } + + R open_directory(const Variant& params) + { + if(params["native_path"].type() != VariantType::string || params["native_path"].str().empty()) + return set_error(E_BAD_ARGUMENTS, "native_path must name a directory."); + auto opened = working_directories.open(Path(params["native_path"].c_str())); + if(!opened.valid()) return opened.errcode(); + invalidate_previews(); + return working_directories.describe(*opened.get(), true); + } + + R list_directories(const Variant&) + { + Variant result(VariantType::array); + for(auto& directory : working_directories.directories) + result.push_back(working_directories.describe(*directory, true)); + return result; + } + + R resolve_path(const Variant& params) + { + lutry + { + lulet(path, working_directories.resolve_native(Path(params["native_path"].c_str()))); + lulet(directory, working_directories.owner(path, true)); + Variant result(VariantType::object); + result["path"] = path.encode().c_str(); + result["working_directory_id"] = directory->id; + return result; + } + lucatchret; + return E_FAILURE; + } + + R prepare_close_directory(const Variant& params) + { + lutry + { + lulet(directory, working_directories.get(params["working_directory_id"].unum(), true)); + directory->closing = true; + directory->close_token = next_close_token++; + Variant result = working_directories.describe(*directory); + result["close_token"] = directory->close_token; + result["documents"] = Variant(VariantType::array); + for(u64 id : document_order) + { + auto& state = *documents.find(id)->second; + if(state.working_directory_id == directory->id) + result["documents"].push_back(metadata_variant(state)); + } + return result; + } + lucatchret; + return E_FAILURE; + } + + R closing_directory(const Variant& params) + { + auto result = working_directories.get(params["working_directory_id"].unum(), true); + if(!result.valid()) return result.errcode(); + auto directory = result.get(); + if(!directory->closing || !directory->close_token || directory->close_token != params["close_token"].unum()) + return set_error(E_BUSY, "The directory close token is stale."); + return directory; + } + + R cancel_close_directory(const Variant& params) + { + lutry + { + lulet(directory, closing_directory(params)); + // An unsuccessful VFS cleanup must be retried before normal use. + if(!directory->registered) luthrow(E_BUSY); + directory->closing = false; + directory->close_token = 0; + invalidate_previews(); + return working_directories.describe(*directory); + } + lucatchret; + return E_FAILURE; + } + + R close_directory(const Variant& params) + { + lutry + { + lulet(directory, closing_directory(params)); + Vector closing_documents; + for(u64 id : document_order) + { + auto& state = *documents.find(id)->second; + if(state.working_directory_id != directory->id) continue; + const Variant* decision = nullptr; + for(const Variant& item : params["documents"].values()) + if(item["document_id"].unum() == id) { decision = &item; break; } + if(!decision || (*decision)["expected_revision"].unum() != state.revision) + luthrow(set_error(E_BUSY, "The document close decision is missing or stale.")); + if(state.dirty() && !(*decision)["discard"].boolean()) + luthrow(set_error(E_BUSY, "Unsaved changes require Save or explicit Discard.")); + closing_documents.push_back(id); + } + invalidate_previews(); + luexp(working_directories.close(*directory)); + for(u64 id : closing_documents) + { + auto& state = *documents.find(id)->second; + if(state.asset) asset_documents.erase(Asset::get_asset_guid(state.asset)); + documents.erase(id); + for(usize i = 0; i < document_order.size(); ++i) + if(document_order[i] == id) { document_order.erase(document_order.begin() + i); break; } + } + Variant result(VariantType::object); + result["closed"] = true; + return result; + } + lucatchret; + return E_FAILURE; + } + + R refresh_directory(const Variant& params) + { + lutry + { + lulet(directory, working_directories.get(params["working_directory_id"].unum())); + for(auto& entry : documents) + if(entry.second->working_directory_id == directory->id && entry.second->dirty()) + luthrow(set_error(E_BUSY, "Save or close modified documents before refreshing this directory.")); + if(directory->database->is_dirty()) luthrow(set_error(E_BUSY, "Save pending metadata before refreshing.")); + lulet(candidate, directory->database->read_snapshot()); + for(auto& entry : documents) + { + auto& state = *entry.second; + if(state.working_directory_id != directory->id || !state.asset) continue; + bool compatible = false; + for(const auto& record : candidate) + { + if(record.guid != Asset::get_asset_guid(state.asset) || record.type != GameGUI::get_asset_type()) continue; + for(const auto& unit : record.data_units) + if(unit.id == get_authoring_data_unit() && unit.loader == get_authoring_asset_loader()) compatible = true; + } + if(!compatible) luthrow(set_error(E_BUSY, + "Close documents whose metadata was removed or changed to an incompatible type before refreshing.")); + } + invalidate_previews(); + luexp(working_directories.unload_data(*directory)); + luexp(Asset::reload_asset_database(directory->database)); + luexp(working_directories.rebuild_index(*directory)); + // Read candidates before replacing any open document snapshot. + Vector>> replacements; + for(auto& entry : documents) + { + auto& state = *entry.second; + if(state.working_directory_id != directory->id || !state.asset) continue; + luexp(Asset::load_asset_data_unit(state.asset, get_authoring_data_unit())); + lulet(source, Asset::get_asset_data_unit_object(state.asset, get_authoring_data_unit())); + if(!source) luthrow(E_BAD_DATA); + replacements.push_back(make_pair(&state, clone_document(*source))); + } + for(auto& replacement : replacements) + { + auto& state = *replacement.first; + HistoryEntry entry; + entry.document = replacement.second; + entry.state_id = state.next_history_state++; + entry.label = "Reload"; + state.history.clear(); + state.history.push_back(move(entry)); + state.history_index = 0; + state.saved_state = state.history_state(); + state.title = Asset::get_asset_name(state.asset).c_str(); + ++state.revision; + } + return working_directories.describe(*directory, true); + } + lucatchret; + return E_FAILURE; + } + + R get_document(const Variant& params) + { + if(params.type() != VariantType::object || + params["document_id"].type() != VariantType::number) + { + return set_error(E_BAD_ARGUMENTS, "document_id must be provided as an integer."); + } + u64 id = params["document_id"].unum(); + auto iter = documents.find(id); + if(iter == documents.end()) + return set_error(E_NOT_FOUND, "The GameGUI editor document is not open."); + return iter->second.get(); + } + + RV check_revision(const DocumentState& document, const Variant& params, bool saving = false) + { + auto directory = working_directories.get(document.working_directory_id, true); + if(!directory.valid()) return directory.errcode(); + if(directory.get()->closing && (!saving || + params["close_token"].unum() != directory.get()->close_token)) + return set_error(E_BUSY, "The working directory is closing."); + if(params["expected_revision"].type() != VariantType::number) + { + return set_error(E_BAD_ARGUMENTS, "expected_revision must be provided as an integer."); + } + u64 expected = params["expected_revision"].unum(); + if(expected != document.revision) + { + return set_error(E_BUSY, + "The GameGUI editor document revision is stale (expected %llu, current %llu).", + (unsigned long long)expected, (unsigned long long)document.revision); + } + return ok; + } + + DocumentState* add_document(const Ref& source, const c8* title, + Asset::asset_t asset, bool saved, u64 directory_id) + { + UniquePtr state(memnew()); + state->id = next_document_id++; + state->working_directory_id = directory_id; + state->title = title; + state->asset = asset; + HistoryEntry entry; + entry.document = clone_document(*source); + entry.state_id = 1; + entry.label = "Initial"; + state->history.push_back(move(entry)); + state->saved_state = saved ? 1 : 0; + invalidate_previews(); + refresh_diagnostics(*state); + u64 id = state->id; + DocumentState* result = state.get(); + documents.insert(make_pair(id, move(state))); + document_order.push_back(id); + if(asset) asset_documents.insert_or_assign(Asset::get_asset_guid(asset), id); + return result; + } + + R create(const Variant& params) + { + u64 directory_id = params["working_directory_id"].unum(); + if(!directory_id && working_directories.directories.size() == 1) + directory_id = working_directories.directories[0]->id; + auto directory = working_directories.get(directory_id); + if(!directory.valid()) return directory.errcode(); + Path folder(params["relative_directory"].c_str()); + auto checked_folder = directory.get()->file_system->open_dir(folder); + if(!checked_folder.valid()) return checked_folder.errcode(); + AuthoringNodeRecord root; + root.id = random_guid(); + root.type = GameGUI::get_flex_node_type(); + auto descriptor = get_authoring_node_type(root.type); + if(!descriptor.valid()) return descriptor.errcode(); + root.type_version = descriptor.get().current_version; + root.name = "Root"; + root.properties = descriptor.get().default_properties; + Ref document = new_object(); + document->root = root.id; + document->nodes.push_back(move(root)); + String title; + strprintf(title, "Untitled %llu", (unsigned long long)next_untitled_id++); + DocumentState* state = add_document(document, title.c_str(), Asset::asset_t(), false, directory_id); + state->creation_folder = folder; + return metadata_variant(*state); + } + + R open(const Variant& params) + { + lutry + { + if(params.type() != VariantType::object) + luthrow(set_error(E_BAD_ARGUMENTS, "Open parameters must be an object.")); + Asset::asset_t asset; + if(params["asset_guid"].type() == VariantType::string) + { + lulet(guid, guid_param(params["asset_guid"], "asset_guid")); + auto existing = asset_documents.find(guid); + if(existing != asset_documents.end()) + { + auto& existing_state = *documents.find(existing->second)->second; + luexp(working_directories.get(existing_state.working_directory_id)); + refresh_diagnostics(existing_state); + return metadata_variant(existing_state); + } + asset = Asset::get_asset(guid); + } + else if(params["path"].type() == VariantType::string) + { + Path path(params["path"].c_str()); + lulet(path_asset, Asset::get_asset_by_path(path)); + asset = path_asset; + Guid guid = Asset::get_asset_guid(asset); + auto existing = asset_documents.find(guid); + if(existing != asset_documents.end()) + { + auto& existing_state = *documents.find(existing->second)->second; + luexp(working_directories.get(existing_state.working_directory_id)); + refresh_diagnostics(existing_state); + return metadata_variant(existing_state); + } + } + else + { + luthrow(set_error(E_BAD_ARGUMENTS, + "Open requires either asset_guid or path.")); + } + auto main_state = Asset::get_asset_data_unit_state(asset, Name()); + if(!main_state.valid() || + main_state.get() == Asset::AssetDataUnitState::unregistered) + luthrow(set_error(E_NOT_FOUND, "The GameGUI asset is not registered.")); + if(Asset::get_asset_type(asset) != GameGUI::get_asset_type()) + luthrow(set_error(E_BAD_ARGUMENTS, "The selected asset is not a GameGUI document.")); + lulet(directory, working_directories.owner(Asset::get_asset_path(asset))); + lulet(source, load_editor_authoring(asset)); + DocumentState* state = add_document(source, + Asset::get_asset_name(asset).c_str(), asset, true, directory->id); + return metadata_variant(*state); + } + lucatchret; + return E_FAILURE; + } + + R list(const Variant& params) + { + Variant result(VariantType::array); + for(u64 id : document_order) + { + auto iter = documents.find(id); + if(iter != documents.end()) + { + refresh_diagnostics(*iter->second); + result.push_back(metadata_variant(*iter->second)); + } + } + return result; + } + + R snapshot(const Variant& params) + { + lutry + { + lulet(state, get_document(params)); + refresh_diagnostics(*state); + Variant result = metadata_variant(*state); + lulet(document, encode_authoring_document(*state->document())); + result["document"] = move(document); + return result; + } + lucatchret; + return E_FAILURE; + } + + R apply(const Variant& params) + { + lutry + { + lulet(state, get_document(params)); + luexp(check_revision(*state, params)); + const Variant& commands = params["commands"]; + if(commands.type() != VariantType::array || commands.empty()) + luthrow(set_error(E_BAD_ARGUMENTS, "commands must be a non-empty array.")); + Ref edited = clone_document(*state->document()); + Vector created; + for(const Variant& command : commands.values()) + { + luexp(apply_command(*edited, command, created)); + } + luexp(validate_authoring_document(*edited)); + Name coalesce_key = params["coalesce_key"].str(); + bool coalesce = !coalesce_key.empty() && state->history_index > 0 && + state->history_index + 1 == state->history.size() && + state->history[state->history_index].coalesce_key == coalesce_key && + state->history_state() != state->saved_state; + if(coalesce) + { + state->history[state->history_index].document = edited; + } + else + { + if(state->history_index + 1 < state->history.size()) + { + state->history.erase(state->history.begin() + state->history_index + 1, + state->history.end()); + } + HistoryEntry entry; + entry.document = edited; + entry.state_id = state->next_history_state++; + entry.label = params["label"].str("Edit"); + entry.coalesce_key = coalesce_key; + state->history.push_back(move(entry)); + state->history_index = state->history.size() - 1; + } + ++state->revision; + invalidate_previews(); + refresh_diagnostics(*state); + Variant result = metadata_variant(*state); + Variant created_result(VariantType::array); + for(const Guid& id : created) + created_result.push_back(guid_string(id).c_str()); + result["created_nodes"] = move(created_result); + return result; + } + lucatchret; + return E_FAILURE; + } + + R undo(const Variant& params) + { + lutry + { + lulet(state, get_document(params)); + luexp(check_revision(*state, params)); + if(state->history_index == 0) + luthrow(set_error(E_BAD_CALLING_TIME, "The GameGUI editor document cannot be undone.")); + --state->history_index; + ++state->revision; + invalidate_previews(); + refresh_diagnostics(*state); + return metadata_variant(*state); + } + lucatchret; + return E_FAILURE; + } + + R redo(const Variant& params) + { + lutry + { + lulet(state, get_document(params)); + luexp(check_revision(*state, params)); + if(state->history_index + 1 >= state->history.size()) + luthrow(set_error(E_BAD_CALLING_TIME, "The GameGUI editor document cannot be redone.")); + ++state->history_index; + ++state->revision; + invalidate_previews(); + refresh_diagnostics(*state); + return metadata_variant(*state); + } + lucatchret; + return E_FAILURE; + } + + R save(const Variant& params) + { + lutry + { + lulet(state, get_document(params)); + luexp(check_revision(*state, params, true)); + if(!state->asset) + luthrow(set_error(E_BAD_CALLING_TIME, "An untitled document must be saved with SaveAs.")); + lulet(directory, working_directories.get(state->working_directory_id, true)); + luexp(persist(*state, state->asset, *directory)); + state->saved_state = state->history_state(); + ++state->revision; + invalidate_previews(); + refresh_diagnostics(*state); + return metadata_variant(*state); + } + lucatchret; + return E_FAILURE; + } + + RV persist(DocumentState& state, Asset::asset_t asset, WorkingDirectory& directory) + { + working_directories.track_asset(directory, asset); + lutry + { + luexp(ensure_authoring_data_unit(asset)); + luexp(Asset::set_asset_data_unit_object(asset, get_authoring_data_unit(), state.document().object())); + luexp(Asset::save_asset_data_unit(asset, get_authoring_data_unit())); + luexp(Asset::save_asset_meta(asset)); + luexp(working_directories.flush(directory)); + // Update only the saved entry; unrelated directory scans are not part of a save. + working_directories.track_asset(directory, asset); + } + lucatchret; + return ok; + } + + R save_as(const Variant& params) + { + lutry + { + lulet(state, get_document(params)); + luexp(check_revision(*state, params, true)); + if(params["path"].type() != VariantType::string || params["path"].str().empty()) + luthrow(set_error(E_BAD_ARGUMENTS, "SaveAs requires an asset path.")); + Path path(params["path"].c_str()); + luexp(working_directories.validate_asset_path(path, true)); + lulet(directory, working_directories.owner(path, true)); + if(directory->closing && (state->working_directory_id != directory->id || + params["close_token"].unum() != directory->close_token)) luthrow(E_BUSY); + auto existing = Asset::get_asset_by_path(path); + if(existing.valid()) + { + if(Asset::get_asset_type(existing.get()) != GameGUI::get_asset_type()) + luthrow(set_error(E_BAD_ARGUMENTS, "The destination belongs to another asset type.")); + auto opened = asset_documents.find(Asset::get_asset_guid(existing.get())); + if(opened != asset_documents.end() && opened->second != state->id) + luthrow(set_error(E_ALREADY_EXISTS, "The destination asset is open in another document.")); + if(existing.get() != state->asset && !params["overwrite"].boolean()) + luthrow(set_error(E_ALREADY_EXISTS, "Replacing an existing asset requires overwrite=true.")); + } + else if(existing.errcode() != E_NOT_FOUND) luthrow(existing.errcode()); + if(!existing.valid()) + { + Path source = path; + source.append_extension("json"); + auto payload = VFS::get_file_attribute(source); + if(payload.valid()) luthrow(set_error(E_ALREADY_EXISTS, + "An unregistered file already exists at the destination. Choose another name.")); + if(payload.errcode() != E_NOT_FOUND) luthrow(payload.errcode()); + } + lulet(asset, Asset::new_asset(path, GameGUI::get_asset_type(), true)); + luexp(persist(*state, asset, *directory)); + if(state->asset) asset_documents.erase(Asset::get_asset_guid(state->asset)); + state->asset = asset; + state->working_directory_id = directory->id; + asset_documents.insert_or_assign(Asset::get_asset_guid(asset), state->id); + state->title = Asset::get_asset_name(asset).c_str(); + state->saved_state = state->history_state(); + ++state->revision; + invalidate_previews(); + refresh_diagnostics(*state); + return metadata_variant(*state); + } + lucatchret; + return E_FAILURE; + } + + R close(const Variant& params) + { + lutry + { + lulet(state, get_document(params)); + luexp(check_revision(*state, params)); + if(state->dirty() && !params["discard"].boolean(false)) + { + luthrow(set_error(E_BUSY, + "The GameGUI editor document has unsaved changes; explicit discard is required.")); + } + u64 id = state->id; + if(state->asset) + { + auto loaded = Asset::get_asset_data_unit_state(state->asset, get_authoring_data_unit()); + if(loaded.valid() && loaded.get() == Asset::AssetDataUnitState::loaded) + luexp(Asset::set_asset_data_unit_object(state->asset, get_authoring_data_unit(), nullptr)); + } + invalidate_previews(); + if(state->asset) asset_documents.erase(Asset::get_asset_guid(state->asset)); + documents.erase(id); + for(usize i = 0; i < document_order.size(); ++i) + { + if(document_order[i] == id) + { + document_order.erase(document_order.begin() + i); + break; + } + } + Variant result(VariantType::object); + result["closed"] = true; + result["document_id"] = id; + return result; + } + lucatchret; + return E_FAILURE; + } + + R cook(const Variant& params) + { + lutry + { + lulet(state, get_document(params)); + luexp(check_revision(*state, params)); + if(!state->asset) + luthrow(set_error(E_BAD_CALLING_TIME, + "An untitled GameGUI document must be saved before cooking.")); + Vector diagnostics; + lulet(cooked, cook_authoring_document(*state->document(), &diagnostics)); + luexp(Asset::set_asset_data_unit_object(state->asset, Name(), cooked.object())); + luexp(Asset::save_asset_data_unit(state->asset, Name())); + lulet(directory, working_directories.get(state->working_directory_id)); + luexp(working_directories.flush(*directory)); + return metadata_variant(*state); + } + lucatchret; + return E_FAILURE; + } + + R node_types(const Variant& params) + { + Vector descriptors; + get_authoring_node_types(descriptors); + Variant result(VariantType::array); + for(const AuthoringNodeTypeDesc& descriptor : descriptors) + result.push_back(node_type_variant(descriptor)); + return result; + } + + RV install() + { + frontend = Frontend::new_frontend(); + lutry + { + luexp(frontend->set_resource_function(OPEN_DIRECTORY_URL, + [this](Frontend::IFrontend*, const Variant& params) { return open_directory(params); })); + luexp(frontend->set_resource_function(LIST_DIRECTORIES_URL, + [this](Frontend::IFrontend*, const Variant& params) { return list_directories(params); })); + luexp(frontend->set_resource_function(RESOLVE_PATH_URL, + [this](Frontend::IFrontend*, const Variant& params) { return resolve_path(params); })); + luexp(frontend->set_resource_function(REFRESH_DIRECTORY_URL, + [this](Frontend::IFrontend*, const Variant& params) { return refresh_directory(params); })); + luexp(frontend->set_resource_function(PREPARE_CLOSE_DIRECTORY_URL, + [this](Frontend::IFrontend*, const Variant& params) { return prepare_close_directory(params); })); + luexp(frontend->set_resource_function(CANCEL_CLOSE_DIRECTORY_URL, + [this](Frontend::IFrontend*, const Variant& params) { return cancel_close_directory(params); })); + luexp(frontend->set_resource_function(CLOSE_DIRECTORY_URL, + [this](Frontend::IFrontend*, const Variant& params) { return close_directory(params); })); + luexp(frontend->set_resource_function(CREATE_DOCUMENT_URL, + [this](Frontend::IFrontend*, const Variant& params) { return create(params); })); + luexp(frontend->set_resource_function(OPEN_DOCUMENT_URL, + [this](Frontend::IFrontend*, const Variant& params) { return open(params); })); + luexp(frontend->set_resource_function(LIST_DOCUMENTS_URL, + [this](Frontend::IFrontend*, const Variant& params) { return list(params); })); + luexp(frontend->set_resource_function(GET_SNAPSHOT_URL, + [this](Frontend::IFrontend*, const Variant& params) { return snapshot(params); })); + luexp(frontend->set_resource_function(APPLY_COMMANDS_URL, + [this](Frontend::IFrontend*, const Variant& params) { return apply(params); })); + luexp(frontend->set_resource_function(UNDO_URL, + [this](Frontend::IFrontend*, const Variant& params) { return undo(params); })); + luexp(frontend->set_resource_function(REDO_URL, + [this](Frontend::IFrontend*, const Variant& params) { return redo(params); })); + luexp(frontend->set_resource_function(SAVE_URL, + [this](Frontend::IFrontend*, const Variant& params) { return save(params); })); + luexp(frontend->set_resource_function(SAVE_AS_URL, + [this](Frontend::IFrontend*, const Variant& params) { return save_as(params); })); + luexp(frontend->set_resource_function(COOK_URL, + [this](Frontend::IFrontend*, const Variant& params) { return cook(params); })); + luexp(frontend->set_resource_function(CLOSE_DOCUMENT_URL, + [this](Frontend::IFrontend*, const Variant& params) { return close(params); })); + luexp(frontend->set_resource_function(GET_NODE_TYPES_URL, + [this](Frontend::IFrontend*, const Variant& params) { return node_types(params); })); + } + lucatchret; + return ok; + } + }; + + Service::Service() : m_impl(memnew()) {} + + Service::~Service() + { + m_impl->frontend.reset(); + m_impl->documents.clear(); + while(!m_impl->working_directories.directories.empty()) + { + auto& directory = *m_impl->working_directories.directories.back(); + RV closed = m_impl->working_directories.close(directory); + if(failed(closed)) + { + log_error("GameGUIEditor", "Directory cleanup failed: %s", explain(closed.errcode())); + break; + } + } + memdelete(m_impl); + } + + RV Service::init() + { + return m_impl->install(); + } + + Frontend::IFrontend* Service::frontend() const + { + return m_impl->frontend; + } + + u64 Service::preview_revision() const { return m_impl->preview_epoch; } + + R Service::prepare_preview(u64 document_id) + { + auto found = m_impl->documents.find(document_id); + if(found == m_impl->documents.end()) return E_NOT_FOUND; + m_impl->refresh_diagnostics(*found->second); + return found->second->preview; + } + + LUNA_GAME_GUI_EDITOR_SERVICE_API R> new_service() + { + RV authoring_result = initialize_authoring(); + if(failed(authoring_result)) return authoring_result.errcode(); + void* memory = memalloc(sizeof(Service), alignof(Service)); + UniquePtr service(new (memory) Service()); + RV result = service->init(); + if(failed(result)) return result.errcode(); + return service; + } + } +} diff --git a/Programs/GameGUIEditor/Service/Source/PreviewResources.cpp b/Programs/GameGUIEditor/Service/Source/PreviewResources.cpp new file mode 100644 index 00000000..1660dc04 --- /dev/null +++ b/Programs/GameGUIEditor/Service/Source/PreviewResources.cpp @@ -0,0 +1,112 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file PreviewResources.cpp +* @author JXMaster +* @date 2026/9/11 +*/ +#include "PreviewResources.hpp" +#include +#include + +namespace Luna::GameGUIEditor +{ + namespace + { + R resolve_preview_resource(object_t userdata, Asset::asset_t asset) + { + auto resources = cast_object(userdata); + Guid guid = Asset::get_asset_guid(asset); + auto found = resources->objects.find(guid); + if(found != resources->objects.end() && found->second) return found->second; + auto error = resources->errors.find(guid); + return set_error(E_NOT_FOUND, "%s", error == resources->errors.end() ? + "The preview dependency is unavailable in the opened working directories." : error->second.c_str()); + } + } + + R> load_editor_authoring(Asset::asset_t asset) + { + lutry + { + auto loader = Asset::get_asset_data_unit_loader(asset, get_authoring_data_unit()); + if(!loader.valid() || loader.get() != get_authoring_asset_loader()) + luthrow(set_error(E_NOT_SUPPORTED, "This GameGUI asset has no compatible Authoring data unit.")); + luexp(Asset::load_asset_data_unit(asset, get_authoring_data_unit())); + lulet(source, Asset::get_asset_data_unit_object(asset, get_authoring_data_unit())); + if(!source) luthrow(set_error(E_BAD_DATA, "The GameGUI Authoring data is missing.")); + return source; + } + lucatchret; + return E_FAILURE; + } + + R prepare_editor_preview(const AuthoringDocument& source, + Asset::asset_t asset, WorkingDirectories& directories, + const Function(Asset::asset_t)>& working_copy) + { + GameGUI::InstanceDesc desc; + lutry + { + luset(desc.document, cook_authoring_document(source)); + desc.source_asset = asset; + auto resources = new_object(); + desc.resource_resolver.userdata = resources.object(); + desc.resource_resolver.resolve = resolve_preview_resource; + if(asset) resources->objects.insert_or_assign(Asset::get_asset_guid(asset), desc.document.object()); + Vector> pending; + pending.push_back(desc.document); + for(usize i = 0; i < pending.size(); ++i) + { + Vector references; + for(const auto& node : pending[i]->nodes) + { + auto type = GameGUI::get_node_type(node.type); + if(type.valid() && type.get().collect_assets) + type.get().collect_assets(node, references, type.get().userdata.get()); + } + for(Asset::asset_t reference : references) + { + if(!reference) continue; + Guid guid = Asset::get_asset_guid(reference); + if(resources->objects.contains(guid)) continue; + resources->objects.insert_or_assign(guid, ObjRef()); + auto load = [&]() -> RV + { + lutry + { + luexp(directories.owner(Asset::get_asset_path(reference))); + if(Asset::get_asset_type(reference) == GameGUI::get_asset_type()) + { + Ref authoring = working_copy(reference); + if(!authoring) + { + // Loading an existing unit must never add or save metadata. + luset(authoring, load_editor_authoring(reference)); + } + if(!authoring) luthrow(E_BAD_DATA); + lulet(cooked, cook_authoring_document(*authoring)); + resources->objects.insert_or_assign(guid, cooked.object()); + pending.push_back(cooked); + } + else + { + luexp(Asset::load_asset_data_unit(reference, Name())); + lulet(object, Asset::get_asset_data_unit_object(reference, Name())); + resources->objects.insert_or_assign(guid, object); + } + } + lucatchret; + return ok; + }; + RV loaded = load(); + if(failed(loaded)) resources->errors.insert_or_assign(guid, String(explain(loaded.errcode()))); + } + } + } + lucatchret; + return desc; + } +} diff --git a/Programs/GameGUIEditor/Service/Source/PreviewResources.hpp b/Programs/GameGUIEditor/Service/Source/PreviewResources.hpp new file mode 100644 index 00000000..76185b68 --- /dev/null +++ b/Programs/GameGUIEditor/Service/Source/PreviewResources.hpp @@ -0,0 +1,31 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file PreviewResources.hpp +* @author JXMaster +* @date 2026/9/11 +*/ +#pragma once +#include "WorkingDirectories.hpp" +#include "../Authoring.hpp" +#include +#include +#include +#include "PreviewResources.generated.hpp" + +namespace Luna::GameGUIEditor +{ + struct [[Luna::struct("{88C2CDFC-213C-405D-A2EB-F76C2EB6790C}")]] PreviewResources + { + HashMap objects; + HashMap errors; + }; + + R prepare_editor_preview(const AuthoringDocument& source, + Asset::asset_t asset, WorkingDirectories& directories, + const Function(Asset::asset_t)>& working_copy); + + R> load_editor_authoring(Asset::asset_t asset); +} diff --git a/Programs/GameGUIEditor/Service/Source/WorkingDirectories.cpp b/Programs/GameGUIEditor/Service/Source/WorkingDirectories.cpp new file mode 100644 index 00000000..5ffa8d84 --- /dev/null +++ b/Programs/GameGUIEditor/Service/Source/WorkingDirectories.cpp @@ -0,0 +1,466 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file WorkingDirectories.cpp +* @author JXMaster +* @date 2026/9/11 +*/ +#include "WorkingDirectories.hpp" +#include "../Authoring.hpp" +#include +#include +#include +#include +#include + +namespace Luna::GameGUIEditor +{ + namespace + { + // Runtime has no canonical native path API. Use the non-throwing standard API only + // for path identity; all asset and directory I/O remains in Luna's filesystem layer. + R canonical_path(const Path& path) + { + std::error_code error; + auto canonical = std::filesystem::weakly_canonical( + std::filesystem::path((const char8_t*)path.encode().c_str()), error); + if(error) return set_error(E_BAD_ARGUMENTS, "Cannot resolve path: %s", error.message().c_str()); + auto utf8 = canonical.generic_u8string(); + return Path((const c8*)utf8.c_str()); + } + + bool contains_path(const Path& root, const Path& path) + { +#if defined(LUNA_PLATFORM_WINDOWS) + String root_string = root.encode(); + String path_string = path.encode(); + for(c8& c : root_string) if(c >= 'A' && c <= 'Z') c += 'a' - 'A'; + for(c8& c : path_string) if(c >= 'A' && c <= 'Z') c += 'a' - 'A'; + return Path(path_string.c_str()).is_subpath_of(Path(root_string.c_str())); +#else + return path.is_subpath_of(root); +#endif + } + + RV scan_folders(WorkingDirectory& directory, const Path& relative, Vector& folders) + { + lutry + { + lulet(iterator, directory.file_system->open_dir(relative)); + while(iterator->is_valid()) + { + Name name(iterator->get_filename()); + if(name != Name(".") && name != Name("..") && + test_flags(iterator->get_attributes(), FileAttributeFlag::directory)) + { + Path child = relative; + child.push_back(name); + folders.push_back(child); + luexp(scan_folders(directory, child, folders)); + } + iterator->move_next(); + } + } + lucatchret; + return ok; + } + } + + R DirectoryFileSystem::check(const Path& path, bool directory) + { + if(test_flags(path.flags(), PathFlag::absolute) || !path.root().empty()) return E_BAD_ARGUMENTS; + for(const Name& part : path) if(part == Name("..")) return E_BAD_ARGUMENTS; + Path native = native_root; + native.append(path); + lutry + { + lulet(canonical, canonical_path(native)); + if(!contains_path(native_root, canonical)) + luthrow(set_error(E_BAD_ARGUMENTS, "The path escapes its working directory: %s", native.encode().c_str())); + // Directory aliases introduce duplicate asset paths or recursive scan cycles. + if(directory && canonical != native) + luthrow(set_error(E_NOT_SUPPORTED, "Directory aliases are not supported inside a working directory: %s", + native.encode().c_str())); + return native; + } + lucatchret; + return E_FAILURE; + } + + R> DirectoryFileSystem::open_file(const Path& path, FileOpenFlag flags, FileCreationMode creation) + { + auto checked = check(path); + if(!checked.valid()) return checked.errcode(); + return storage->open_file(path, flags, creation); + } + R DirectoryFileSystem::get_file_attribute(const Path& path) + { + auto checked = check(path); + if(!checked.valid()) return checked.errcode(); + return storage->get_file_attribute(path); + } + R> DirectoryFileSystem::open_dir(const Path& path) + { + auto checked = check(path, true); + if(!checked.valid()) return checked.errcode(); + return storage->open_dir(path); + } + RV DirectoryFileSystem::copy_file(const Path& from, const Path& to, VFS::IFileSystem* destination) + { + if(destination && destination != this) return E_NOT_SUPPORTED; + auto source = check(from); + if(!source.valid()) return source.errcode(); + auto target = check(to); + if(!target.valid()) return target.errcode(); + return storage->copy_file(from, to, nullptr); + } + RV DirectoryFileSystem::move_file(const Path& from, const Path& to, VFS::IFileSystem* destination, FileMoveFlag flags) + { + if(destination && destination != this) return E_NOT_SUPPORTED; + auto source = check(from); + if(!source.valid()) return source.errcode(); + auto target = check(to); + if(!target.valid()) return target.errcode(); + return storage->move_file(from, to, nullptr, flags); + } + RV DirectoryFileSystem::delete_file(const Path& path) + { + auto checked = check(path); + if(!checked.valid()) return checked.errcode(); + return storage->delete_file(path); + } + RV DirectoryFileSystem::create_dir(const Path& path) + { + auto checked = check(path); + if(!checked.valid()) return checked.errcode(); + return storage->create_dir(path); + } + R DirectoryFileSystem::get_native_path(const Path& path) + { + auto checked = check(path); + if(!checked.valid()) return checked.errcode(); + return Name(checked.get().encode()); + } + RV DirectoryFileSystem::flush() { return storage->flush(); } + + R WorkingDirectories::get(u64 id, bool allow_closing) + { + for(auto& directory : directories) + { + if(directory->id != id) continue; + if(directory->closing && !allow_closing) return set_error(E_BUSY, "The working directory is closing."); + return directory.get(); + } + return set_error(E_NOT_FOUND, "Open a working directory first."); + } + + R WorkingDirectories::owner(const Path& path, bool allow_closing) + { + for(auto& directory : directories) + { + if(!path.is_subpath_of(directory->vfs_root)) continue; + return get(directory->id, allow_closing); + } + return set_error(E_NOT_FOUND, "The asset is outside the opened working directories. Open its directory first."); + } + + R WorkingDirectories::open(const Path& native_root) + { + UniquePtr directory(memnew()); + lutry + { + luset(directory->native_root, canonical_path(native_root)); + lulet(attributes, Luna::get_file_attribute(directory->native_root.encode().c_str())); + if(!test_flags(attributes.attributes, FileAttributeFlag::directory)) luthrow(E_NOT_DIRECTORY); + for(auto& existing : directories) + { + std::error_code error; + bool same = std::filesystem::equivalent(std::filesystem::path((const char8_t*)existing->native_root.encode().c_str()), + std::filesystem::path((const char8_t*)directory->native_root.encode().c_str()), error); + if(!error && same) return get(existing->id); + if(contains_path(existing->native_root, directory->native_root) || + contains_path(directory->native_root, existing->native_root)) + luthrow(set_error(E_ALREADY_EXISTS, "Working directories must not contain each other.")); + } + // Process-wide IDs avoid collisions between independent headless services. + static volatile u64 next_id = 0; + directory->id = atom_inc_u64(&next_id); + String mount; + strprintf(mount, "/GameGUIEditor/WorkingDirectories/%llu", (unsigned long long)directory->id); + directory->vfs_root = mount.c_str(); + directory->file_system = new_object(); + directory->file_system->native_root = directory->native_root; + luset(directory->file_system->storage, VFS::new_native_file_system(directory->native_root.encode().c_str())); + luexp(VFS::mount(directory->file_system, directory->vfs_root)); + auto prepare = [&]() -> RV + { + lutry + { + auto attributes = directory->file_system->get_file_attribute("assets.db"); + if(attributes.valid()) + { + luset(directory->database, Asset::open_file_database(directory->vfs_root, "assets.db", Asset::DatabaseMode::read_write)); + } + else if(attributes.errcode() == E_NOT_FOUND) + { + luset(directory->database, Asset::new_sidecar_database(directory->vfs_root)); + } + else luthrow(attributes.errcode()); + luexp(Asset::register_asset_database(directory->database)); + directory->registered = true; + luexp(rebuild_index(*directory)); + } + lucatchret; + return ok; + }; + RV prepared = prepare(); + if(failed(prepared)) + { + ResultCode code = unwrap_errcode(prepared); + String message = explain(prepared.errcode()); + // Keep cleanup failures reachable for retry rather than losing an owned mount. + RV cleanup = directory->registered ? Asset::unregister_asset_database(directory->database) : ok; + if(succeeded(cleanup)) + { + directory->registered = false; + cleanup = VFS::unmount(directory->vfs_root); + } + if(failed(cleanup)) + { + message.append(" Directory cleanup is pending; retry Unload Directory."); + directory->closing = true; + directories.push_back(move(directory)); + } + return set_error(code, "%s", message.c_str()); + } + } + lucatchret; + WorkingDirectory* result = directory.get(); + directories.push_back(move(directory)); + return result; + } + + RV WorkingDirectories::rebuild_index(WorkingDirectory& directory) + { + lutry + { + lulet(records, directory.database->get_records()); + Vector folders; + luexp(scan_folders(directory, Path(), folders)); + // Preserve metadata-only folder paths even when payload directories are absent. + for(const auto& record : records) + { + Path parent = record.path; + parent.pop_back(); + while(!parent.empty()) + { + bool found = false; + for(const Path& folder : folders) if(folder == parent) { found = true; break; } + if(!found) folders.push_back(parent); + parent.pop_back(); + } + } + directory.records = move(records); + directory.folders = move(folders); + } + lucatchret; + return ok; + } + + R WorkingDirectories::resolve_native(Path path) + { + if(!test_flags(path.flags(), PathFlag::absolute)) return E_BAD_ARGUMENTS; + if(!path.extension().empty() && path.extension() != "json") + return set_error(E_BAD_ARGUMENTS, "GameGUI authoring files must use the .json extension."); + lutry + { + luset(path, canonical_path(path)); + if(path.extension() == "json") path.remove_extension(); + for(auto& directory : directories) + { + if(!contains_path(directory->native_root, path)) continue; + luexp(get(directory->id, true)); + Path relative; + relative.assign_relative(directory->native_root, path); + Path result = directory->vfs_root; + result.append(relative); + luexp(validate_asset_path(result, true)); + return result; + } + } + lucatchret; + return set_error(E_NOT_FOUND, "The selected file is outside the opened working directories. Open its directory first."); + } + + RV WorkingDirectories::validate_asset_path(const Path& path, bool allow_closing) + { + lutry + { + lulet(directory, owner(path, allow_closing)); + Path relative; + relative.assign_relative(directory->vfs_root, path); + if(relative.empty()) luthrow(E_BAD_ARGUMENTS); + luexp(directory->file_system->check(relative)); + } + lucatchret; + return ok; + } + + RV WorkingDirectories::flush(WorkingDirectory& directory) + { + lutry + { + luexp(directory.database->flush()); + luexp(directory.file_system->flush()); + } + lucatchret; + return ok; + } + + RV WorkingDirectories::unload_data(WorkingDirectory& directory) + { + lutry + { + // Include the registered snapshot and attempted saves even if sidecars were removed + // or corrupted externally. Unloading must not depend on rereading valid metadata. + Vector assets = directory.touched_assets; + auto collect = [&](const Vector& records) + { + for(const auto& record : records) + { + Asset::asset_t asset = Asset::get_asset(record.guid); + bool found = false; + for(auto existing : assets) if(existing == asset) { found = true; break; } + if(!found) assets.push_back(asset); + } + }; + collect(directory.records); + auto records = directory.database->get_records(); + if(records.valid()) collect(records.get()); + for(Asset::asset_t asset : assets) + { + if(!Asset::get_asset_path(asset).is_subpath_of(directory.vfs_root)) continue; + Vector units; + auto enumerated = Asset::get_asset_data_units(asset, units); + if(!enumerated.valid()) + { + // Unknown types can have metadata without any installed loader. + Asset::AssetDataUnitDesc main; + units.push_back(main); + for(const auto& record : directory.records) + if(record.guid == Asset::get_asset_guid(asset)) + for(const auto& unit : record.data_units) units.push_back(unit); + } + for(const auto& unit : units) + { + auto current = Asset::get_asset_data_unit_state(asset, unit.id); + if(!current.valid() && current.errcode() == Asset::E_ASSET_DATA_UNIT_NOT_FOUND) continue; + luexp(current); + auto state = current.get(); + if(state == Asset::AssetDataUnitState::loading) luthrow(E_BUSY); + if(state == Asset::AssetDataUnitState::loaded) + luexp(Asset::set_asset_data_unit_object(asset, unit.id, nullptr)); + } + } + } + lucatchret; + return ok; + } + + void WorkingDirectories::track_asset(WorkingDirectory& directory, Asset::asset_t asset) + { + bool tracked = false; + for(auto existing : directory.touched_assets) if(existing == asset) { tracked = true; break; } + if(!tracked) directory.touched_assets.push_back(asset); + Asset::AssetMetadata metadata; + metadata.guid = Asset::get_asset_guid(asset); + metadata.type = Asset::get_asset_type(asset); + metadata.path.assign_relative(directory.vfs_root, Asset::get_asset_path(asset)); + Vector units; + if(succeeded(Asset::get_asset_data_units(asset, units))) + for(const auto& unit : units) if(!unit.id.empty()) metadata.data_units.push_back(unit); + bool found = false; + for(auto& record : directory.records) + { + if(record.guid != metadata.guid) continue; + record = metadata; + found = true; + break; + } + if(!found) directory.records.push_back(metadata); + Path parent = metadata.path; + parent.pop_back(); + while(!parent.empty()) + { + bool exists = false; + for(const Path& folder : directory.folders) if(folder == parent) { exists = true; break; } + if(!exists) directory.folders.push_back(parent); + parent.pop_back(); + } + } + + RV WorkingDirectories::close(WorkingDirectory& directory) + { + lutry + { + if(directory.registered) + { + luexp(unload_data(directory)); + luexp(Asset::unregister_asset_database(directory.database)); + directory.registered = false; + } + RV unmounted = VFS::unmount(directory.vfs_root); + if(failed(unmounted)) + { + ResultCode code = unwrap_errcode(unmounted); + String message = explain(unmounted.errcode()); + // Restore registration when possible; otherwise keep an explicit closing entry. + RV restored = directory.database ? Asset::register_asset_database(directory.database) : RV(E_BAD_ARGUMENTS); + directory.registered = succeeded(restored); + directory.closing = !directory.registered; + if(!directory.registered) message.append(" Registration could not be restored; retry Unload Directory."); + return set_error(code, "%s", message.c_str()); + } + for(usize i = 0; i < directories.size(); ++i) + { + if(directories[i].get() == &directory) + { + directories.erase(directories.begin() + i); + break; + } + } + } + lucatchret; + return ok; + } + + Variant WorkingDirectories::describe(const WorkingDirectory& directory, bool entries) const + { + Variant result(VariantType::object); + result["working_directory_id"] = directory.id; + result["native_path"] = directory.native_root.encode().c_str(); + result["vfs_path"] = directory.vfs_root.encode().c_str(); + result["closing"] = directory.closing; + if(!entries) return result; + result["folders"] = Variant(VariantType::array); + for(const Path& folder : directory.folders) result["folders"].push_back(folder.encode().c_str()); + result["assets"] = Variant(VariantType::array); + for(const auto& record : directory.records) + { + Variant asset(VariantType::object); + c8 guid[GUID_STRING_LENGTH + 1] = {}; + lupanic_if_failed(encode_guid(record.guid, guid, GUID_STRING_LENGTH)); + asset["asset_guid"] = guid; + asset["type"] = record.type; + asset["path"] = record.path.encode().c_str(); + bool editable = false; + for(const auto& unit : record.data_units) + if(unit.id == get_authoring_data_unit() && unit.loader == get_authoring_asset_loader()) editable = true; + asset["editable"] = editable; + result["assets"].push_back(move(asset)); + } + return result; + } +} diff --git a/Programs/GameGUIEditor/Service/Source/WorkingDirectories.hpp b/Programs/GameGUIEditor/Service/Source/WorkingDirectories.hpp new file mode 100644 index 00000000..c3e7f340 --- /dev/null +++ b/Programs/GameGUIEditor/Service/Source/WorkingDirectories.hpp @@ -0,0 +1,68 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file WorkingDirectories.hpp +* @author JXMaster +* @date 2026/9/11 +*/ +#pragma once +#include +#include +#include +#include "WorkingDirectories.generated.hpp" + +namespace Luna::GameGUIEditor +{ + // NativeFileSystem supplies storage; this wrapper enforces the editor's directory boundary, + // including symlink targets. It does not change VFS or native filesystem semantics globally. + struct [[Luna::struct("{EBBF92C8-52B3-4E8F-A23A-5B36EF739724}")]] DirectoryFileSystem : VFS::IFileSystem + { + luiimpl(); + Path native_root; + Ref storage; + R check(const Path& path, bool directory = false); + virtual R> open_file(const Path& path, FileOpenFlag flags, FileCreationMode creation) override; + virtual R get_file_attribute(const Path& path) override; + virtual R> open_dir(const Path& path) override; + virtual RV copy_file(const Path& from, const Path& to, VFS::IFileSystem* destination) override; + virtual RV move_file(const Path& from, const Path& to, VFS::IFileSystem* destination, FileMoveFlag flags) override; + virtual RV delete_file(const Path& path) override; + virtual RV create_dir(const Path& path) override; + virtual R get_native_path(const Path& path) override; + virtual RV flush() override; + }; + + struct WorkingDirectory + { + u64 id = 0; + Path native_root; + Path vfs_root; + Ref file_system; + Ref database; + Vector records; + Vector folders; + Vector touched_assets; + bool closing = false; + u64 close_token = 0; + bool registered = false; + }; + + class WorkingDirectories + { + public: + Vector> directories; + R open(const Path& native_root); + R get(u64 id, bool allow_closing = false); + R owner(const Path& path, bool allow_closing = false); + R resolve_native(Path path); + RV rebuild_index(WorkingDirectory& directory); + RV flush(WorkingDirectory& directory); + RV unload_data(WorkingDirectory& directory); + RV close(WorkingDirectory& directory); + Variant describe(const WorkingDirectory& directory, bool entries = false) const; + RV validate_asset_path(const Path& path, bool allow_closing = false); + void track_asset(WorkingDirectory& directory, Asset::asset_t asset); + }; +} diff --git a/Programs/GameGUIEditor/Source/EditorApp.cpp b/Programs/GameGUIEditor/Source/EditorApp.cpp new file mode 100644 index 00000000..5267e545 --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorApp.cpp @@ -0,0 +1,297 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorApp.cpp +* @author JXMaster +* @date 2026/8/28 +*/ +#include "EditorApp.hpp" +#include +#include +#include +#include +#include +#include +#if defined(LUNA_PLATFORM_MACOS) +#include +#endif + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { + RV EditorApp::run() + { + lutry + { + i32 frame_index = 0; + while(true) + { + Window::poll_events(); + // Native menus can close documents before a GUI frame has begun. + for(u64 id : deferred_document_removals) remove_document_view(id); + deferred_document_removals.clear(); +#if defined(LUNA_PLATFORM_MACOS) + if(Window::is_application_quit_requested()) break; + luexp(update_application_menu_state()); +#endif + if(window->is_closed()) break; + synchronize_previews(); + if(window->is_minimized()) + { + sleep(100); + continue; + } + UInt2U framebuffer_size = window->get_framebuffer_size(); + if(framebuffer_size.x && framebuffer_size.y && + (framebuffer_size.x != width || framebuffer_size.y != height)) + { + luexp(swap_chain->reset({framebuffer_size.x, framebuffer_size.y, 2, + RHI::Format::unknown, true})); + luexp(resize_target(framebuffer_size)); + width = framebuffer_size.x; + height = framebuffer_size.y; + } + for(DocumentView& document : documents) + luexp(apply_preview_surface_size(document)); + UInt2U logical_size = window->get_size(); + GUI::FrameDesc frame; + frame.logical_size = Float2U((f32)logical_size.x, (f32)logical_size.y); + frame.render_size = framebuffer_size; + frame.delta_time = 1.0f / 60.0f; + gui->begin_frame(frame); + GUIWindow::update_input(&input_adapter); + gui->push_layer(gui->make_id("editor.root.layer")); + UIHandles handles; + GUI::ElementHandle root = build_editor(handles); + gui->pop_layer(); + luexp(EditorGUI::layout_tree(gui, root, + RectF(0.0f, 0.0f, frame.logical_size.x, frame.logical_size.y))); + gui->route_input(); + EditorGUI::ResolveResult resolved = EditorGUI::resolve_interactions(gui); + if(resolved.relayout_requested) + { + luexp(EditorGUI::layout_tree(gui, root, + RectF(0.0f, 0.0f, frame.logical_size.x, frame.logical_size.y))); + } + + PreviewInput preview_input; + RectF preview_rect = EditorGUI::get_item_rect(gui, handles.preview_host); + DocumentView* input_document = find_document(handles.document_id); + if(input_document) + collect_preview_input(*input_document, handles.preview_host, + preview_rect, frame, preview_input); + luexp(GUIWindow::update_text_input(&input_adapter)); + luexp(gui->generate_draw_commands()); + + process_interactions(handles); + if(discard_smoke && frame_index == 0) + { + DocumentView* document = active_document(); + if(document) request_close(*document, true); + } + DocumentView* preview_document = find_document(preview_input.document_id); + if(!preview_document) preview_document = active_document(); + for(u64 id : deferred_document_removals) + if(preview_document && preview_document->id == id) preview_document = nullptr; + if(preview_document) + { + Span events; + if(preview_document->id == preview_input.document_id) + events = Span(preview_input.events.data(), + preview_input.events.size()); + luexp(build_preview(*preview_document, events)); + } + luexp(render_frame(preview_document)); + for(u64 id : deferred_document_removals) remove_document_view(id); + deferred_document_removals.clear(); + process_deferred_directory_closes(); + ++frame_index; + if(max_frames >= 0 && frame_index >= max_frames) break; + } +#if defined(LUNA_PLATFORM_MACOS) + auto _ = Window::reset_application_menu(); +#endif + GUIWindow::uninstall_window_event_handler(&input_adapter); + Window::set_event_handler(nullptr, nullptr); + service.reset(); + documents.clear(); + } + lucatchret; + return ok; + } + RV EditorApp::resize_target(const UInt2U& size) + { + lutry + { + luset(gui_target, RHI::get_main_device()->new_texture( + RHI::MemoryType::local, RHI::TextureDesc::tex2d( + swap_chain->get_desc().format, + RHI::TextureUsageFlag::color_attachment | + RHI::TextureUsageFlag::read_texture, + size.x, size.y, 1, 1))); + } + lucatchret; + return ok; + } + + RV EditorApp::initialize_dock_layout() + { + EditorGUI::DockSpaceLayoutDesc layout; + layout.nodes.resize(11); + layout.root_node = 0; + layout.nodes[0].split = true; + layout.nodes[0].split_axis = EditorGUI::DockSplitAxis::y; + layout.nodes[0].split_ratio = 0.10f; + layout.nodes[0].child0 = 1; + layout.nodes[0].child1 = 2; + layout.nodes[1].tabs.push_back(gui->make_id("panel.palette")); + + layout.nodes[2].split = true; + layout.nodes[2].split_axis = EditorGUI::DockSplitAxis::x; + layout.nodes[2].split_ratio = 0.22f; + layout.nodes[2].child0 = 3; + layout.nodes[2].child1 = 4; + layout.nodes[3].split = true; + layout.nodes[3].split_axis = EditorGUI::DockSplitAxis::y; + layout.nodes[3].split_ratio = 0.5f; + layout.nodes[3].child0 = 9; + layout.nodes[3].child1 = 10; + layout.nodes[9].tabs.push_back(gui->make_id("panel.explorer")); + layout.nodes[10].tabs.push_back(gui->make_id("panel.hierarchy")); + layout.nodes[4].split = true; + layout.nodes[4].split_axis = EditorGUI::DockSplitAxis::x; + layout.nodes[4].split_ratio = 0.76f; + layout.nodes[4].child0 = 5; + layout.nodes[4].child1 = 8; + layout.nodes[5].split = true; + layout.nodes[5].split_axis = EditorGUI::DockSplitAxis::y; + layout.nodes[5].split_ratio = 0.78f; + layout.nodes[5].child0 = 6; + layout.nodes[5].child1 = 7; + for(const DocumentView& document : documents) + { + layout.nodes[6].tabs.push_back(document_panel_id(gui, document.id)); + } + if(!documents.empty()) + { + usize selected = (usize)clamp(selected_document, 0, (i32)documents.size() - 1); + layout.nodes[6].selected_tab = document_panel_id(gui, documents[selected].id); + } + layout.nodes[7].tabs.push_back(gui->make_id("panel.diagnostics")); + layout.nodes[8].tabs.push_back(gui->make_id("panel.inspector")); + EditorGUI::set_dockspace_layout(gui, gui->make_id("editor.dock_space"), layout); + dock_layout_initialized = true; + return ok; + } + + bool EditorApp::has_dirty_documents() const + { + for(const DocumentView& document : documents) + { + if(document.dirty) return true; + } + return false; + } + + RV EditorApp::init() + { + lutry + { + luset(window, Window::new_window(APP_NAME, + Window::DEFAULT_POS, Window::DEFAULT_POS, 1440, 960)); + luexp(window->set_foreground()); + RHI::IDevice* device = RHI::get_main_device(); + for(u32 i = 0; i < device->get_num_command_queues(); ++i) + { + if(device->get_command_queue_desc(i).type == RHI::CommandQueueType::graphics) + { + queue = i; + break; + } + } + if(queue == U32_MAX) luthrow(set_error(E_NOT_SUPPORTED, + "No graphics command queue is available.")); + UInt2U size = window->get_framebuffer_size(); + luset(swap_chain, device->new_swap_chain(queue, window, + RHI::SwapChainDesc({size.x, size.y, 2, RHI::Format::bgra8_unorm, + true, RHI::ColorSpace::srgb}))); + luset(cmdbuf, device->new_command_buffer(queue)); + luset(blit, RHIUtility::new_blit_context(device, swap_chain->get_desc().format)); + luset(renderer, GUI::new_renderer(device)); + luset(preview_renderer, GUI::new_renderer(device)); + gui = GUI::new_context(); + luexp(gui->register_font("default", Font::get_default_font())); + EditorGUI::register_style_schemas(gui); + EditorGUI::DefaultStyleDesc style; + style.input_mode = EditorGUI::InputMode::pointer; + style.color_theme = EditorGUI::ColorTheme::dark; + EditorGUI::set_default_style(gui, style); + luexp(resize_target(size)); + width = size.x; + height = size.y; + lulet(created_service, GameGUIEditor::new_service()); + service = move(created_service); + Variant types; + if(!invoke(GameGUIEditor::GET_NODE_TYPES_URL, + Variant(VariantType::object), types)) + { + luthrow(E_FAILURE); + } + for(const Variant& value : types.values()) + { + NodeTypeView type; + if(!decode_guid_string(value["type"], type.type)) continue; + type.name = value["name"].c_str(); + type.display_name = value["display_name"].c_str(); + type.category = value["category"].c_str(); + if(!decode_editing_schema(value["property_schema"], + type.property_schema) || + !decode_editing_schema(value["child_attachment_schema"], + type.child_attachment_schema)) + { + luthrow(set_error(E_BAD_DATA, + "GameGUIEditor received an invalid editing schema.")); + } + node_types.push_back(move(type)); + } + for(const String& path : startup_directories) + if(!open_working_directory(Path(path.c_str()))) luthrow(E_FAILURE); + if(discard_smoke && selected_directory && !create_document()) luthrow(E_FAILURE); + luexp(initialize_dock_layout()); + + Window::set_event_handler([](object_t event, void* userdata) + { + EditorApp* app = (EditorApp*)userdata; +#if defined(LUNA_PLATFORM_MACOS) + if(auto menu_item = cast_object(event)) + { + app->handle_application_menu_item(menu_item->item_id); + } + if(auto quit = cast_object(event)) + { + quit->do_quit = app->confirm_exit(); + } +#endif + if(auto close = cast_object(event)) + { + if(close->window == app->window) close->do_close = app->confirm_exit(); + } + }, this); + input_adapter.window = window; + input_adapter.gui = gui; + GUIWindow::install_window_event_handler(&input_adapter); +#if defined(LUNA_PLATFORM_MACOS) + luexp(install_application_menu()); +#endif + } + lucatchret; + return ok; + } + } + } +} diff --git a/Programs/GameGUIEditor/Source/EditorApp.hpp b/Programs/GameGUIEditor/Source/EditorApp.hpp new file mode 100644 index 00000000..24fcffa5 --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorApp.hpp @@ -0,0 +1,407 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorApp.hpp +* @author JXMaster +* @date 2026/8/28 +*/ +#pragma once +#include "../Service/GameGUIEditorService.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(LUNA_PLATFORM_MACOS) +#include +#endif + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { + constexpr c8 APP_NAME[] = "Luna GameGUI Editor"; + constexpr u32 PREVIEW_LAYOUT_WIDTH = 640; + constexpr u32 PREVIEW_LAYOUT_HEIGHT = 480; + constexpr f32 PREVIEW_GRID_SIZE = 32.0f; + constexpr f32 PREVIEW_RESIZE_HANDLE_SIZE = 14.0f; + constexpr f32 PREVIEW_MIN_NODE_SIZE = 64.0f; + constexpr f32 PREVIEW_MAX_NODE_SIZE = 8192.0f; + constexpr f32 PREVIEW_ZOOM_LEVELS[] = { + 0.125f, 0.25f, 0.5f, 0.75f, 1.0f, 1.25f, + 1.5f, 2.0f, 3.0f, 4.0f, 6.0f, 8.0f + }; + constexpr usize PREVIEW_ZOOM_LEVEL_COUNT = + sizeof(PREVIEW_ZOOM_LEVELS) / sizeof(PREVIEW_ZOOM_LEVELS[0]); + + enum class PropertyTarget : u8 + { + node, + attachment + }; + + enum class VisualEffectAction : u8 + { + add, + move_up, + move_down, + remove + }; + + struct VisualEffectEditor + { + Variant source; + bool supported = true; + i32 phase = 0; + i32 type = 0; + f32 inset[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + f32 color[4] = {1.0f, 1.0f, 1.0f, 1.0f}; + f32 gradient_colors[3][4] = { + {1.0f, 1.0f, 1.0f, 1.0f}, + {1.0f, 1.0f, 1.0f, 1.0f}, + {1.0f, 1.0f, 1.0f, 1.0f} + }; + f32 radius = 0.0f; + f32 line_width = 1.0f; + f32 shadow_offset[2] = {0.0f, 0.0f}; + f32 shadow_softness = 0.0f; + f32 shadow_spread = 0.0f; + i32 shadow_mode = 0; + }; + + struct PropertyEditor + { + EditingPropertyDesc desc; + PropertyTarget target = PropertyTarget::node; + Variant original; + Variant alternate_original; + Variant baseline; + bool original_present = false; + bool alternate_present = false; + bool raw = false; + String text; + bool boolean = false; + f32 number = 0.0f; + f32 vector[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + i32 selected_item = 0; + i32 size_mode = 0; + i32 baseline_size_mode = 0; + Vector visual_effects; + }; + + struct PreviewState + { + Ref context; + Ref instance; + Ref target; + u64 revision = 0; + Float2U viewport_size = Float2U((f32)PREVIEW_LAYOUT_WIDTH, + (f32)PREVIEW_LAYOUT_HEIGHT); + UInt2U render_size = UInt2U(PREVIEW_LAYOUT_WIDTH, PREVIEW_LAYOUT_HEIGHT); + Float2U pending_viewport_size = Float2U((f32)PREVIEW_LAYOUT_WIDTH, + (f32)PREVIEW_LAYOUT_HEIGHT); + UInt2U pending_render_size = UInt2U(PREVIEW_LAYOUT_WIDTH, + PREVIEW_LAYOUT_HEIGHT); + Float2U pan = Float2U(0.0f); + Float2U pan_pointer = Float2U(0.0f); + f32 zoom = 1.0f; + f32 zoom_wheel_accumulator = 0.0f; + Float2U node_size = Float2U((f32)PREVIEW_LAYOUT_WIDTH, + (f32)PREVIEW_LAYOUT_HEIGHT); + Float2U resize_pointer = Float2U(0.0f); + Float2U resize_start_size = Float2U(0.0f); + GUI::id_t selected_element_id = 0; + bool panning = false; + bool resizing = false; + }; + + enum class HierarchyDropMode : u8 + { + none, + before, + after, + child + }; + + struct HierarchyDragState + { + Guid source; + // A nonzero type creates a new node instead of moving an existing source. + Guid source_type; + GUI::id_t source_element = 0; + Float2U press_position = Float2U(0.0f); + bool pressed = false; + bool dragging = false; + bool cancelled = false; + HierarchyDropMode drop_mode = HierarchyDropMode::none; + Guid target_node; + Guid target_parent; + usize target_index = 0; + RectF feedback_rect; + u32 feedback_depth = 0; + }; + + struct DocumentView + { + u64 id = 0; + u64 working_directory_id = 0; + Path creation_folder; + u64 revision = 0; + u64 history_state = 0; + String title; + String asset_path; + Guid asset_guid; + bool dirty = false; + bool can_undo = false; + bool can_redo = false; + bool panel_open = true; + Ref snapshot; + Ref cooked_snapshot; + Variant diagnostics; + Guid selected_node; + u64 inspector_revision = 0; + Guid inspector_node; + String node_name; + Vector property_editors; + PreviewState preview; + HierarchyDragState hierarchy_drag; + Guid hierarchy_expand_node; + Guid hierarchy_context_node; + Float2U hierarchy_context_position = Float2U(0.0f); + }; + + struct NodeTypeView + { + Guid type; + String name; + String display_name; + String category; + EditingSchema property_schema; + EditingSchema child_attachment_schema; + }; + + struct NodeHit + { + Guid node; + Guid parent; + usize sibling_index = 0; + u32 depth = 0; + GUI::ElementHandle element; + }; + + struct TypeHit + { + Guid type; + GUI::ElementHandle element; + }; + + struct PropertyActionHit + { + usize property_index = 0; + GUI::ElementHandle element; + }; + + struct VisualEffectActionHit + { + usize property_index = 0; + usize effect_index = 0; + VisualEffectAction action = VisualEffectAction::add; + GUI::ElementHandle element; + }; + + struct UIHandles + { + u64 document_id = 0; + GUI::ElementHandle create_document; + GUI::ElementHandle open_document; + GUI::ElementHandle open_directory; + GUI::ElementHandle open_directory_button; + GUI::ElementHandle unload_directory; + GUI::ElementHandle refresh_directory; + GUI::ElementHandle save_document; + GUI::ElementHandle save_as_document; + GUI::ElementHandle cook_document; + GUI::ElementHandle close_document; + GUI::ElementHandle undo; + GUI::ElementHandle redo; + GUI::ElementHandle set_root_node; + GUI::ElementHandle delete_node; + GUI::ElementHandle preview_host; + Vector nodes; + Vector types; + Vector browse_assets; + Vector visual_effect_actions; + struct ExplorerHit + { + u64 directory_id = 0; + Path path; + Guid asset; + GUI::ElementHandle element; + }; + Vector explorer; + Vector asset_choices; + }; + + struct PreviewInput + { + u64 document_id = 0; + Vector events; + }; + + struct EditorApp + { + Ref window; + Ref swap_chain; + Ref cmdbuf; + Ref gui_target; + Ref blit; + Ref gui; + Ref renderer; + Ref preview_renderer; + UniquePtr service; + GUIWindow::GUIWindowInputAdapter input_adapter; + Vector documents; + Vector deferred_document_removals; + Vector node_types; + i32 selected_document = 0; + bool dock_layout_initialized = false; + bool discard_smoke = false; + Vector startup_directories; + Variant working_directories = Variant(VariantType::array); + u64 selected_directory = 0; + Path selected_folder; + u64 observed_preview_revision = 0; + bool directory_dialog_active = false; + u64 explorer_context_directory = 0; + Float2U explorer_context_position = Float2U(0.0f); + Vector deferred_directory_closes; + u64 asset_picker_document = 0; + usize asset_picker_property = 0; + u64 asset_picker_revision = 0; + Float2U asset_picker_position = Float2U(0.0f); + String error_message; + u32 queue = U32_MAX; + u32 width = 0; + u32 height = 0; + i32 max_frames = -1; +#if defined(LUNA_PLATFORM_MACOS) + bool application_menu_has_document = false; + bool application_menu_has_directory = false; + bool application_menu_can_cook = false; + bool application_menu_can_undo = false; + bool application_menu_can_redo = false; +#endif + + RV init(); + RV run(); + RV resize_target(const UInt2U& size); + RV initialize_dock_layout(); + bool invoke(const c8* url, const Variant& params, Variant& result); + bool native_path_to_asset_path(Path native_path, String& asset_path, + bool allow_missing = false); + void show_file_error(const c8* title); + bool create_document(); + bool open_document(); + bool open_document_asset(const Variant& params); + bool open_working_directory(const Path& path = Path()); + bool refresh_working_directories(); + Path directory_native_path(u64 directory_id) const; + bool confirm_directory_close(u64 directory_id, Variant& plan); + bool commit_directory_close(const Variant& plan); + void process_deferred_directory_closes(); + void synchronize_previews(); + bool refresh_snapshot(DocumentView& document); + bool update_metadata(DocumentView& document, const Variant& metadata); + DocumentView* find_document(u64 id); + DocumentView* active_document(); + void place_document_panel(u64 document_id, u64 target_document_id); + void rebuild_inspector(DocumentView& document); + RV apply_preview_surface_size(DocumentView& document); + RV ensure_preview(DocumentView& document); + RV build_preview(DocumentView& document, Span input); + RV render_frame(DocumentView* preview_document); + void collect_preview_input(DocumentView& document, + const GUI::ElementHandle& preview_host, const RectF& preview_rect, + const GUI::FrameDesc& editor_frame, PreviewInput& preview_input); + GUI::ElementHandle build_editor(UIHandles& handles); +#if !defined(LUNA_PLATFORM_MACOS) + void build_main_menu_bar(UIHandles& handles); +#endif + void build_hierarchy_panel(UIHandles& handles); + void build_explorer_panel(UIHandles& handles); + void build_explorer_folder(const Variant& directory, const Path& folder, u32 depth, UIHandles& handles); + bool process_explorer_interactions(const UIHandles& handles); + void build_asset_picker(UIHandles& handles); + void build_palette_panel(UIHandles& handles); + void build_document_panels(UIHandles& handles); + void build_inspector_panel(UIHandles& handles); + void build_diagnostics_panel(); + GUI::ElementHandle build_visual_effects_editor(PropertyEditor& property, + usize property_index, GUI::id_t property_id, UIHandles& handles); + bool process_visual_effect_actions(DocumentView& document, + const UIHandles& handles); + void build_hierarchy_node(DocumentView& document, const Guid& node, + const Guid& parent, usize sibling_index, u32 depth, UIHandles& handles); + void process_interactions(const UIHandles& handles); + bool process_hierarchy_interactions(DocumentView& document, + const UIHandles& handles); + void update_hierarchy_drop(DocumentView& document, const UIHandles& handles, + const Float2U& pointer_position); + bool apply_hierarchy_drop(DocumentView& document); + void add_node(DocumentView& document, const Guid& type, Guid parent, + usize index = USIZE_MAX); + void apply_inspector_changes(DocumentView& document); + void remove_document_view(u64 id); + bool has_dirty_documents() const; + bool confirm_exit(); + void request_close(DocumentView& document, bool discard); + bool save(DocumentView& document, bool save_as, u64 close_token = 0); + void cook(DocumentView& document); + void undo_document(DocumentView& document); + void redo_document(DocumentView& document); +#if defined(LUNA_PLATFORM_MACOS) + RV install_application_menu(); + RV update_application_menu_state(); + void handle_application_menu_item(Window::application_menu_item_id_t id); +#endif + }; + + GUI::LayoutConfig fill_layout(); + GUI::LayoutConfig fill_width(f32 height); + GUI::LayoutConfig fixed_layout(f32 width, f32 height); + String guid_string(const Guid& guid); + bool decode_guid_string(const Variant& value, Guid& result); + GUI::id_t guid_gui_id(GUI::id_t scope, const Guid& guid); + GUI::id_t document_panel_id(GUI::IContext* context, u64 document_id); + GUI::id_t hierarchy_context_popup_id(GUI::IContext* context, + u64 document_id); + String property_text(const Variant& value); + bool decode_editing_schema(const Variant& value, EditingSchema& schema); + R property_value(const PropertyEditor& editor); + void decode_visual_effects(const Variant& value, + Vector& effects); + Variant encode_visual_effects(Span effects); + f32 visual_effects_editor_height(const PropertyEditor& property); + bool point_in_rect(const RectF& rect, const Float2U& point); + RectF item_screen_rect(GUI::IContext* context, + const GUI::ElementHandle& item, bool clip_rect = false); + bool subtree_contains(const AuthoringDocument& document, const Guid& root, + const Guid& node); + bool find_parent_info(const AuthoringDocument& document, const Guid& node, + Guid& parent, usize& sibling_index); + Variant editing_params(const DocumentView& document); + R draw_hierarchy_drop(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata); + } + } +} diff --git a/Programs/GameGUIEditor/Source/EditorApplicationMenu.cpp b/Programs/GameGUIEditor/Source/EditorApplicationMenu.cpp new file mode 100644 index 00000000..eed09fce --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorApplicationMenu.cpp @@ -0,0 +1,258 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorApplicationMenu.cpp +* @author JXMaster +* @date 2026/8/28 +*/ +#include "EditorApp.hpp" +#if defined(LUNA_PLATFORM_MACOS) +#include +#endif + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { +#if defined(LUNA_PLATFORM_MACOS) + constexpr Window::application_menu_item_id_t MENU_ITEM_NEW = 1; + constexpr Window::application_menu_item_id_t MENU_ITEM_OPEN = 2; + constexpr Window::application_menu_item_id_t MENU_ITEM_SAVE = 3; + constexpr Window::application_menu_item_id_t MENU_ITEM_SAVE_AS = 4; + constexpr Window::application_menu_item_id_t MENU_ITEM_CLOSE = 5; + constexpr Window::application_menu_item_id_t MENU_ITEM_UNDO = 6; + constexpr Window::application_menu_item_id_t MENU_ITEM_REDO = 7; + constexpr Window::application_menu_item_id_t MENU_ITEM_COOK = 8; + constexpr Window::application_menu_item_id_t MENU_ITEM_OPEN_DIRECTORY = 9; + + Window::ApplicationMenuItemDesc menu_command(const c8* title, + Window::application_menu_item_id_t id, KeyCode shortcut_key = KeyCode::unknown, + Window::KeyModifierFlag shortcut_modifiers = Window::KeyModifierFlag::none) + { + Window::ApplicationMenuItemDesc desc; + desc.title = title; + desc.id = id; + desc.shortcut_key = shortcut_key; + desc.shortcut_modifiers = shortcut_modifiers; + return desc; + } + + Window::ApplicationMenuItemDesc standard_menu_command(Window::ApplicationMenuItemRole role, + KeyCode shortcut_key = KeyCode::unknown, + Window::KeyModifierFlag shortcut_modifiers = Window::KeyModifierFlag::none) + { + Window::ApplicationMenuItemDesc desc; + desc.role = role; + desc.shortcut_key = shortcut_key; + desc.shortcut_modifiers = shortcut_modifiers; + return desc; + } + + Window::ApplicationMenuItemDesc menu_separator() + { + Window::ApplicationMenuItemDesc desc; + desc.type = Window::ApplicationMenuItemType::separator; + return desc; + } + + Window::ApplicationMenuItemDesc menu_submenu(const c8* title, + Span children, + Window::ApplicationMenuItemRole role = Window::ApplicationMenuItemRole::none) + { + Window::ApplicationMenuItemDesc desc; + desc.type = Window::ApplicationMenuItemType::submenu; + desc.role = role; + desc.title = title; + desc.children = children; + return desc; + } + + Window::ApplicationMenuItemState menu_item_state(bool enabled) + { + Window::ApplicationMenuItemState state; + state.enabled = enabled; + return state; + } +#endif +#if defined(LUNA_PLATFORM_MACOS) + RV EditorApp::install_application_menu() + { + Window::ApplicationMenuItemDesc app_items[] = + { + standard_menu_command(Window::ApplicationMenuItemRole::about), + menu_separator(), + menu_submenu(nullptr, {}, Window::ApplicationMenuItemRole::services), + menu_separator(), + standard_menu_command(Window::ApplicationMenuItemRole::hide, + KeyCode::h, Window::KeyModifierFlag::system), + standard_menu_command(Window::ApplicationMenuItemRole::hide_others, + KeyCode::h, Window::KeyModifierFlag::system | Window::KeyModifierFlag::alt), + standard_menu_command(Window::ApplicationMenuItemRole::show_all), + menu_separator(), + standard_menu_command(Window::ApplicationMenuItemRole::quit, + KeyCode::q, Window::KeyModifierFlag::system), + }; + + Window::ApplicationMenuItemDesc file_items[] = + { + menu_command("Open Directory...", MENU_ITEM_OPEN_DIRECTORY, KeyCode::o, + Window::KeyModifierFlag::system | Window::KeyModifierFlag::shift), + menu_command("New", MENU_ITEM_NEW, KeyCode::n, Window::KeyModifierFlag::system), + menu_command("Open...", MENU_ITEM_OPEN, KeyCode::o, Window::KeyModifierFlag::system), + menu_separator(), + menu_command("Save", MENU_ITEM_SAVE, KeyCode::s, Window::KeyModifierFlag::system), + menu_command("Save As...", MENU_ITEM_SAVE_AS, KeyCode::s, + Window::KeyModifierFlag::system | Window::KeyModifierFlag::shift), + menu_command("Cook", MENU_ITEM_COOK), + menu_separator(), + menu_command("Close", MENU_ITEM_CLOSE, KeyCode::w, Window::KeyModifierFlag::system), + }; + DocumentView* document = active_document(); + bool has_document = document != nullptr; + file_items[1].state = menu_item_state(selected_directory != 0); + file_items[2].state = menu_item_state(selected_directory != 0); + file_items[4].state = menu_item_state(has_document); + file_items[5].state = menu_item_state(has_document); + file_items[6].state = menu_item_state(document && !document->asset_path.empty()); + file_items[8].state = menu_item_state(has_document); + + Window::ApplicationMenuItemDesc edit_items[] = + { + menu_command("Undo", MENU_ITEM_UNDO, KeyCode::z, Window::KeyModifierFlag::system), + menu_command("Redo", MENU_ITEM_REDO, KeyCode::z, + Window::KeyModifierFlag::system | Window::KeyModifierFlag::shift), + }; + edit_items[0].state = menu_item_state(document && document->can_undo); + edit_items[1].state = menu_item_state(document && document->can_redo); + + Window::ApplicationMenuItemDesc main_items[] = + { + menu_submenu(APP_NAME, Span(app_items, 9)), + menu_submenu("File", Span(file_items, 9)), + menu_submenu("Edit", Span(edit_items, 2)), + menu_submenu(nullptr, {}, Window::ApplicationMenuItemRole::window_menu), + menu_submenu(nullptr, {}, Window::ApplicationMenuItemRole::help_menu), + }; + Window::ApplicationMenuDesc desc; + desc.items = Span(main_items, 5); + lutry + { + luexp(Window::set_application_menu(desc)); + application_menu_has_document = has_document; + application_menu_has_directory = selected_directory != 0; + application_menu_can_cook = document && !document->asset_path.empty(); + application_menu_can_undo = document && document->can_undo; + application_menu_can_redo = document && document->can_redo; + } + lucatchret; + return ok; + } + + RV EditorApp::update_application_menu_state() + { + DocumentView* document = active_document(); + bool has_document = document != nullptr; + bool can_undo = document && document->can_undo; + bool can_redo = document && document->can_redo; + bool can_cook = document && !document->asset_path.empty(); + lutry + { + if(application_menu_has_directory != (selected_directory != 0)) + { + luexp(Window::set_application_menu_item_state(MENU_ITEM_NEW, menu_item_state(selected_directory != 0))); + luexp(Window::set_application_menu_item_state(MENU_ITEM_OPEN, menu_item_state(selected_directory != 0))); + application_menu_has_directory = selected_directory != 0; + } + if(has_document != application_menu_has_document) + { + Window::ApplicationMenuItemState state = menu_item_state(has_document); + luexp(Window::set_application_menu_item_state(MENU_ITEM_SAVE, state)); + luexp(Window::set_application_menu_item_state(MENU_ITEM_SAVE_AS, state)); + luexp(Window::set_application_menu_item_state(MENU_ITEM_CLOSE, state)); + application_menu_has_document = has_document; + } + if(can_cook != application_menu_can_cook) + { + luexp(Window::set_application_menu_item_state(MENU_ITEM_COOK, + menu_item_state(can_cook))); + application_menu_can_cook = can_cook; + } + if(can_undo != application_menu_can_undo) + { + luexp(Window::set_application_menu_item_state(MENU_ITEM_UNDO, + menu_item_state(can_undo))); + application_menu_can_undo = can_undo; + } + if(can_redo != application_menu_can_redo) + { + luexp(Window::set_application_menu_item_state(MENU_ITEM_REDO, + menu_item_state(can_redo))); + application_menu_can_redo = can_redo; + } + } + lucatchret; + return ok; + } + + void EditorApp::handle_application_menu_item(Window::application_menu_item_id_t id) + { + if(directory_dialog_active || !deferred_directory_closes.empty()) return; + switch(id) + { + case MENU_ITEM_OPEN_DIRECTORY: + open_working_directory(); + break; + case MENU_ITEM_NEW: + create_document(); + break; + case MENU_ITEM_OPEN: + open_document(); + break; + case MENU_ITEM_SAVE: + { + DocumentView* document = active_document(); + if(document) save(*document, false); + break; + } + case MENU_ITEM_SAVE_AS: + { + DocumentView* document = active_document(); + if(document) save(*document, true); + break; + } + case MENU_ITEM_COOK: + { + DocumentView* document = active_document(); + if(document) cook(*document); + break; + } + case MENU_ITEM_CLOSE: + { + DocumentView* document = active_document(); + if(document) request_close(*document, false); + break; + } + case MENU_ITEM_UNDO: + { + DocumentView* document = active_document(); + if(document) undo_document(*document); + break; + } + case MENU_ITEM_REDO: + { + DocumentView* document = active_document(); + if(document) redo_document(*document); + break; + } + default: + break; + } + } +#endif + } + } +} diff --git a/Programs/GameGUIEditor/Source/EditorCommon.cpp b/Programs/GameGUIEditor/Source/EditorCommon.cpp new file mode 100644 index 00000000..4d16406e --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorCommon.cpp @@ -0,0 +1,277 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorCommon.cpp +* @author JXMaster +* @date 2026/8/28 +*/ +#include "EditorApp.hpp" +#include + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { + GUI::LayoutConfig fill_layout() + { + GUI::LayoutConfig result; + result.width.kind = GUI::SizeKind::percent; + result.width.value = 1.0f; + result.height.kind = GUI::SizeKind::percent; + result.height.value = 1.0f; + result.flex_grow = 1.0f; + return result; + } + + GUI::LayoutConfig fill_width(f32 height) + { + GUI::LayoutConfig result; + result.width.kind = GUI::SizeKind::percent; + result.width.value = 1.0f; + result.height.kind = GUI::SizeKind::fixed; + result.height.value = height; + return result; + } + + GUI::LayoutConfig fixed_layout(f32 width, f32 height) + { + GUI::LayoutConfig result; + result.width.kind = GUI::SizeKind::fixed; + result.width.value = width; + result.height.kind = GUI::SizeKind::fixed; + result.height.value = height; + return result; + } + + String guid_string(const Guid& guid) + { + c8 buffer[GUID_STRING_LENGTH]; + RV result = encode_guid(guid, buffer, sizeof(buffer)); + luassert(succeeded(result)); + return String(buffer, sizeof(buffer)); + } + + bool decode_guid_string(const Variant& value, Guid& result) + { + return value.type() == VariantType::string && + succeeded(decode_guid(value.c_str(), value.str().size(), result)); + } + + GUI::id_t guid_gui_id(GUI::id_t scope, const Guid& guid) + { + GUI::id_t result = memhash64(&guid, sizeof(guid), scope); + return result ? result : GUI::DEFAULT_DATA_SCOPE; + } + + GUI::id_t document_panel_id(GUI::IContext* context, u64 document_id) + { + return GUI::make_scoped_id(context->make_id("panel.document"), document_id); + } + + GUI::id_t hierarchy_context_popup_id(GUI::IContext* context, u64 document_id) + { + return GUI::make_scoped_id(context->make_id("hierarchy.context_popup"), + document_id); + } + + String property_text(const Variant& value) + { + switch(value.type()) + { + case VariantType::string: return String(value.c_str()); + case VariantType::number: + { + String result; + if(value.number_type() == VariantNumberType::number_i64) + strprintf(result, "%lld", (long long)value.inum()); + else if(value.number_type() == VariantNumberType::number_u64) + strprintf(result, "%llu", (unsigned long long)value.unum()); + else strprintf(result, "%.9g", value.fnum()); + return result; + } + default: + { + auto encoded = VariantUtils::write_json(value, + VariantUtils::JSONWriteOptions::strict()); + return encoded.valid() ? encoded.get() : String("null"); + } + } + } + + bool decode_editing_schema(const Variant& value, EditingSchema& schema) + { + if(value.type() != VariantType::object || + value["properties"].type() != VariantType::array) + return false; + schema.properties.clear(); + for(const Variant& encoded : value["properties"].values()) + { + if(encoded.type() != VariantType::object) return false; + EditingPropertyDesc property; + property.id = encoded["id"].str(); + property.alternate_id = encoded["alternate_id"].str(); + property.display_name = encoded["display_name"].c_str(); + property.description = encoded["description"].c_str(); + if(property.id.empty() || property.display_name.empty()) return false; + + Name section = encoded["section"].str(); + if(section == Name("layout")) + property.section = EditingPropertySection::layout; + else if(section == Name("style")) + property.section = EditingPropertySection::style; + else if(section == Name("property")) + property.section = EditingPropertySection::property; + else return false; + + Name editor = encoded["editor"].str(); + if(editor == Name("boolean")) property.editor = EditingPropertyEditor::boolean; + else if(editor == Name("number")) property.editor = EditingPropertyEditor::number; + else if(editor == Name("string")) property.editor = EditingPropertyEditor::string; + else if(editor == Name("name")) property.editor = EditingPropertyEditor::name; + else if(editor == Name("enumeration")) + property.editor = EditingPropertyEditor::enumeration; + else if(editor == Name("float2")) property.editor = EditingPropertyEditor::float2; + else if(editor == Name("float4")) property.editor = EditingPropertyEditor::float4; + else if(editor == Name("color")) property.editor = EditingPropertyEditor::color; + else if(editor == Name("visual_effects")) + property.editor = EditingPropertyEditor::visual_effects; + else if(editor == Name("size")) property.editor = EditingPropertyEditor::size; + else if(editor == Name("asset")) property.editor = EditingPropertyEditor::asset; + else if(editor == Name("json")) property.editor = EditingPropertyEditor::json; + else return false; + + property.has_default = encoded["has_default"].boolean(); + if(property.has_default) property.default_value = encoded["default_value"]; + property.optional = encoded["optional"].boolean(true); + property.bounded = encoded["bounded"].boolean(); + property.minimum = encoded["minimum"].fnum(); + property.maximum = encoded["maximum"].fnum(); + property.step = encoded["step"].fnum(0.1); + property.asset_type = encoded["asset_type"].str(); + const Variant& items = encoded["items"]; + if(items.valid()) + { + if(items.type() != VariantType::array) return false; + for(const Variant& encoded_item : items.values()) + { + EditingEnumItemDesc item; + item.value = encoded_item["value"].str(); + item.display_name = encoded_item["display_name"].c_str(); + if(item.value.empty() || item.display_name.empty()) return false; + property.enumeration_items.push_back(move(item)); + } + } + if(property.editor == EditingPropertyEditor::size && + property.alternate_id.empty()) return false; + if(property.editor == EditingPropertyEditor::enumeration && + property.enumeration_items.empty()) return false; + schema.properties.push_back(move(property)); + } + return true; + } + + Variant make_editor_vector(const f32* values, usize count) + { + Variant result(VariantType::array); + for(usize i = 0; i < count; ++i) result.push_back((f64)values[i]); + return result; + } + + R property_value(const PropertyEditor& editor) + { + switch(editor.desc.editor) + { + case EditingPropertyEditor::boolean: return Variant(editor.boolean); + case EditingPropertyEditor::number: return Variant((f64)editor.number); + case EditingPropertyEditor::string: + case EditingPropertyEditor::name: + case EditingPropertyEditor::asset: + return Variant(editor.text.c_str()); + case EditingPropertyEditor::enumeration: + if(editor.selected_item < 0 || + (usize)editor.selected_item >= editor.desc.enumeration_items.size()) + return E_BAD_DATA; + return Variant(editor.desc.enumeration_items[(usize)editor.selected_item].value); + case EditingPropertyEditor::float2: + return make_editor_vector(editor.vector, 2); + case EditingPropertyEditor::float4: + case EditingPropertyEditor::color: + return make_editor_vector(editor.vector, 4); + case EditingPropertyEditor::visual_effects: + return encode_visual_effects(editor.visual_effects.cspan()); + case EditingPropertyEditor::size: + return Variant((f64)(editor.size_mode == 2 ? + editor.number * 0.01f : editor.number)); + case EditingPropertyEditor::json: + return VariantUtils::read_json(editor.text.c_str(), editor.text.size(), + VariantUtils::JSONReadOptions::strict()); + } + return E_BAD_DATA; + } + + bool point_in_rect(const RectF& rect, const Float2U& point) + { + return point.x >= rect.offset_x && point.y >= rect.offset_y && + point.x < rect.offset_x + rect.width && + point.y < rect.offset_y + rect.height; + } + + RectF item_screen_rect(GUI::IContext* context, const GUI::ElementHandle& item, + bool clip_rect) + { + const GUI::Element* element = context->get_element(item.index); + if(!element || element->id != item.id) return RectF(); + Span layers = context->get_layers(); + if(element->layer >= layers.size()) return RectF(); + RectF result = clip_rect ? element->layout_result.clip_rect : + element->layout_result.rect; + result.offset_x += layers[element->layer].screen_position.x; + result.offset_y += layers[element->layer].screen_position.y; + return result; + } + + bool subtree_contains(const AuthoringDocument& document, const Guid& root, + const Guid& node) + { + if(root == node) return true; + const AuthoringNodeRecord* record = find_authoring_node(document, root); + if(!record) return false; + for(const AuthoringChildLink& child : record->children) + { + if(subtree_contains(document, child.child, node)) return true; + } + return false; + } + + bool find_parent_info(const AuthoringDocument& document, const Guid& node, + Guid& parent, usize& sibling_index) + { + for(const AuthoringNodeRecord& candidate : document.nodes) + { + for(usize i = 0; i < candidate.children.size(); ++i) + { + if(candidate.children[i].child == node) + { + parent = candidate.id; + sibling_index = i; + return true; + } + } + } + return false; + } + + Variant editing_params(const DocumentView& document) + { + Variant params(VariantType::object); + params["document_id"] = document.id; + params["expected_revision"] = document.revision; + return params; + } + } + } +} diff --git a/Programs/GameGUIEditor/Source/EditorDocuments.cpp b/Programs/GameGUIEditor/Source/EditorDocuments.cpp new file mode 100644 index 00000000..31292eda --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorDocuments.cpp @@ -0,0 +1,575 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorDocuments.cpp +* @author JXMaster +* @date 2026/8/28 +*/ +#include "EditorApp.hpp" +#include +#include +#include +#include + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { + static const NodeTypeView* find_node_type(const Vector& node_types, + const Guid& type) + { + for(const NodeTypeView& node_type : node_types) + { + if(node_type.type == type) return &node_type; + } + return nullptr; + } + + static bool schema_contains(const EditingSchema& schema, const Name& property) + { + for(const EditingPropertyDesc& desc : schema.properties) + { + if(desc.id == property || desc.alternate_id == property) return true; + } + return false; + } + + static Variant editor_source_value(const PropertyEditor& editor) + { + if(editor.original_present) return editor.original; + if(editor.desc.has_default) return editor.desc.default_value; + return Variant(); + } + + static void initialize_property_editor(PropertyEditor& editor, + const EditingPropertyDesc& desc, PropertyTarget target, + const Variant& source) + { + editor.desc = desc; + editor.target = target; + editor.original_present = source.type() == VariantType::object && + source.contains(desc.id); + if(editor.original_present) editor.original = source[desc.id]; + if(!desc.alternate_id.empty()) + { + editor.alternate_present = source.type() == VariantType::object && + source.contains(desc.alternate_id); + if(editor.alternate_present) + editor.alternate_original = source[desc.alternate_id]; + } + Variant value = editor_source_value(editor); + switch(desc.editor) + { + case EditingPropertyEditor::boolean: + editor.boolean = value.boolean(); + break; + case EditingPropertyEditor::number: + editor.number = (f32)value.fnum(); + break; + case EditingPropertyEditor::string: + case EditingPropertyEditor::name: + case EditingPropertyEditor::asset: + editor.text = value.c_str(); + break; + case EditingPropertyEditor::enumeration: + { + Name selected = value.str(); + editor.selected_item = 0; + for(usize i = 0; i < desc.enumeration_items.size(); ++i) + { + if(desc.enumeration_items[i].value == selected) + { + editor.selected_item = (i32)i; + break; + } + } + break; + } + case EditingPropertyEditor::float2: + for(usize i = 0; i < 2; ++i) editor.vector[i] = 0.0f; + if(value.type() == VariantType::array) + { + for(usize i = 0; i < min(value.size(), 2); ++i) + editor.vector[i] = (f32)value[i].fnum(); + } + break; + case EditingPropertyEditor::float4: + case EditingPropertyEditor::color: + for(usize i = 0; i < 4; ++i) editor.vector[i] = 0.0f; + if(value.type() == VariantType::array) + { + for(usize i = 0; i < min(value.size(), 4); ++i) + editor.vector[i] = (f32)value[i].fnum(); + } + break; + case EditingPropertyEditor::visual_effects: + decode_visual_effects(value, editor.visual_effects); + break; + case EditingPropertyEditor::size: + if(editor.original_present) + { + editor.size_mode = 1; + editor.number = (f32)editor.original.fnum(); + } + else if(editor.alternate_present) + { + editor.size_mode = 2; + editor.number = (f32)editor.alternate_original.fnum() * 100.0f; + } + else + { + editor.size_mode = 0; + editor.number = 0.0f; + } + editor.baseline_size_mode = editor.size_mode; + break; + case EditingPropertyEditor::json: + editor.text = property_text(value); + break; + } + auto baseline = property_value(editor); + editor.baseline = baseline.valid() ? baseline.get() : value; + } + + DocumentView* EditorApp::find_document(u64 id) + { + for(DocumentView& document : documents) + { + if(document.id == id) return &document; + } + return nullptr; + } + + DocumentView* EditorApp::active_document() + { + if(documents.empty()) return nullptr; + selected_document = clamp(selected_document, 0, (i32)documents.size() - 1); + return &documents[(usize)selected_document]; + } + + void EditorApp::place_document_panel(u64 document_id, u64 target_document_id) + { + GUI::id_t dock_space = gui->make_id("editor.dock_space"); + GUI::id_t panel = document_panel_id(gui, document_id); + bool placed = false; + if(target_document_id && target_document_id != document_id) + { + placed = EditorGUI::dock_panel(gui, dock_space, panel, + document_panel_id(gui, target_document_id)); + } + if(!placed) + { + placed = EditorGUI::dock_panel(gui, dock_space, panel, + gui->make_id("panel.diagnostics"), EditorGUI::DockPanelPlacement::up, 0.78f); + } + if(!placed) + { + error_message = "The document panel could not be added to the dock space."; + return; + } + EditorGUI::activate_dock_panel(gui, dock_space, panel); + } + + bool EditorApp::invoke(const c8* url, const Variant& params, Variant& result) + { + auto response = service->frontend()->invoke(url, params); + if(!response.valid()) + { + error_message = explain(response.errcode()); + return false; + } + result = move(response.get()); + error_message.clear(); + return true; + } + + bool EditorApp::update_metadata(DocumentView& document, const Variant& metadata) + { + if(metadata.type() != VariantType::object) return false; + document.id = metadata["document_id"].unum(); + document.working_directory_id = metadata["working_directory_id"].unum(); + document.creation_folder = Path(metadata["creation_folder"].c_str()); + document.revision = metadata["revision"].unum(); + document.history_state = metadata["history_state"].unum(); + document.title = metadata["title"].c_str(); + document.asset_path = metadata["asset_path"].c_str(); + decode_guid_string(metadata["asset_guid"], document.asset_guid); + document.dirty = metadata["dirty"].boolean(); + document.can_undo = metadata["can_undo"].boolean(); + document.can_redo = metadata["can_redo"].boolean(); + document.diagnostics = metadata["diagnostics"]; + return true; + } + + bool EditorApp::refresh_snapshot(DocumentView& document) + { + Variant params(VariantType::object); + params["document_id"] = document.id; + Variant snapshot; + if(!invoke(GameGUIEditor::GET_SNAPSHOT_URL, params, snapshot)) return false; + update_metadata(document, snapshot); + auto decoded = decode_authoring_document(snapshot["document"]); + if(!decoded.valid()) + { + error_message = explain(decoded.errcode()); + return false; + } + document.snapshot = decoded.get(); + auto cooked = cook_authoring_document(*document.snapshot); + document.cooked_snapshot = cooked.valid() ? cooked.get() : Ref(); + if(!find_authoring_node(*document.snapshot, document.selected_node)) + document.selected_node = document.snapshot->root; + document.inspector_revision = 0; + document.preview.revision = 0; + return true; + } + + bool EditorApp::create_document() + { + DocumentView* target = active_document(); + u64 target_id = target ? target->id : 0; + Variant metadata; + Variant params(VariantType::object); + params["working_directory_id"] = selected_directory; + params["relative_directory"] = selected_folder.encode().c_str(); + if(!invoke(GameGUIEditor::CREATE_DOCUMENT_URL, params, metadata)) + return false; + DocumentView document; + update_metadata(document, metadata); + documents.push_back(move(document)); + selected_document = (i32)documents.size() - 1; + if(!refresh_snapshot(documents.back())) return false; + if(dock_layout_initialized) place_document_panel(documents.back().id, target_id); + return true; + } + + void EditorApp::show_file_error(const c8* title) + { + // Keep a copy across the native modal event loop and preserve Diagnostics output. + String message = error_message; + log_error("GameGUIEditor", "%s: %s", title, message.c_str()); + auto result = Window::message_box(message.c_str(), title, {"OK"}, + Window::MessageBoxIcon::error); + if(!result.valid()) + log_error("GameGUIEditor", "Failed to show the error dialog: %s", + explain(result.errcode())); + error_message = move(message); + } + + bool EditorApp::native_path_to_asset_path(Path native_path, String& asset_path, + bool allow_missing) + { + Variant params(VariantType::object), result; + params["native_path"] = native_path.encode().c_str(); + if(!invoke(RESOLVE_PATH_URL, params, result)) return false; + asset_path = result["path"].c_str(); + if(!allow_missing) + { + auto asset = Asset::get_asset_by_path(Path(asset_path.c_str())); + if(!asset.valid()) + { + error_message = "This file is not a registered asset in an opened working directory. Refresh its directory first."; + return false; + } + } + return true; + } + + bool EditorApp::open_document() + { + Window::FileDialogFilter filter; + filter.name = "GameGUI Document"; + const c8* extension = "json"; + filter.extensions = {&extension, 1}; + auto selected_files = Window::open_file_dialog("Open GameGUI Document", + {&filter, 1}, directory_native_path(selected_directory)); + if(!selected_files.valid()) + { + if(selected_files.errcode() != E_INTERRUPTED) + { + error_message = explain(selected_files.errcode()); + show_file_error("Open GameGUI Document Failed"); + } + return false; + } + if(selected_files.get().empty()) return false; + String asset_path; + if(!native_path_to_asset_path(selected_files.get()[0], asset_path)) + { + show_file_error("Open GameGUI Document Failed"); + return false; + } + Variant params(VariantType::object); + params["path"] = asset_path.c_str(); + return open_document_asset(params); + } + + bool EditorApp::open_document_asset(const Variant& params) + { + Variant metadata; + if(!invoke(GameGUIEditor::OPEN_DOCUMENT_URL, params, metadata)) + { + show_file_error("Open GameGUI Document Failed"); + return false; + } + u64 id = metadata["document_id"].unum(); + for(usize i = 0; i < documents.size(); ++i) + { + if(documents[i].id == id) + { + selected_document = (i32)i; + if(!refresh_snapshot(documents[i])) return false; + if(dock_layout_initialized) + { + EditorGUI::activate_dock_panel(gui, gui->make_id("editor.dock_space"), + document_panel_id(gui, documents[i].id)); + } + return true; + } + } + DocumentView* target = active_document(); + u64 target_id = target ? target->id : 0; + DocumentView document; + update_metadata(document, metadata); + documents.push_back(move(document)); + selected_document = (i32)documents.size() - 1; + if(!refresh_snapshot(documents.back())) return false; + if(dock_layout_initialized) place_document_panel(documents.back().id, target_id); + return true; + } + + void EditorApp::rebuild_inspector(DocumentView& document) + { + if(!document.snapshot) return; + const AuthoringNodeRecord* node = find_authoring_node(*document.snapshot, + document.selected_node); + if(!node) return; + document.node_name = node->name.c_str(); + document.property_editors.clear(); + const NodeTypeView* node_type = find_node_type(node_types, node->type); + if(node_type) + { + for(const EditingPropertyDesc& desc : node_type->property_schema.properties) + { + PropertyEditor editor; + initialize_property_editor(editor, desc, PropertyTarget::node, + node->properties); + document.property_editors.push_back(move(editor)); + } + } + + Guid parent_id; + usize sibling_index = 0; + if(find_parent_info(*document.snapshot, node->id, parent_id, sibling_index)) + { + const AuthoringNodeRecord* parent = find_authoring_node(*document.snapshot, + parent_id); + const NodeTypeView* parent_type = parent ? + find_node_type(node_types, parent->type) : nullptr; + if(parent && parent_type && sibling_index < parent->children.size()) + { + const Variant& attachment = parent->children[sibling_index].attachment; + for(const EditingPropertyDesc& desc : + parent_type->child_attachment_schema.properties) + { + PropertyEditor editor; + initialize_property_editor(editor, desc, + PropertyTarget::attachment, attachment); + document.property_editors.push_back(move(editor)); + } + } + } + + for(const auto& item : node->properties.key_values()) + { + if(node_type && schema_contains(node_type->property_schema, item.first)) + continue; + EditingPropertyDesc desc; + desc.id = item.first; + desc.display_name = item.first.c_str(); + desc.description = + "Property not declared by the installed editing schema."; + desc.section = EditingPropertySection::property; + desc.editor = item.second.type() == VariantType::boolean ? + EditingPropertyEditor::boolean : + (item.second.type() == VariantType::string ? + EditingPropertyEditor::string : EditingPropertyEditor::json); + PropertyEditor editor; + initialize_property_editor(editor, desc, PropertyTarget::node, + node->properties); + editor.raw = true; + document.property_editors.push_back(move(editor)); + } + document.inspector_revision = document.revision; + document.inspector_node = document.selected_node; + } + void EditorApp::undo_document(DocumentView& document) + { + if(!document.can_undo) return; + Variant result; + if(invoke(GameGUIEditor::UNDO_URL, editing_params(document), result)) + refresh_snapshot(document); + } + + void EditorApp::redo_document(DocumentView& document) + { + if(!document.can_redo) return; + Variant result; + if(invoke(GameGUIEditor::REDO_URL, editing_params(document), result)) + refresh_snapshot(document); + } + + bool EditorApp::save(DocumentView& document, bool save_as, u64 close_token) + { + Variant params = editing_params(document); + params["close_token"] = close_token; + const c8* url = GameGUIEditor::SAVE_URL; + if(save_as || document.asset_path.empty()) + { + Window::FileDialogFilter filter; + filter.name = "GameGUI Document"; + const c8* extension = "json"; + filter.extensions = {&extension, 1}; + Path initial_path = directory_native_path(document.working_directory_id); + initial_path.append(document.creation_folder); + if(document.asset_path.empty()) initial_path.push_back(Name("Untitled.json")); + else + { + Path current_path(document.asset_path.c_str()); + current_path.append_extension("json"); + auto native_path = VFS::get_native_path(current_path); + if(native_path.valid()) initial_path = native_path.get().c_str(); + } + auto selected_path = Window::save_file_dialog("Save GameGUI Document As", + {&filter, 1}, initial_path); + if(!selected_path.valid()) + { + if(selected_path.errcode() != E_INTERRUPTED) + { + error_message = explain(selected_path.errcode()); + show_file_error("Save GameGUI Document Failed"); + } + return false; + } + String asset_path; + if(!native_path_to_asset_path(selected_path.get(), asset_path, true)) + { + show_file_error("Save GameGUI Document Failed"); + return false; + } + auto existing = Asset::get_asset_by_path(Path(asset_path.c_str())); + if(existing.valid() && Asset::get_asset_guid(existing.get()) != document.asset_guid) + { + const c8* buttons[] = {"Replace", "Cancel"}; + auto response = Window::message_box("Replace the existing GameGUI asset at this destination?", + "Replace Asset", {buttons, 2}, Window::MessageBoxIcon::warning, 1, 1); + if(!response.valid() || response.get() != 0) return false; + params["overwrite"] = true; + } + params["path"] = asset_path.c_str(); + url = GameGUIEditor::SAVE_AS_URL; + } + Variant metadata; + if(!invoke(url, params, metadata)) + { + show_file_error("Save GameGUI Document Failed"); + return false; + } + // The savepoint is already committed even if refreshing the view later fails. + update_metadata(document, metadata); + if(!refresh_snapshot(document)) show_file_error("Document Saved, Refresh Failed"); + refresh_working_directories(); + return true; + } + + void EditorApp::cook(DocumentView& document) + { + Variant metadata; + if(invoke(GameGUIEditor::COOK_URL, editing_params(document), metadata)) + update_metadata(document, metadata); + } + + void EditorApp::remove_document_view(u64 id) + { + for(usize i = 0; i < documents.size(); ++i) + { + if(documents[i].id == id) + { + documents.erase(documents.begin() + i); + if(documents.empty()) selected_document = 0; + else + { + usize next = min(i, documents.size() - 1); + selected_document = (i32)next; + EditorGUI::activate_dock_panel(gui, gui->make_id("editor.dock_space"), + document_panel_id(gui, documents[next].id)); + } + break; + } + } + } + + void EditorApp::request_close(DocumentView& document, bool discard) + { + if(document.dirty && !discard) + { + constexpr usize SAVE_BUTTON_INDEX = 0; + constexpr usize DISCARD_BUTTON_INDEX = 1; + constexpr usize CANCEL_BUTTON_INDEX = 2; + const c8* buttons[] = {"Save", "Discard", "Cancel"}; + String message; + strprintf(message, "Save changes to \"%s\" before closing?", document.title.c_str()); + auto response = Window::message_box(message.c_str(), "Unsaved Changes", + Span(buttons, 3), Window::MessageBoxIcon::warning, + SAVE_BUTTON_INDEX, CANCEL_BUTTON_INDEX); + if(!response.valid()) + { + document.panel_open = true; + error_message = explain(response.errcode()); + return; + } + if(response.get() == CANCEL_BUTTON_INDEX) + { + document.panel_open = true; + return; + } + if(response.get() == SAVE_BUTTON_INDEX) + { + save(document, document.asset_path.empty()); + if(document.dirty) + { + document.panel_open = true; + return; + } + } + else discard = true; + } + Variant params = editing_params(document); + params["discard"] = discard; + Variant result; + if(invoke(GameGUIEditor::CLOSE_DOCUMENT_URL, params, result)) + { + u64 id = document.id; + // The editor draw list generated earlier in this frame may still refer to + // resources owned by this document, notably its preview texture. Keep the + // view alive until Renderer has consumed the draw list. + bool already_deferred = false; + for(u64 deferred_id : deferred_document_removals) + { + if(deferred_id == id) + { + already_deferred = true; + break; + } + } + if(!already_deferred) deferred_document_removals.push_back(id); + } + else document.panel_open = true; + } + } + } +} diff --git a/Programs/GameGUIEditor/Source/EditorExplorer.cpp b/Programs/GameGUIEditor/Source/EditorExplorer.cpp new file mode 100644 index 00000000..141eda8b --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorExplorer.cpp @@ -0,0 +1,218 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorExplorer.cpp +* @author JXMaster +* @date 2026/9/11 +*/ +#include "EditorApp.hpp" +#include +#include + +namespace Luna::GameGUIEditor::Internal +{ + void EditorApp::build_explorer_folder(const Variant& directory, const Path& folder, + u32 depth, UIHandles& handles) + { + u64 id = directory["working_directory_id"].unum(); + GUI::id_t scope = GUI::make_scoped_id(gui->make_id("explorer.directory"), id); + Vector children; + for(const Variant& value : directory["folders"].values()) + { + Path path(value.c_str()); + Path parent = path; + parent.pop_back(); + if(parent == folder) children.push_back(path); + } + sort(children.begin(), children.end(), [](const Path& a, const Path& b) + { return strcmp(a.encode().c_str(), b.encode().c_str()) < 0; }); + for(const Path& child : children) + { + auto flags = EditorGUI::TreeNodeFlag::open_on_arrow; + if(selected_directory == id && selected_folder == child) flags |= EditorGUI::TreeNodeFlag::selected; + GUI::ElementHandle element; + bool expanded = EditorGUI::tree_node(gui, GUI::make_scoped_id(scope, child.encode().c_str()), + child.back().c_str(), flags, depth, fill_width(26.0f), EditorGUI::DisclosureDesc(), &element); + handles.explorer.push_back({id, child, Guid(), element}); + if(expanded) build_explorer_folder(directory, child, depth + 1, handles); + } + Vector assets; + for(const Variant& asset : directory["assets"].values()) + { + if(asset["type"].str() != GameGUI::get_asset_type()) continue; + Path parent(asset["path"].c_str()); + parent.pop_back(); + if(parent == folder) assets.push_back(&asset); + } + sort(assets.begin(), assets.end(), [](const Variant* a, const Variant* b) + { return strcmp((*a)["path"].c_str(), (*b)["path"].c_str()) < 0; }); + for(const Variant* value : assets) + { + const Variant& asset = *value; + Guid guid; + if(!decode_guid_string(asset["asset_guid"], guid)) continue; + Path path(asset["path"].c_str()); + String label = path.back().c_str(); + auto flags = EditorGUI::TreeNodeFlag::leaf; + for(const DocumentView& document : documents) + { + if(document.asset_guid != guid) continue; + if(document.dirty) label.append(" *"); + if(active_document() && active_document()->id == document.id) flags |= EditorGUI::TreeNodeFlag::selected; + } + if(!asset["editable"].boolean()) label.append(" (no Authoring)"); + GUI::ElementHandle element; + EditorGUI::tree_node(gui, guid_gui_id(scope, guid), label.c_str(), flags, depth, + fill_width(26.0f), EditorGUI::DisclosureDesc(), &element); + // Disclosure leaves do not toggle and are non-interactive by default. Explorer + // still needs their selection, double-click and hover events. + GUI::Interactable interactable; + interactable.pointer_hit_behavior = GUI::PointerHitBehavior::target; + set_flags(interactable.flags, GUI::InteractableFlag::hoverable); + set_flags(interactable.flags, GUI::InteractableFlag::activatable); + set_flags(interactable.flags, GUI::InteractableFlag::focusable); + gui->set_interactable(element, interactable); + handles.explorer.push_back({id, path, guid, element}); + EditorGUI::set_item_tooltip(gui, GUI::make_scoped_id(element.id, "tooltip"), element, + asset["editable"].boolean() ? path.encode().c_str() : "This asset has no editable Authoring data unit."); + } + } + + void EditorApp::build_explorer_panel(UIHandles& handles) + { + if(!EditorGUI::begin_dock_panel(gui, gui->make_id("panel.explorer"), "Explorer")) return; + if(working_directories.empty()) + { + EditorGUI::text(gui, gui->make_id("explorer.empty"), "Open a working directory to create or edit assets.", fill_width(52.0f)); + handles.open_directory_button = EditorGUI::text_button(gui, gui->make_id("explorer.open"), "Open Directory...", fill_width(30.0f)); + } + EditorGUI::ScrollViewDesc scroll; + scroll.horizontal = false; + EditorGUI::begin_scroll_view(gui, gui->make_id("explorer.scroll"), "Asset Explorer", fill_layout(), scroll); + for(const Variant& directory : working_directories.values()) + { + u64 id = directory["working_directory_id"].unum(); + Path path(directory["native_path"].c_str()); + String label = path.empty() ? directory["native_path"].c_str() : path.back().c_str(); + if(label.empty()) label = directory["native_path"].c_str(); + if(directory["closing"].boolean()) label.append(" (closing)"); + auto flags = EditorGUI::TreeNodeFlag::open_on_arrow; + if(selected_directory == id && selected_folder.empty()) flags |= EditorGUI::TreeNodeFlag::selected; + GUI::ElementHandle element; + bool expanded = EditorGUI::tree_node(gui, GUI::make_scoped_id(gui->make_id("explorer.root"), id), + label.c_str(), flags, 0, fill_width(28.0f), EditorGUI::DisclosureDesc(), &element); + handles.explorer.push_back({id, Path(), Guid(), element}); + EditorGUI::set_item_tooltip(gui, GUI::make_scoped_id(element.id, "tooltip"), element, directory["native_path"].c_str()); + if(expanded) build_explorer_folder(directory, Path(), 1, handles); + } + EditorGUI::end_scroll_view(gui); + GUI::id_t popup_id = gui->make_id("explorer.context"); + EditorGUI::PopupDesc desc; + desc.position = explorer_context_position; + desc.layout = fixed_layout(190.0f, 74.0f); + GUI::ElementHandle popup; + if(EditorGUI::begin_popup(gui, popup_id, desc, &popup)) + { + handles.refresh_directory = EditorGUI::menu_item(gui, GUI::make_scoped_id(popup_id, "refresh"), "Refresh Directory"); + handles.unload_directory = EditorGUI::menu_item(gui, GUI::make_scoped_id(popup_id, "unload"), "Unload Directory"); + lupanic_if_failed(EditorGUI::end_popup(gui, popup, RectF(0, 0, 190, 74))); + } + EditorGUI::end_dock_panel(gui); + } + + bool EditorApp::process_explorer_interactions(const UIHandles& handles) + { + if(EditorGUI::is_item_clicked(gui, handles.open_directory) || + EditorGUI::is_item_clicked(gui, handles.open_directory_button)) + { + open_working_directory(); + return true; + } + if(EditorGUI::is_item_clicked(gui, handles.unload_directory)) + { + EditorGUI::close_popup(gui, gui->make_id("explorer.context")); + Variant plan; + if(confirm_directory_close(explorer_context_directory, plan)) deferred_directory_closes.push_back(move(plan)); + return true; + } + if(EditorGUI::is_item_clicked(gui, handles.refresh_directory)) + { + EditorGUI::close_popup(gui, gui->make_id("explorer.context")); + Variant params(VariantType::object), result; + params["working_directory_id"] = explorer_context_directory; + if(!invoke(REFRESH_DIRECTORY_URL, params, result)) show_file_error("Refresh Directory Failed"); + else refresh_working_directories(); + return true; + } + for(const auto& hit : handles.explorer) + { + if(EditorGUI::is_item_clicked(gui, hit.element)) + { + selected_directory = hit.directory_id; + selected_folder = hit.path; + if(hit.asset != Guid()) selected_folder.pop_back(); + } + if(hit.asset != Guid() && EditorGUI::is_item_double_clicked(gui, hit.element)) + { + Variant params(VariantType::object); + params["asset_guid"] = guid_string(hit.asset).c_str(); + open_document_asset(params); + return true; + } + if(hit.asset == Guid() && hit.path.empty()) + { + for(const auto& routed : gui->get_routed_input_events(hit.element.id)) + { + const auto& event = routed.event; + if(event.type != GUI::InputEventType::pointer_down || event.button != GUI::PointerButton::right) continue; + explorer_context_directory = hit.directory_id; + explorer_context_position = event.position; + EditorGUI::open_popup(gui, gui->make_id("explorer.context")); + return true; + } + } + } + return false; + } + + void EditorApp::build_asset_picker(UIHandles& handles) + { + auto document = find_document(asset_picker_document); + GUI::id_t popup_id = gui->make_id("inspector.asset_picker"); + if(!document || document->revision != asset_picker_revision || + asset_picker_property >= document->property_editors.size()) + { + EditorGUI::close_popup(gui, popup_id); + return; + } + EditorGUI::PopupDesc desc; + desc.position = asset_picker_position; + desc.layout = fixed_layout(360.0f, 320.0f); + GUI::ElementHandle popup; + if(!EditorGUI::begin_popup(gui, popup_id, desc, &popup)) return; + EditorGUI::text(gui, GUI::make_scoped_id(popup_id, "title"), "Assets in Open Working Directories", fill_width(26.0f)); + EditorGUI::ScrollViewDesc scroll; + scroll.horizontal = false; + EditorGUI::begin_scroll_view(gui, GUI::make_scoped_id(popup_id, "scroll"), "Select Asset", fill_layout(), scroll); + const auto& property = document->property_editors[asset_picker_property]; + for(const Variant& directory : working_directories.values()) + { + if(directory["closing"].boolean()) continue; + for(const Variant& asset : directory["assets"].values()) + { + if(!property.desc.asset_type.empty() && asset["type"].str() != property.desc.asset_type) continue; + Guid guid; + if(!decode_guid_string(asset["asset_guid"], guid)) continue; + Path root(directory["native_path"].c_str()); + String label; + strprintf(label, "%s / %s", root.empty() ? "/" : root.back().c_str(), asset["path"].c_str()); + GUI::ElementHandle item = EditorGUI::text_button(gui, guid_gui_id(popup_id, guid), label.c_str(), fill_width(28.0f)); + handles.asset_choices.push_back({directory["working_directory_id"].unum(), Path(asset["path"].c_str()), guid, item}); + } + } + EditorGUI::end_scroll_view(gui); + lupanic_if_failed(EditorGUI::end_popup(gui, popup, RectF(0, 0, 360, 320))); + } +} diff --git a/Programs/GameGUIEditor/Source/EditorInteractions.cpp b/Programs/GameGUIEditor/Source/EditorInteractions.cpp new file mode 100644 index 00000000..a87d59af --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorInteractions.cpp @@ -0,0 +1,614 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorInteractions.cpp +* @author JXMaster +* @date 2026/8/28 +*/ +#include "EditorApp.hpp" +#include + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { + R draw_hierarchy_drop(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) + { + HierarchyDragState* drag = (HierarchyDragState*)userdata; + if(!drag || !drag->dragging || drag->drop_mode == HierarchyDropMode::none) + return paint_order_id; + const Float4U color(0.96f, 0.34f, 0.44f, 1.0f); + if(drag->drop_mode == HierarchyDropMode::child) + { + GUI::DrawCommand highlight; + highlight.type = GUI::DrawCommandType::rounded_rect; + highlight.rect_reference = GUI::DrawCommandRectReference::layer; + highlight.rect = drag->feedback_rect; + highlight.color = Float4U(color.x, color.y, color.z, 0.22f); + highlight.radius = 4.0f; + context->draw(highlight, paint_order_id); + return paint_order_id; + } + + f32 y = drag->drop_mode == HierarchyDropMode::before ? + drag->feedback_rect.offset_y : + drag->feedback_rect.offset_y + drag->feedback_rect.height; + f32 left = drag->feedback_rect.offset_x + 8.0f + + (f32)drag->feedback_depth * 16.0f; + f32 right = drag->feedback_rect.offset_x + drag->feedback_rect.width - 8.0f; + GUI::DrawCommand line; + line.type = GUI::DrawCommandType::line; + line.rect_reference = GUI::DrawCommandRectReference::layer; + line.rect = RectF(left, y, 0.0f, 0.0f); + line.point1 = Float2U(max(right, left), y); + line.color = color; + line.line_width = 2.0f; + context->draw(line, paint_order_id); + return paint_order_id; + } + + void EditorApp::apply_inspector_changes(DocumentView& document) + { + // A document opened through Explorer may not have a visible Inspector this frame. + // Never apply uninitialized or stale property-editor buffers to its new snapshot. + if(document.inspector_revision != document.revision || + document.inspector_node != document.selected_node) return; + if(!document.snapshot) return; + const AuthoringNodeRecord* node = find_authoring_node(*document.snapshot, + document.selected_node); + if(!node) return; + Variant commands(VariantType::array); + usize changed_field_count = 0; + String coalesce_key; + auto mark_changed = [&](const c8* target, const Name& property) + { + ++changed_field_count; + if(changed_field_count == 1) + { + coalesce_key = guid_string(node->id); + coalesce_key.append("."); + coalesce_key.append(target); + coalesce_key.append("."); + coalesce_key.append(property.c_str()); + } + else coalesce_key.clear(); + }; + if(Name(document.node_name.c_str()) != node->name) + { + Variant command(VariantType::object); + command["kind"] = "set_name"; + command["node"] = guid_string(node->id).c_str(); + command["name"] = document.node_name.c_str(); + commands.push_back(move(command)); + mark_changed("node", "name"); + } + + Variant attachment(VariantType::object); + bool attachment_changed = false; + Guid parent_id; + usize sibling_index = 0; + if(find_parent_info(*document.snapshot, node->id, parent_id, sibling_index)) + { + const AuthoringNodeRecord* parent = find_authoring_node(*document.snapshot, + parent_id); + if(parent && sibling_index < parent->children.size() && + parent->children[sibling_index].attachment.type() == VariantType::object) + { + attachment = parent->children[sibling_index].attachment; + } + } + for(const PropertyEditor& editor : document.property_editors) + { + auto value = property_value(editor); + if(!value.valid()) + { + error_message = explain(value.errcode()); + continue; + } + bool changed = editor.desc.editor == EditingPropertyEditor::size ? + (editor.size_mode != editor.baseline_size_mode || + (editor.size_mode != 0 && value.get() != editor.baseline)) : + value.get() != editor.baseline; + if(!changed) continue; + mark_changed(editor.target == PropertyTarget::node ? "node" : "attachment", + editor.desc.id); + if(editor.target == PropertyTarget::attachment) + { + attachment[editor.desc.id] = value.get(); + attachment_changed = true; + continue; + } + if(editor.desc.editor == EditingPropertyEditor::size) + { + Variant remove_fixed(VariantType::object); + remove_fixed["kind"] = "remove_property"; + remove_fixed["node"] = guid_string(node->id).c_str(); + remove_fixed["property"] = editor.desc.id; + commands.push_back(move(remove_fixed)); + Variant remove_percent(VariantType::object); + remove_percent["kind"] = "remove_property"; + remove_percent["node"] = guid_string(node->id).c_str(); + remove_percent["property"] = editor.desc.alternate_id; + commands.push_back(move(remove_percent)); + if(editor.size_mode != 0) + { + Variant set_size(VariantType::object); + set_size["kind"] = "set_property"; + set_size["node"] = guid_string(node->id).c_str(); + set_size["property"] = editor.size_mode == 1 ? + editor.desc.id : editor.desc.alternate_id; + set_size["value"] = value.get(); + commands.push_back(move(set_size)); + } + } + else + { + Variant command(VariantType::object); + command["kind"] = "set_property"; + command["node"] = guid_string(node->id).c_str(); + command["property"] = editor.desc.id; + command["value"] = value.get(); + commands.push_back(move(command)); + } + } + if(attachment_changed) + { + Variant command(VariantType::object); + command["kind"] = "set_attachment"; + command["node"] = guid_string(node->id).c_str(); + command["attachment"] = move(attachment); + commands.push_back(move(command)); + } + if(commands.empty()) return; + Variant params = editing_params(document); + params["commands"] = move(commands); + params["label"] = "Edit node properties"; + if(changed_field_count == 1) + params["coalesce_key"] = coalesce_key.c_str(); + Variant metadata; + if(invoke(GameGUIEditor::APPLY_COMMANDS_URL, params, metadata)) + refresh_snapshot(document); + } + + void EditorApp::update_hierarchy_drop(DocumentView& document, + const UIHandles& handles, const Float2U& pointer_position) + { + HierarchyDragState& drag = document.hierarchy_drag; + drag.drop_mode = HierarchyDropMode::none; + drag.target_node = Guid(); + drag.target_parent = Guid(); + drag.target_index = 0; + if(!document.snapshot || !drag.dragging) return; + + auto can_insert_at = [&](const Guid& parent, usize target_index) + { + if(drag.source_type != Guid()) return true; + if(subtree_contains(*document.snapshot, drag.source, parent)) return false; + Guid old_parent; + usize old_index = 0; + if(!find_parent_info(*document.snapshot, drag.source, old_parent, + old_index)) return false; + if(old_parent == parent && old_index < target_index) --target_index; + return old_parent != parent || old_index != target_index; + }; + auto set_reorder_target = [&](const NodeHit& hit, + HierarchyDropMode mode, const RectF& feedback_rect) + { + if(hit.parent == Guid()) return false; + usize target_index = hit.sibling_index + + (mode == HierarchyDropMode::after ? 1 : 0); + if(!can_insert_at(hit.parent, target_index)) return false; + drag.drop_mode = mode; + drag.target_node = hit.node; + drag.target_parent = hit.parent; + drag.target_index = target_index; + drag.feedback_rect = feedback_rect; + drag.feedback_depth = hit.depth; + return true; + }; + + for(const NodeHit& hit : handles.nodes) + { + RectF screen_rect = item_screen_rect(gui, hit.element); + RectF screen_clip = item_screen_rect(gui, hit.element, true); + if(!point_in_rect(screen_rect, pointer_position) || + !point_in_rect(screen_clip, pointer_position)) continue; + + HierarchyDropMode mode = HierarchyDropMode::child; + if(hit.node != document.snapshot->root) + { + f32 local_y = pointer_position.y - screen_rect.offset_y; + f32 edge_size = min(6.0f, screen_rect.height * 0.25f); + if(local_y <= edge_size) mode = HierarchyDropMode::before; + else if(local_y >= screen_rect.height - edge_size) + mode = HierarchyDropMode::after; + } + + if(mode != HierarchyDropMode::child) + { + set_reorder_target(hit, mode, + EditorGUI::get_item_rect(gui, hit.element)); + return; + } + const AuthoringNodeRecord* target = find_authoring_node( + *document.snapshot, hit.node); + if(!target) return; + usize target_index = target->children.size(); + if(!can_insert_at(hit.node, target_index)) return; + + drag.drop_mode = mode; + drag.target_node = hit.node; + drag.target_parent = hit.node; + drag.target_index = target_index; + drag.feedback_rect = EditorGUI::get_item_rect(gui, hit.element); + drag.feedback_depth = hit.depth; + return; + } + + for(usize i = 1; i < handles.nodes.size(); ++i) + { + const NodeHit& previous = handles.nodes[i - 1]; + const NodeHit& next = handles.nodes[i]; + RectF previous_rect = item_screen_rect(gui, previous.element); + RectF next_rect = item_screen_rect(gui, next.element); + RectF next_clip = item_screen_rect(gui, next.element, true); + f32 gap_top = previous_rect.offset_y + previous_rect.height; + f32 gap_bottom = next_rect.offset_y; + if(gap_bottom < gap_top || pointer_position.y < gap_top || + pointer_position.y > gap_bottom || + pointer_position.x < next_rect.offset_x || + pointer_position.x >= next_rect.offset_x + next_rect.width || + !point_in_rect(next_clip, pointer_position)) continue; + set_reorder_target(next, HierarchyDropMode::before, + EditorGUI::get_item_rect(gui, next.element)); + return; + } + } + + bool EditorApp::apply_hierarchy_drop(DocumentView& document) + { + HierarchyDragState& drag = document.hierarchy_drag; + if(!document.snapshot || drag.drop_mode == HierarchyDropMode::none) + return false; + if(drag.source_type != Guid()) + { + Guid type = drag.source_type; + Guid parent = drag.target_parent; + usize index = drag.target_index; + drag = HierarchyDragState(); + add_node(document, type, parent, index); + return true; + } + Guid old_parent; + usize old_index = 0; + if(!find_parent_info(*document.snapshot, drag.source, old_parent, old_index)) + return false; + usize target_index = drag.target_index; + if(old_parent == drag.target_parent && old_index < target_index) + --target_index; + if(old_parent == drag.target_parent && old_index == target_index) + return false; + + Guid source = drag.source; + Guid target_parent = drag.target_parent; + Variant command(VariantType::object); + command["kind"] = "move_node"; + command["node"] = guid_string(source).c_str(); + command["parent"] = guid_string(target_parent).c_str(); + command["index"] = (u64)target_index; + if(old_parent != target_parent) + { + command["slot"] = ""; + command["attachment"] = Variant(); + } + Variant params = editing_params(document); + params["commands"] = Variant(VariantType::array); + params["commands"].push_back(move(command)); + params["label"] = old_parent == target_parent ? "Reorder node" : + "Reparent node"; + drag = HierarchyDragState(); + Variant result; + if(invoke(GameGUIEditor::APPLY_COMMANDS_URL, params, result)) + { + document.selected_node = source; + document.hierarchy_expand_node = target_parent; + refresh_snapshot(document); + } + return true; + } + + void EditorApp::add_node(DocumentView& document, const Guid& type, + Guid parent, usize index) + { + Variant command(VariantType::object); + command["kind"] = "insert_node"; + command["parent"] = guid_string(parent).c_str(); + command["type"] = guid_string(type).c_str(); + if(index != USIZE_MAX) command["index"] = (u64)index; + Variant params = editing_params(document); + params["commands"] = Variant(VariantType::array); + params["commands"].push_back(move(command)); + params["label"] = "Add node"; + Variant result; + if(invoke(GameGUIEditor::APPLY_COMMANDS_URL, params, result)) + { + if(!result["created_nodes"].empty()) + decode_guid_string(result["created_nodes"][0], document.selected_node); + document.hierarchy_expand_node = parent; + refresh_snapshot(document); + } + } + + bool EditorApp::process_hierarchy_interactions(DocumentView& document, + const UIHandles& handles) + { + if(!document.snapshot) return false; + HierarchyDragState& drag = document.hierarchy_drag; + bool pressed_this_frame = false; + for(const TypeHit& hit : handles.types) + { + for(const GUI::RoutedInputEvent& routed : + gui->get_routed_input_events(hit.element.id)) + { + const GUI::InputEvent& event = routed.event; + if(event.type == GUI::InputEventType::pointer_down && + event.button == GUI::PointerButton::left) + { + drag = HierarchyDragState(); + drag.source_type = hit.type; + drag.source_element = hit.element.id; + drag.press_position = event.position; + drag.pressed = true; + pressed_this_frame = true; + } + } + } + for(const NodeHit& hit : handles.nodes) + { + if(EditorGUI::is_item_right_clicked(gui, hit.element)) + { + document.selected_node = hit.node; + document.inspector_revision = 0; + drag = HierarchyDragState(); + GUI::id_t popup_id = hierarchy_context_popup_id(gui, document.id); + if(hit.node != document.snapshot->root) + { + document.hierarchy_context_node = hit.node; + document.hierarchy_context_position = gui->get_pointer_position(); + EditorGUI::open_popup(gui, popup_id); + } + else + { + document.hierarchy_context_node = Guid(); + EditorGUI::close_popup(gui, popup_id); + } + break; + } + for(const GUI::RoutedInputEvent& routed : + gui->get_routed_input_events(hit.element.id)) + { + const GUI::InputEvent& event = routed.event; + if(event.type == GUI::InputEventType::pointer_down && + event.button == GUI::PointerButton::left) + { + document.selected_node = hit.node; + document.inspector_revision = 0; + drag = HierarchyDragState(); + if(hit.node != document.snapshot->root) + { + drag.source = hit.node; + drag.source_element = hit.element.id; + drag.press_position = event.position; + drag.pressed = true; + pressed_this_frame = true; + } + } + } + } + if(!drag.pressed) return false; + // Preserve excursions outside the click threshold even if the pointer returns + // to its starting position within the same input batch. + bool tracking_motion = !pressed_this_frame; + for(const GUI::RoutedInputEvent& routed : + gui->get_routed_input_events(drag.source_element)) + { + const GUI::InputEvent& event = routed.event; + if(event.type == GUI::InputEventType::pointer_down && + event.button == GUI::PointerButton::left) tracking_motion = true; + if(tracking_motion && (event.type == GUI::InputEventType::pointer_move || + event.type == GUI::InputEventType::pointer_up)) + { + f32 delta_x = event.position.x - drag.press_position.x; + f32 delta_y = event.position.y - drag.press_position.y; + if(delta_x * delta_x + delta_y * delta_y >= 16.0f) drag.dragging = true; + } + if(event.type == GUI::InputEventType::pointer_up && + event.button == GUI::PointerButton::left) tracking_motion = false; + } + bool released = false; + Float2U pointer_position = gui->get_pointer_position(); + for(const GUI::InputEvent& event : gui->get_input_events()) + { + if(event.type == GUI::InputEventType::blur) + { + drag = HierarchyDragState(); + return true; + } + if((event.type == GUI::InputEventType::key_down && event.key == KeyCode::esc) || + event.type == GUI::InputEventType::navigation_back) + { + drag.cancelled = true; + drag.drop_mode = HierarchyDropMode::none; + } + if(event.type == GUI::InputEventType::pointer_up && + event.button == GUI::PointerButton::left) + { + released = true; + pointer_position = event.position; + } + } + f32 delta_x = pointer_position.x - drag.press_position.x; + f32 delta_y = pointer_position.y - drag.press_position.y; + if(!drag.dragging && delta_x * delta_x + delta_y * delta_y >= 16.0f) + drag.dragging = true; + if(drag.dragging && !drag.cancelled) + update_hierarchy_drop(document, handles, pointer_position); + if(gui->is_pointer_button_down(GUI::PointerButton::left)) return false; + if(drag.dragging || drag.cancelled) + { + if(released && !drag.cancelled) apply_hierarchy_drop(document); + drag = HierarchyDragState(); + // A drag released over its palette button must not also insert via click. + return true; + } + else + { + drag = HierarchyDragState(); + } + return false; + } + + void EditorApp::process_interactions(const UIHandles& handles) + { + if(directory_dialog_active || !deferred_directory_closes.empty()) return; + if(process_explorer_interactions(handles)) return; + for(usize i = 0; i < documents.size(); ++i) + { + if(documents[i].panel_open) continue; + selected_document = (i32)i; + apply_inspector_changes(documents[i]); + request_close(documents[i], false); + return; + } + + if(EditorGUI::is_item_clicked(gui, handles.create_document)) create_document(); + if(EditorGUI::is_item_clicked(gui, handles.open_document)) open_document(); + + DocumentView* document = find_document(handles.document_id); + if(!document) document = active_document(); + for(DocumentView& view : documents) + { + if(&view != document) view.hierarchy_drag = HierarchyDragState(); + } + if(document) + { + if(process_visual_effect_actions(*document, handles)) return; + apply_inspector_changes(*document); + for(const PropertyActionHit& hit : handles.browse_assets) + { + if(!EditorGUI::is_item_clicked(gui, hit.element) || + hit.property_index >= document->property_editors.size()) continue; + PropertyEditor& property = + document->property_editors[hit.property_index]; + asset_picker_document = document->id; + asset_picker_property = hit.property_index; + asset_picker_revision = document->revision; + RectF rect = item_screen_rect(gui, hit.element); + asset_picker_position = Float2U(rect.offset_x - 280.0f, rect.offset_y + rect.height); + EditorGUI::open_popup(gui, gui->make_id("inspector.asset_picker")); + return; + } + for(const auto& choice : handles.asset_choices) + { + if(!EditorGUI::is_item_clicked(gui, choice.element)) continue; + if(asset_picker_document == document->id && asset_picker_revision == document->revision && + asset_picker_property < document->property_editors.size()) + { + document->property_editors[asset_picker_property].text = guid_string(choice.asset); + apply_inspector_changes(*document); + } + EditorGUI::close_popup(gui, gui->make_id("inspector.asset_picker")); + return; + } + if(process_hierarchy_interactions(*document, handles)) return; + for(const NodeHit& hit : handles.nodes) + { + if(EditorGUI::is_item_clicked(gui, hit.element)) + { + document->selected_node = hit.node; + document->inspector_revision = 0; + break; + } + } + + if(EditorGUI::is_item_clicked(gui, handles.save_document)) save(*document, false); + if(EditorGUI::is_item_clicked(gui, handles.save_as_document)) save(*document, true); + if(EditorGUI::is_item_clicked(gui, handles.cook_document)) cook(*document); + if(EditorGUI::is_item_clicked(gui, handles.undo)) undo_document(*document); + if(EditorGUI::is_item_clicked(gui, handles.redo)) redo_document(*document); + if(EditorGUI::is_item_clicked(gui, handles.close_document)) + { + request_close(*document, false); + return; + } + + if(EditorGUI::is_item_clicked(gui, handles.set_root_node) && + document->snapshot && document->hierarchy_context_node != Guid() && + document->hierarchy_context_node != document->snapshot->root) + { + Guid node = document->hierarchy_context_node; + EditorGUI::close_popup(gui, + hierarchy_context_popup_id(gui, document->id)); + document->hierarchy_context_node = Guid(); + Variant command(VariantType::object); + command["kind"] = "set_root"; + command["node"] = guid_string(node).c_str(); + Variant params = editing_params(*document); + params["commands"] = Variant(VariantType::array); + params["commands"].push_back(move(command)); + params["label"] = "Set root node"; + Variant result; + if(invoke(GameGUIEditor::APPLY_COMMANDS_URL, params, result)) + { + document->selected_node = node; + refresh_snapshot(*document); + } + return; + } + + if(EditorGUI::is_item_clicked(gui, handles.delete_node) && + document->snapshot && document->hierarchy_context_node != Guid() && + document->hierarchy_context_node != document->snapshot->root) + { + Guid node = document->hierarchy_context_node; + Guid parent; + usize sibling_index = 0; + find_parent_info(*document->snapshot, node, parent, sibling_index); + EditorGUI::close_popup(gui, + hierarchy_context_popup_id(gui, document->id)); + document->hierarchy_context_node = Guid(); + Variant command(VariantType::object); + command["kind"] = "remove_node"; + command["node"] = guid_string(node).c_str(); + Variant params = editing_params(*document); + params["commands"] = Variant(VariantType::array); + params["commands"].push_back(move(command)); + params["label"] = "Delete node"; + Variant result; + if(invoke(GameGUIEditor::APPLY_COMMANDS_URL, params, result)) + { + document->selected_node = parent; + refresh_snapshot(*document); + } + return; + } + + for(const TypeHit& hit : handles.types) + { + if(!EditorGUI::is_item_clicked(gui, hit.element)) continue; + add_node(*document, hit.type, document->selected_node); + break; + } + + } + + } + } + } +} diff --git a/Programs/GameGUIEditor/Source/EditorPreview.cpp b/Programs/GameGUIEditor/Source/EditorPreview.cpp new file mode 100644 index 00000000..90497778 --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorPreview.cpp @@ -0,0 +1,751 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorPreview.cpp +* @author JXMaster +* @date 2026/8/28 +*/ +#include "EditorApp.hpp" +#include +#include +#include + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { + usize preview_zoom_level_index(f32 zoom) + { + usize result = 0; + f32 minimum_difference = abs(zoom - PREVIEW_ZOOM_LEVELS[0]); + for(usize i = 1; i < PREVIEW_ZOOM_LEVEL_COUNT; ++i) + { + f32 difference = abs(zoom - PREVIEW_ZOOM_LEVELS[i]); + if(difference < minimum_difference) + { + result = i; + minimum_difference = difference; + } + } + return result; + } + + Float2U preview_logical_position(const PreviewState& preview, + const Float2U& surface_position) + { + Float2U center((f32)PREVIEW_LAYOUT_WIDTH * 0.5f, + (f32)PREVIEW_LAYOUT_HEIGHT * 0.5f); + return Float2U( + center.x + (surface_position.x - center.x - preview.pan.x) / preview.zoom, + center.y + (surface_position.y - center.y - preview.pan.y) / preview.zoom); + } + + static void enable_preview_node_hit_testing(DocumentView& document) + { + PreviewState& preview = document.preview; + if(!preview.context || !preview.instance) return; + for(const GameGUI::GeneratedNodeInfo& generated : + preview.instance->get_generated_nodes()) + { + GUI::ElementHandle handle = preview.context->find_element_handle( + generated.root_element_id); + const GUI::Element* element = preview.context->get_element(handle.index); + if(!element || element->id != handle.id || + element->interactable.pointer_hit_behavior != + GUI::PointerHitBehavior::none) + { + continue; + } + GUI::Interactable interactable = element->interactable; + interactable.pointer_hit_behavior = GUI::PointerHitBehavior::pass_through; + preview.context->set_interactable(handle, interactable); + } + } + + static bool find_preview_node_from_element(const DocumentView& document, + const GUI::Element* element, Guid& node, GUI::id_t& root_element_id) + { + const PreviewState& preview = document.preview; + if(!preview.context || !preview.instance || !document.snapshot) return false; + Span generated_nodes = + preview.instance->get_generated_nodes(); + while(element) + { + for(const GameGUI::GeneratedNodeInfo& generated : generated_nodes) + { + if(generated.root_element_id == element->id && + find_authoring_node(*document.snapshot, generated.node)) + { + node = generated.node; + root_element_id = generated.root_element_id; + return true; + } + } + if(element->parent == GUI::INVALID_ELEMENT) break; + element = preview.context->get_element(element->parent); + } + return false; + } + + static void select_preview_node(DocumentView& document, + const Float2U& position) + { + PreviewState& preview = document.preview; + if(!preview.context || !preview.instance || !document.snapshot) return; + Guid selected_node; + GUI::id_t selected_element_id = 0; + preview.context->hit_test(position, [&](const GUI::HitTestVisit& visit) + { + if(selected_node != Guid()) return; + find_preview_node_from_element(document, visit.element_data, + selected_node, selected_element_id); + }); + if(selected_node == Guid()) return; + document.selected_node = selected_node; + document.inspector_revision = 0; + preview.selected_element_id = selected_element_id; + } + + static void update_preview_selected_element(DocumentView& document) + { + PreviewState& preview = document.preview; + preview.selected_element_id = 0; + if(!preview.instance) return; + for(const GameGUI::GeneratedNodeInfo& generated : + preview.instance->get_generated_nodes()) + { + if(generated.node == document.selected_node) + { + preview.selected_element_id = generated.root_element_id; + return; + } + } + } + + R draw_preview_background(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata) + { + PreviewState& preview = *(PreviewState*)userdata; + Float2U logical_top_left = preview_logical_position(preview, Float2U(0.0f)); + Float2U logical_bottom_right = preview_logical_position(preview, + preview.viewport_size); + GUI::DrawCommand background; + background.type = GUI::DrawCommandType::rect; + background.rect_reference = GUI::DrawCommandRectReference::layer; + background.rect = RectF(logical_top_left.x, logical_top_left.y, + logical_bottom_right.x - logical_top_left.x, + logical_bottom_right.y - logical_top_left.y); + background.color = Float4U(0.26f, 0.26f, 0.26f, 1.0f); + context->draw(background, paint_order_id); + + f32 grid_size = PREVIEW_GRID_SIZE; + while(grid_size * preview.zoom < 12.0f) grid_size *= 2.0f; + i32 first_x = (i32)floor(logical_top_left.x / grid_size); + i32 last_x = (i32)ceil(logical_bottom_right.x / grid_size); + i32 first_y = (i32)floor(logical_top_left.y / grid_size); + i32 last_y = (i32)ceil(logical_bottom_right.y / grid_size); + for(i32 i = first_x; i <= last_x; ++i) + { + f32 x = (f32)i * grid_size; + GUI::DrawCommand line; + line.type = GUI::DrawCommandType::line; + line.rect_reference = GUI::DrawCommandRectReference::layer; + line.rect = RectF(x, logical_top_left.y, 0.0f, 0.0f); + line.point1 = Float2U(x, logical_bottom_right.y); + line.color = Float4U(1.0f, 1.0f, 1.0f, + (i % 4) ? 0.12f : 0.22f); + line.line_width = ((i % 4) ? 1.0f : 1.25f) / preview.zoom; + context->draw(line, paint_order_id + 1); + } + for(i32 i = first_y; i <= last_y; ++i) + { + f32 y = (f32)i * grid_size; + GUI::DrawCommand line; + line.type = GUI::DrawCommandType::line; + line.rect_reference = GUI::DrawCommandRectReference::layer; + line.rect = RectF(logical_top_left.x, y, 0.0f, 0.0f); + line.point1 = Float2U(logical_bottom_right.x, y); + line.color = Float4U(1.0f, 1.0f, 1.0f, + (i % 4) ? 0.12f : 0.22f); + line.line_width = ((i % 4) ? 1.0f : 1.25f) / preview.zoom; + context->draw(line, paint_order_id + 1); + } + return paint_order_id + 1; + } + + R draw_preview_overlay(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata) + { + PreviewState& preview = *(PreviewState*)userdata; + const GUI::Element* selected = context->find_element( + preview.selected_element_id); + const GUI::Element* overlay = context->get_element(element.index); + Span layers = context->get_layers(); + if(selected && overlay && overlay->id == element.id && + selected->layer < layers.size() && overlay->layer < layers.size()) + { + const Float2U& selected_layer = layers[selected->layer].screen_position; + const Float2U& overlay_layer = layers[overlay->layer].screen_position; + RectF rect = selected->layout_result.rect; + rect.offset_x += selected_layer.x - overlay_layer.x; + rect.offset_y += selected_layer.y - overlay_layer.y; + f32 thickness = min(2.0f / preview.zoom, + min(rect.width, rect.height) * 0.5f); + if(thickness > 0.0f) + { + GUI::DrawCommand border; + border.type = GUI::DrawCommandType::rect; + border.rect_reference = GUI::DrawCommandRectReference::layer; + border.color = Float4U(0.96f, 0.34f, 0.44f, 1.0f); + border.rect = RectF(rect.offset_x, rect.offset_y, + rect.width, thickness); + context->draw(border, paint_order_id); + border.rect = RectF(rect.offset_x, + rect.offset_y + rect.height - thickness, rect.width, thickness); + context->draw(border, paint_order_id); + border.rect = RectF(rect.offset_x, rect.offset_y, + thickness, rect.height); + context->draw(border, paint_order_id); + border.rect = RectF(rect.offset_x + rect.width - thickness, + rect.offset_y, thickness, rect.height); + context->draw(border, paint_order_id); + ++paint_order_id; + } + } + constexpr f32 BADGE_WIDTH = 68.0f; + constexpr f32 BADGE_HEIGHT = 26.0f; + constexpr f32 BADGE_MARGIN = 8.0f; + Float2U badge_top_left = preview_logical_position(preview, + Float2U(preview.viewport_size.x - BADGE_WIDTH - BADGE_MARGIN, + preview.viewport_size.y - BADGE_HEIGHT - BADGE_MARGIN)); + RectF badge(badge_top_left.x, badge_top_left.y, + BADGE_WIDTH / preview.zoom, BADGE_HEIGHT / preview.zoom); + GUI::DrawCommand background; + background.type = GUI::DrawCommandType::rounded_rect; + background.rect_reference = GUI::DrawCommandRectReference::layer; + background.rect = badge; + background.color = Float4U(0.08f, 0.08f, 0.08f, 0.82f); + background.radius = 5.0f / preview.zoom; + context->draw(background, paint_order_id); + + String label; + f32 percentage = preview.zoom * 100.0f; + if(abs(percentage - round(percentage)) < 0.01f) + strprintf(label, "%d%%", (i32)round(percentage)); + else strprintf(label, "%.1f%%", percentage); + GUI::DrawCommand text; + text.type = GUI::DrawCommandType::text; + text.rect_reference = GUI::DrawCommandRectReference::layer; + text.rect = badge; + text.color = Float4U(1.0f); + text.font = Name("default"); + text.font_size = 13.0f / preview.zoom; + text.horizontal_alignment = VG::TextAlignment::center; + text.vertical_alignment = VG::TextAlignment::center; + text.text = move(label); + context->draw(text, paint_order_id + 1); + return paint_order_id + 1; + } + + R draw_preview_node(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata) + { + PreviewState& preview = *(PreviewState*)userdata; + if(phase == GUI::DrawPhase::before_children) + { + GUI::DrawCommand background; + background.type = GUI::DrawCommandType::rect; + background.rect_reference = GUI::DrawCommandRectReference::element; + background.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); + background.color = Float4U(0.08f, 0.09f, 0.11f, 0.35f); + context->draw(background, paint_order_id); + return paint_order_id; + } + f32 thickness = 1.5f / preview.zoom; + const Float4U color(0.86f, 0.88f, 0.92f, 0.9f); + GUI::DrawCommand border; + border.type = GUI::DrawCommandType::rect; + border.rect_reference = GUI::DrawCommandRectReference::element; + border.color = color; + + border.rect = RectF(0.0f, 0.0f, 0.0f, thickness); + border.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 0.0f); + context->draw(border, paint_order_id); + border.rect = RectF(0.0f, -thickness, 0.0f, thickness); + border.rect_layout_scale = Float4U(0.0f, 1.0f, 1.0f, 0.0f); + context->draw(border, paint_order_id); + border.rect = RectF(0.0f, 0.0f, thickness, 0.0f); + border.rect_layout_scale = Float4U(0.0f, 0.0f, 0.0f, 1.0f); + context->draw(border, paint_order_id); + border.rect = RectF(-thickness, 0.0f, thickness, 0.0f); + border.rect_layout_scale = Float4U(1.0f, 0.0f, 0.0f, 1.0f); + context->draw(border, paint_order_id); + return paint_order_id; + } + + R draw_preview_resize_handle(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata) + { + PreviewState& preview = *(PreviewState*)userdata; + GUI::InteractionState interaction = context->get_interaction_state(element.id); + GUI::DrawCommand background; + background.type = GUI::DrawCommandType::rounded_rect; + background.rect_reference = GUI::DrawCommandRectReference::element; + background.rect_layout_scale = Float4U(0.0f, 0.0f, 1.0f, 1.0f); + background.color = preview.resizing ? Float4U(1.0f, 0.42f, 0.50f, 1.0f) : + interaction.hovered ? Float4U(1.0f, 0.55f, 0.61f, 1.0f) : + Float4U(0.96f, 0.34f, 0.44f, 1.0f); + background.radius = 2.0f / preview.zoom; + context->draw(background, paint_order_id); + return paint_order_id; + } + + GUI::ElementHandle build_preview_resize_handle(GUI::IContext* context, + PreviewState& preview) + { + GUI::ElementHandle handle = context->begin_element( + context->make_id("preview.node.resize_handle")); + f32 handle_size = min(PREVIEW_RESIZE_HANDLE_SIZE / preview.zoom, + min(preview.node_size.x, preview.node_size.y) * 0.5f); + GUI::LayoutConfig layout = fixed_layout(handle_size, handle_size); + context->set_layout_config(handle, layout); + GUI::Interactable interactable; + interactable.pointer_hit_behavior = GUI::PointerHitBehavior::target; + set_flags(interactable.flags, GUI::InteractableFlag::hoverable); + set_flags(interactable.flags, GUI::InteractableFlag::activatable); + context->set_interactable(handle, interactable); + + GUI::DrawConfig draw; + draw.name = Name("game_gui_editor.preview.resize_handle"); + draw.callback = draw_preview_resize_handle; + draw.userdata = &preview; + context->set_draw_config(handle, draw); + context->end_element(); + return handle; + } + + bool process_preview_resize(PreviewState& preview, + const GUI::ElementHandle& handle) + { + bool changed = false; + for(const GUI::RoutedInputEvent& routed : + preview.context->get_routed_input_events(handle.id)) + { + const GUI::InputEvent& event = routed.event; + if(event.type == GUI::InputEventType::pointer_down && + event.button == GUI::PointerButton::left) + { + preview.resizing = true; + preview.resize_pointer = event.position; + preview.resize_start_size = preview.node_size; + continue; + } + if(!preview.resizing) continue; + if(event.type == GUI::InputEventType::pointer_move || + (event.type == GUI::InputEventType::pointer_up && + event.button == GUI::PointerButton::left)) + { + Float2U size( + preview.resize_start_size.x + event.position.x - + preview.resize_pointer.x, + preview.resize_start_size.y + event.position.y - + preview.resize_pointer.y); + size.x = round(clamp(size.x, PREVIEW_MIN_NODE_SIZE, + PREVIEW_MAX_NODE_SIZE)); + size.y = round(clamp(size.y, PREVIEW_MIN_NODE_SIZE, + PREVIEW_MAX_NODE_SIZE)); + if(size != preview.node_size) + { + preview.node_size = size; + changed = true; + } + } + if(event.type == GUI::InputEventType::pointer_up && + event.button == GUI::PointerButton::left) + { + preview.resizing = false; + } + } + if(preview.resizing && + !preview.context->is_pointer_button_down(GUI::PointerButton::left)) + { + preview.resizing = false; + } + return changed; + } + + void EditorApp::collect_preview_input(DocumentView& document, + const GUI::ElementHandle& preview_host, const RectF& preview_rect, + const GUI::FrameDesc& editor_frame, PreviewInput& preview_input) + { + preview_input.document_id = document.id; + if(!preview_host.id || preview_rect.width <= 0.0f || preview_rect.height <= 0.0f) + return; + PreviewState& preview = document.preview; + preview.pending_viewport_size = Float2U(preview_rect.width, + preview_rect.height); + f32 render_scale_x = editor_frame.logical_size.x > 0.0f ? + (f32)editor_frame.render_size.x / editor_frame.logical_size.x : 1.0f; + f32 render_scale_y = editor_frame.logical_size.y > 0.0f ? + (f32)editor_frame.render_size.y / editor_frame.logical_size.y : 1.0f; + preview.pending_render_size = UInt2U( + (u32)max(1.0f, round(preview_rect.width * render_scale_x)), + (u32)max(1.0f, round(preview_rect.height * render_scale_y))); + for(const GUI::RoutedInputEvent& routed : + gui->get_routed_input_events(preview_host.id)) + { + GUI::InputEvent event = routed.event; + Float2U surface_position; + if(routed.has_element_position) + { + surface_position.x = routed.element_position.x / preview_rect.width * + preview.viewport_size.x; + surface_position.y = routed.element_position.y / preview_rect.height * + preview.viewport_size.y; + } + + if(event.type == GUI::InputEventType::pointer_down && + event.button == GUI::PointerButton::middle && routed.has_element_position) + { + preview.panning = true; + preview.pan_pointer = surface_position; + gui->capture_pointer(preview_host.id); + continue; + } + if(event.type == GUI::InputEventType::pointer_up && + event.button == GUI::PointerButton::middle) + { + preview.panning = false; + gui->release_pointer_capture(preview_host.id); + continue; + } + if(event.type == GUI::InputEventType::pointer_move && preview.panning && + routed.has_element_position) + { + preview.pan.x += surface_position.x - preview.pan_pointer.x; + preview.pan.y += surface_position.y - preview.pan_pointer.y; + preview.pan_pointer = surface_position; + continue; + } + if(event.type == GUI::InputEventType::pointer_wheel && + routed.has_element_position) + { + preview.zoom_wheel_accumulator += event.wheel_delta.y; + i32 zoom_direction = 0; + if(preview.zoom_wheel_accumulator >= 1.0f) zoom_direction = 1; + else if(preview.zoom_wheel_accumulator <= -1.0f) zoom_direction = -1; + if(!zoom_direction) continue; + preview.zoom_wheel_accumulator = 0.0f; + i32 current_level = (i32)preview_zoom_level_index(preview.zoom); + i32 new_level = clamp(current_level + zoom_direction, 0, + (i32)PREVIEW_ZOOM_LEVEL_COUNT - 1); + f32 new_zoom = PREVIEW_ZOOM_LEVELS[new_level]; + if(new_zoom == preview.zoom) continue; + Float2U logical_position = preview_logical_position(preview, + surface_position); + Float2U center((f32)PREVIEW_LAYOUT_WIDTH * 0.5f, + (f32)PREVIEW_LAYOUT_HEIGHT * 0.5f); + preview.pan.x = surface_position.x - center.x - + new_zoom * (logical_position.x - center.x); + preview.pan.y = surface_position.y - center.y - + new_zoom * (logical_position.y - center.y); + preview.zoom = new_zoom; + continue; + } + + if(routed.has_element_position) + event.position = preview_logical_position(preview, surface_position); + preview_input.events.push_back(move(event)); + } + if(preview.panning && !gui->is_pointer_button_down(GUI::PointerButton::middle)) + { + preview.panning = false; + gui->release_pointer_capture(preview_host.id); + } + } + + + RV EditorApp::apply_preview_surface_size(DocumentView& document) + { + lutry + { + PreviewState& preview = document.preview; + UInt2U render_size = preview.pending_render_size; + if(!render_size.x || !render_size.y) + render_size = UInt2U(PREVIEW_LAYOUT_WIDTH, PREVIEW_LAYOUT_HEIGHT); + bool recreate_target = !preview.target || + preview.target->get_desc().width != render_size.x || + preview.target->get_desc().height != render_size.y; + if(recreate_target) + { + Ref target; + luset(target, RHI::get_main_device()->new_texture( + RHI::MemoryType::local, RHI::TextureDesc::tex2d( + RHI::Format::rgba8_unorm, + RHI::TextureUsageFlag::color_attachment | + RHI::TextureUsageFlag::read_texture, + render_size.x, render_size.y, 1, 1))); + preview.target = move(target); + } + preview.viewport_size = preview.pending_viewport_size; + preview.render_size = render_size; + } + lucatchret; + return ok; + } + + RV EditorApp::ensure_preview(DocumentView& document) + { + lutry + { + if(!document.preview.context) + { + document.preview.context = GUI::new_context(); + luexp(document.preview.context->register_font("default", + Font::get_default_font())); + } + if(!document.preview.target) + { + luset(document.preview.target, RHI::get_main_device()->new_texture( + RHI::MemoryType::local, RHI::TextureDesc::tex2d( + RHI::Format::rgba8_unorm, + RHI::TextureUsageFlag::color_attachment | + RHI::TextureUsageFlag::read_texture, + document.preview.render_size.x, + document.preview.render_size.y, 1, 1))); + } + if(!document.preview.instance || document.preview.revision != service->preview_revision()) + { + if(!document.cooked_snapshot) + { + document.preview.instance.reset(); + document.preview.revision = document.revision; + return ok; + } + lulet(desc, service->prepare_preview(document.id)); + if(!desc.document) return ok; + desc.instance_scope = GUI::make_scoped_id(GUI::DEFAULT_DATA_SCOPE, + document.id); + if(document.asset_guid != Guid()) + desc.source_asset = Asset::get_asset(document.asset_guid); + document.preview.instance = GameGUI::new_instance(desc); + RV prepared = document.preview.instance->prepare(); + if(failed(prepared)) error_message = explain(prepared.errcode()); + document.preview.revision = service->preview_revision(); + } + } + lucatchret; + return ok; + } + + RV EditorApp::build_preview(DocumentView& document, + Span input) + { + lutry + { + luexp(ensure_preview(document)); + GUI::FrameDesc frame; + frame.logical_size = document.preview.viewport_size; + frame.render_size = document.preview.render_size; + frame.delta_time = 1.0f / 60.0f; + document.preview.context->begin_frame(frame); + document.preview.context->add_input_events(input); + document.preview.context->push_layer( + document.preview.context->make_id("preview.background.layer")); + GUI::ElementHandle background = document.preview.context->begin_element( + document.preview.context->make_id("preview.background")); + GUI::DrawConfig background_draw; + background_draw.name = Name("game_gui_editor.preview.background"); + background_draw.callback = draw_preview_background; + background_draw.userdata = &document.preview; + document.preview.context->set_draw_config(background, background_draw); + document.preview.context->end_element(); + document.preview.context->pop_layer(); + document.preview.context->push_layer( + document.preview.context->make_id("preview.root.layer")); + GUI::ElementHandle preview_node = document.preview.context->begin_element( + document.preview.context->make_id("preview.node")); + document.preview.context->set_element_debug_name(preview_node, + Name("GameGUI Preview Parent")); + document.preview.context->set_layout_config(preview_node, + fixed_layout(document.preview.node_size.x, + document.preview.node_size.y)); + GUI::DrawConfig preview_node_draw; + preview_node_draw.name = Name("game_gui_editor.preview.node"); + preview_node_draw.callback = draw_preview_node; + preview_node_draw.userdata = &document.preview; + preview_node_draw.phases = GUI::DrawPhaseFlag::before_children | + GUI::DrawPhaseFlag::after_children; + document.preview.context->set_draw_config(preview_node, preview_node_draw); + GUI::ElementHandle root; + if(document.preview.instance) + luset(root, document.preview.instance->build(document.preview.context)); + enable_preview_node_hit_testing(document); + update_preview_selected_element(document); + if(root.id) + { + const GUI::Element* root_element = + document.preview.context->get_element(root.index); + if(root_element && root_element->id == root.id) + { + GUI::LayoutConfig root_layout = root_element->layout; + root_layout.margin = Float4U(0.0f); + document.preview.context->set_layout_config(root, root_layout); + } + } + GUI::ElementHandle resize_handle = build_preview_resize_handle( + document.preview.context, document.preview); + document.preview.context->end_element(); + document.preview.context->pop_layer(); + + GUI::CanvasLayoutItem preview_items[2]; + usize preview_item_count = 0; + if(root.id) + { + GUI::CanvasLayoutItem& root_item = + preview_items[preview_item_count++]; + root_item.element_id = root.id; + root_item.anchor_min = Float2U(0.0f); + root_item.anchor_max = Float2U(1.0f); + root_item.offset = Float4U(0.0f); + } + GUI::CanvasLayoutItem& resize_item = + preview_items[preview_item_count++]; + resize_item.element_id = resize_handle.id; + resize_item.anchor_min = Float2U(1.0f); + resize_item.anchor_max = Float2U(1.0f); + resize_item.offset = Float4U(0.0f); + resize_item.pivot = Float2U(1.0f); + GUI::CanvasLayoutDesc preview_layout; + preview_layout.items = Span(preview_items, + preview_item_count); + preview_layout.clip_children = true; + GUI::LayoutCallbackConfig preview_layout_config; + preview_layout_config.algorithm = Name("game_gui_editor.preview_parent"); + preview_layout_config.callback = GUI::layout_canvas; + preview_layout_config.userdata = &preview_layout; + document.preview.context->set_layout_callback_config(preview_node, + preview_layout_config); + + document.preview.context->push_layer( + document.preview.context->make_id("preview.overlay.layer")); + GUI::ElementHandle overlay = document.preview.context->begin_element( + document.preview.context->make_id("preview.overlay")); + GUI::DrawConfig overlay_draw; + overlay_draw.name = Name("game_gui_editor.preview.overlay"); + overlay_draw.callback = draw_preview_overlay; + overlay_draw.userdata = &document.preview; + document.preview.context->set_draw_config(overlay, overlay_draw); + document.preview.context->end_element(); + document.preview.context->pop_layer(); + luexp(document.preview.context->apply_layout(preview_node, + RectF(0.0f, 0.0f, document.preview.node_size.x, + document.preview.node_size.y))); + for(const GUI::InputEvent& event : input) + { + if(event.type == GUI::InputEventType::pointer_down && + event.button == GUI::PointerButton::left) + { + select_preview_node(document, event.position); + } + } + document.preview.context->route_input(); + bool preview_resized = process_preview_resize(document.preview, + resize_handle); + if(document.preview.instance) + luexp(document.preview.instance->resolve_interactions( + document.preview.context)); + if(preview_resized || (document.preview.instance && + document.preview.instance->relayout_requested())) + { + document.preview.context->set_layout_config(preview_node, + fixed_layout(document.preview.node_size.x, + document.preview.node_size.y)); + luexp(document.preview.context->apply_layout(preview_node, + RectF(0.0f, 0.0f, document.preview.node_size.x, + document.preview.node_size.y))); + } + luexp(document.preview.context->generate_draw_commands()); + } + lucatchret; + return ok; + } + + RV EditorApp::render_frame(DocumentView* preview_document) + { + lutry + { + if(preview_document && preview_document->preview.target && + preview_document->preview.context) + { + GUI::RenderTargetDesc preview_target(preview_document->preview.target); + preview_target.color_load_op = RHI::LoadOp::clear; + preview_target.color_clear_value = Float4U(0.26f, 0.26f, 0.26f, 1.0f); + preview_target.color_final_state = RHI::TextureStateFlag::shader_read_ps; + const PreviewState& preview = preview_document->preview; + Float2U center((f32)PREVIEW_LAYOUT_WIDTH * 0.5f, + (f32)PREVIEW_LAYOUT_HEIGHT * 0.5f); + f32 translation_x = center.x + preview.pan.x - preview.zoom * center.x; + f32 translation_y = center.y + preview.pan.y - preview.zoom * center.y; + Float4x4 canvas_transform( + preview.zoom, 0.0f, 0.0f, 0.0f, + 0.0f, preview.zoom, 0.0f, 0.0f, + 0.0f, 0.0f, 1.0f, 0.0f, + translation_x, translation_y, 0.0f, 1.0f); + Float4x4 projection = ProjectionMatrix::make_orthographic_off_center( + 0.0f, preview.viewport_size.x, preview.viewport_size.y, + 0.0f, 0.0f, 1.0f); + GUI::RenderSurfaceDesc preview_surface; + preview_surface.use_custom_transform = true; + preview_surface.surface_to_clip = mul(canvas_transform, projection); + if(preview.zoom == 1.0f && preview.pan == Float2U(0.0f)) + { + luexp(preview_renderer->render(preview_document->preview.context, + cmdbuf, preview_target)); + } + else + { + luexp(preview_renderer->render(preview_document->preview.context, + cmdbuf, preview_target, preview_surface)); + } + } + GUI::RenderTargetDesc editor_target(gui_target); + editor_target.color_load_op = RHI::LoadOp::clear; + editor_target.color_clear_value = Float4U(0.06f, 0.07f, 0.09f, 1.0f); + editor_target.color_final_state = RHI::TextureStateFlag::shader_read_ps; + luexp(renderer->render(gui, cmdbuf, editor_target)); + lulet(back_buffer, swap_chain->get_current_back_buffer()); + blit->reset(); + RHI::SamplerDesc sampler(RHI::Filter::linear, RHI::Filter::linear, + RHI::Filter::nearest, RHI::TextureAddressMode::clamp, + RHI::TextureAddressMode::clamp, RHI::TextureAddressMode::clamp); + blit->blit(back_buffer, RHI::SubresourceIndex(0, 0), + RHI::TextureViewDesc::tex2d(gui_target), sampler, + Float2U(0.0f), Float2U((f32)width, 0.0f), + Float2U(0.0f, (f32)height), Float2U((f32)width, (f32)height)); + luexp(blit->commit(cmdbuf, false)); + cmdbuf->resource_barrier({}, { + {back_buffer, RHI::TEXTURE_BARRIER_ALL_SUBRESOURCES, + RHI::TextureStateFlag::automatic, + RHI::TextureStateFlag::present, + RHI::ResourceBarrierFlag::none} + }); + luexp(cmdbuf->submit({}, {}, true)); + cmdbuf->wait(); + luexp(cmdbuf->reset()); + luexp(swap_chain->present()); + } + lucatchret; + return ok; + } + } + } +} diff --git a/Programs/GameGUIEditor/Source/EditorUI.cpp b/Programs/GameGUIEditor/Source/EditorUI.cpp new file mode 100644 index 00000000..d0e56df6 --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorUI.cpp @@ -0,0 +1,625 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorUI.cpp +* @author JXMaster +* @date 2026/8/28 +*/ +#include "EditorApp.hpp" +#include "InspectorLayout.hpp" +#include + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { + static EditorGUI::IconName node_type_icon(const NodeTypeView& type) + { + if(!strcmp(type.name.c_str(), "Flex")) return EditorGUI::IconName::rows; + if(!strcmp(type.name.c_str(), "Canvas")) return EditorGUI::IconName::frame_corners; + if(!strcmp(type.name.c_str(), "Panel")) return EditorGUI::IconName::squares_four; + if(!strcmp(type.name.c_str(), "Text")) return EditorGUI::IconName::cursor_text; + if(!strcmp(type.name.c_str(), "Button")) return EditorGUI::IconName::cursor_click; + if(!strcmp(type.name.c_str(), "AssetInstance")) return EditorGUI::IconName::package; + if(!strcmp(type.category.c_str(), "Layout")) return EditorGUI::IconName::grid_four; + if(!strcmp(type.category.c_str(), "Visual")) return EditorGUI::IconName::eye; + if(!strcmp(type.category.c_str(), "Input")) return EditorGUI::IconName::hand_tap; + if(!strcmp(type.category.c_str(), "Composition")) return EditorGUI::IconName::stack; + return EditorGUI::IconName::plus; + } + + static bool parent_flex_stretches_size(const DocumentView& document, + const PropertyEditor& property) + { + if(!document.snapshot || property.target != PropertyTarget::node || + property.desc.editor != EditingPropertyEditor::size || property.size_mode == 0) + return false; + bool width = property.desc.id == Name("width"); + bool height = property.desc.id == Name("height"); + if(!width && !height) return false; + Guid parent_id; + usize sibling_index = 0; + if(!find_parent_info(*document.snapshot, document.selected_node, + parent_id, sibling_index)) return false; + const AuthoringNodeRecord* parent = find_authoring_node(*document.snapshot, + parent_id); + return parent && flex_stretches_child_size(*parent, property.desc.id); + } + + void EditorApp::build_hierarchy_node(DocumentView& document, const Guid& node_id, + const Guid& parent, usize sibling_index, u32 depth, UIHandles& handles) + { + const AuthoringNodeRecord* node = find_authoring_node(*document.snapshot, node_id); + if(!node) return; + String label; + const c8* name = node->name.empty() ? "Unnamed Node" : node->name.c_str(); + strprintf(label, "%s", name); + EditorGUI::TreeNodeFlag flags = EditorGUI::TreeNodeFlag::open_on_arrow; + if(document.hierarchy_expand_node == node_id) + { + flags |= EditorGUI::TreeNodeFlag::always_open; + document.hierarchy_expand_node = Guid(); + } + if(node->children.empty()) flags |= EditorGUI::TreeNodeFlag::leaf; + if(document.selected_node == node_id) flags |= EditorGUI::TreeNodeFlag::selected; + GUI::ElementHandle item; + bool open = EditorGUI::tree_node(gui, + guid_gui_id(gui->make_id("hierarchy.nodes"), node_id), label.c_str(), + flags, depth, fill_width(26.0f), EditorGUI::DisclosureDesc(), &item); + GUI::Interactable interactable; + interactable.pointer_hit_behavior = GUI::PointerHitBehavior::target; + set_flags(interactable.flags, GUI::InteractableFlag::hoverable); + set_flags(interactable.flags, GUI::InteractableFlag::activatable); + set_flags(interactable.flags, GUI::InteractableFlag::focusable); + gui->set_interactable(item, interactable); + handles.nodes.push_back(NodeHit{node_id, parent, sibling_index, depth, item}); + if(open) + { + for(usize i = 0; i < node->children.size(); ++i) + build_hierarchy_node(document, node->children[i].child, node_id, i, + depth + 1, handles); + } + } + + void EditorApp::build_hierarchy_panel(UIHandles& handles) + { + if(!EditorGUI::begin_dock_panel(gui, gui->make_id("panel.hierarchy"), + "Hierarchy")) return; + GUI::ElementHandle root = EditorGUI::begin_v_layout(gui, + gui->make_id("hierarchy.root"), "Hierarchy Root", fill_layout()); + EditorGUI::text(gui, gui->make_id("hierarchy.title"), "Node Tree", + fill_width(30.0f)); + EditorGUI::ScrollViewDesc scroll_desc; + scroll_desc.horizontal = false; + EditorGUI::begin_scroll_view(gui, gui->make_id("hierarchy.scroll"), + "Hierarchy Scroll", fill_layout(), scroll_desc); + DocumentView* document = active_document(); + if(document && document->snapshot) + build_hierarchy_node(*document, document->snapshot->root, Guid(), 0, 0, + handles); + EditorGUI::end_scroll_view(gui); + GUI::FlexLayoutDesc root_layout; + root_layout.main_axis_gap = 4.0f; + if(document) + { + GUI::DrawConfig hierarchy_draw; + hierarchy_draw.name = Name("game_gui_editor.hierarchy.drop"); + hierarchy_draw.callback = draw_hierarchy_drop; + hierarchy_draw.userdata = &document->hierarchy_drag; + hierarchy_draw.phases = GUI::DrawPhaseFlag::after_children; + gui->set_draw_config(root, hierarchy_draw); + } + EditorGUI::end_v_layout(gui, root, root_layout); + if(document) + { + GUI::id_t popup_id = hierarchy_context_popup_id(gui, document->id); + EditorGUI::PopupDesc popup_desc; + popup_desc.position = document->hierarchy_context_position; + popup_desc.layout = fixed_layout(180.0f, 74.0f); + GUI::ElementHandle popup; + if(EditorGUI::begin_popup(gui, popup_id, popup_desc, &popup)) + { + handles.set_root_node = EditorGUI::menu_item(gui, + GUI::make_scoped_id(popup_id, "set_root"), "Set as Root"); + handles.delete_node = EditorGUI::menu_item(gui, + GUI::make_scoped_id(popup_id, "delete"), "Delete Node"); + lupanic_if_failed(EditorGUI::end_popup(gui, popup, + RectF(0.0f, 0.0f, 180.0f, 74.0f))); + } + else if(!EditorGUI::is_popup_open(gui, popup_id)) + { + document->hierarchy_context_node = Guid(); + } + } + EditorGUI::end_dock_panel(gui); + } + + void EditorApp::build_palette_panel(UIHandles& handles) + { + if(!EditorGUI::begin_dock_panel(gui, gui->make_id("panel.palette"), + "Node Palette")) return; + EditorGUI::ScrollViewDesc scroll_desc; + scroll_desc.horizontal = true; + scroll_desc.vertical = false; + EditorGUI::begin_scroll_view(gui, gui->make_id("palette.scroll"), + "Palette Scroll", fill_layout(), scroll_desc); + GUI::LayoutConfig row_config; + row_config.width.kind = GUI::SizeKind::fit; + row_config.height.kind = GUI::SizeKind::fixed; + row_config.height.value = 36.0f; + row_config.flex_shrink = 0.0f; + GUI::ElementHandle row = EditorGUI::begin_h_layout(gui, + gui->make_id("palette.row"), "Palette Row", row_config); + GUI::id_t scope = gui->make_id("palette.types"); + for(const NodeTypeView& type : node_types) + { + GUI::id_t item_id = guid_gui_id(scope, type.type); + GUI::LayoutConfig button_layout = fixed_layout(36.0f, 36.0f); + button_layout.padding = Float4U(0.01f); + GUI::ElementHandle item = EditorGUI::begin_button(gui, item_id, + type.display_name.c_str(), button_layout); + EditorGUI::IconDesc icon_desc; + icon_desc.size = 20.0f; + EditorGUI::icon(gui, GUI::make_scoped_id(item_id, "icon"), + node_type_icon(type), fixed_layout(20.0f, 20.0f), icon_desc); + EditorGUI::end_button(gui); + String tooltip; + strprintf(tooltip, "%s / %s\nClick to add, or drag into the hierarchy.", + type.category.c_str(), type.display_name.c_str()); + EditorGUI::set_item_tooltip(gui, + guid_gui_id(gui->make_id("palette.tooltips"), type.type), item, + tooltip.c_str()); + handles.types.push_back(TypeHit{type.type, item}); + } + GUI::FlexLayoutDesc row_flex; + row_flex.axis = GUI::LayoutAxis::x; + row_flex.cross_alignment = GUI::FlexAlignment::center; + row_flex.main_axis_gap = 8.0f; + EditorGUI::end_h_layout(gui, row, row_flex); + EditorGUI::end_scroll_view(gui); + EditorGUI::end_dock_panel(gui); + } + + void EditorApp::build_document_panels(UIHandles& handles) + { + for(usize i = 0; i < documents.size(); ++i) + { + DocumentView& document = documents[i]; + String label = document.title; + if(document.dirty) label.append(" *"); + if(!EditorGUI::begin_dock_panel(gui, document_panel_id(gui, document.id), + label.c_str(), &document.panel_open)) continue; + selected_document = (i32)i; + handles.document_id = document.id; + GUI::id_t content_scope = GUI::make_scoped_id(gui->make_id("documents.content"), + document.id); + GUI::ElementHandle root = EditorGUI::begin_v_layout(gui, content_scope, + "Document Preview", fill_layout()); + RV preview_result = ensure_preview(document); + if(failed(preview_result)) error_message = explain(preview_result.errcode()); + handles.preview_host = EditorGUI::begin_v_layout(gui, + GUI::make_scoped_id(content_scope, "preview.host"), + "Interactive GameGUI Preview", fill_layout()); + GUI::Interactable preview_interactable; + preview_interactable.pointer_hit_behavior = GUI::PointerHitBehavior::target; + set_flags(preview_interactable.flags, GUI::InteractableFlag::hoverable); + set_flags(preview_interactable.flags, GUI::InteractableFlag::activatable); + gui->set_interactable(handles.preview_host, preview_interactable); + EditorGUI::ImageDesc image; + image.flags = EditorGUI::ImageFlag::flip_y; + EditorGUI::image(gui, GUI::make_scoped_id(content_scope, "preview.image"), + document.preview.target, fill_layout(), image); + GUI::FlexLayoutDesc preview_layout; + preview_layout.clip_children = true; + EditorGUI::end_v_layout(gui, handles.preview_host, preview_layout); + EditorGUI::end_v_layout(gui, root); + EditorGUI::end_dock_panel(gui); + } + } + +#if !defined(LUNA_PLATFORM_MACOS) + void EditorApp::build_main_menu_bar(UIHandles& handles) + { + DocumentView* document = active_document(); + GUI::ElementHandle menu_bar = EditorGUI::begin_menu_bar(gui, + gui->make_id("main_menu_bar"), "Main Menu Bar", fill_width(34.0f)); + if(EditorGUI::begin_menu(gui, gui->make_id("main_menu.file"), "File")) + { + handles.open_directory = EditorGUI::menu_item(gui, + gui->make_id("main_menu.file.open_directory"), "Open Directory..."); + EditorGUI::MenuItemDesc directory_desc; + directory_desc.enabled = selected_directory != 0; + handles.create_document = EditorGUI::menu_item(gui, + gui->make_id("main_menu.file.new"), "New", false, directory_desc); + EditorGUI::menu_separator(gui, gui->make_id("main_menu.file.new_separator")); + + handles.open_document = EditorGUI::menu_item(gui, + gui->make_id("main_menu.file.open"), "Open...", false, directory_desc); + EditorGUI::menu_separator(gui, gui->make_id("main_menu.file.save_separator")); + + EditorGUI::MenuItemDesc document_desc; + document_desc.enabled = document != nullptr; + handles.save_document = EditorGUI::menu_item(gui, + gui->make_id("main_menu.file.save"), "Save", false, document_desc); + + EditorGUI::MenuItemDesc save_as_desc; + save_as_desc.enabled = document != nullptr; + handles.save_as_document = EditorGUI::menu_item(gui, + gui->make_id("main_menu.file.save_as"), "Save As...", false, save_as_desc); + EditorGUI::MenuItemDesc cook_desc; + cook_desc.enabled = document && !document->asset_path.empty(); + handles.cook_document = EditorGUI::menu_item(gui, + gui->make_id("main_menu.file.cook"), "Cook", false, cook_desc); + EditorGUI::menu_separator(gui, gui->make_id("main_menu.file.close_separator")); + EditorGUI::MenuItemDesc close_desc; + close_desc.enabled = document != nullptr; + handles.close_document = EditorGUI::menu_item(gui, + gui->make_id("main_menu.file.close"), "Close", false, close_desc); + lupanic_if_failed(EditorGUI::end_menu(gui, + RectF(0.0f, 0.0f, 230.0f, 245.0f))); + } + + if(EditorGUI::begin_menu(gui, gui->make_id("main_menu.edit"), "Edit")) + { + EditorGUI::MenuItemDesc undo_desc; + undo_desc.enabled = document && document->can_undo; + handles.undo = EditorGUI::menu_item(gui, + gui->make_id("main_menu.edit.undo"), "Undo", false, undo_desc); + EditorGUI::MenuItemDesc redo_desc; + redo_desc.enabled = document && document->can_redo; + handles.redo = EditorGUI::menu_item(gui, + gui->make_id("main_menu.edit.redo"), "Redo", false, redo_desc); + lupanic_if_failed(EditorGUI::end_menu(gui, + RectF(0.0f, 0.0f, 230.0f, 70.0f))); + } + EditorGUI::end_menu_bar(gui, menu_bar); + } +#endif + + void EditorApp::build_inspector_panel(UIHandles& handles) + { + if(!EditorGUI::begin_dock_panel(gui, gui->make_id("panel.inspector"), + "Inspector")) return; + GUI::ElementHandle root = EditorGUI::begin_v_layout(gui, + gui->make_id("inspector.root"), "Inspector Root", fill_layout()); + DocumentView* document = active_document(); + if(!document || !document->snapshot) + { + EditorGUI::text(gui, gui->make_id("inspector.empty"), + "No document is selected.", fill_width(30.0f)); + EditorGUI::end_v_layout(gui, root); + EditorGUI::end_dock_panel(gui); + return; + } + if(document->inspector_revision != document->revision || + document->inspector_node != document->selected_node) + { + rebuild_inspector(*document); + } + const AuthoringNodeRecord* node = find_authoring_node(*document->snapshot, + document->selected_node); + if(node) + { + String type_name = "Unsupported type"; + for(const NodeTypeView& type : node_types) + { + if(type.type == node->type) + { + type_name = type.display_name; + break; + } + } + EditorGUI::text(gui, gui->make_id("inspector.type"), type_name.c_str(), + fill_width(30.0f)); + GUI::ElementHandle name_row = EditorGUI::begin_h_layout(gui, + gui->make_id("inspector.node_name.row"), "Name", fill_width(30.0f)); + EditorGUI::text(gui, gui->make_id("inspector.node_name.label"), "Name", + fixed_layout(112.0f, 28.0f)); + EditorGUI::input_text(gui, gui->make_id("inspector.node_name"), + document->node_name, fill_width(28.0f)); + EditorGUI::end_h_layout(gui, name_row); + EditorGUI::ScrollViewDesc scroll_desc; + scroll_desc.horizontal = false; + EditorGUI::begin_scroll_view(gui, gui->make_id("inspector.properties.scroll"), + "Property Inspector", fill_layout(), scroll_desc); + + auto draw_section = [&](EditingPropertySection section, const c8* id, + const c8* label, i32 raw_filter) + { + bool has_properties = false; + for(const PropertyEditor& property : document->property_editors) + { + if(property.desc.section == section && + (raw_filter < 0 || property.raw == (raw_filter != 0))) + { + has_properties = true; + break; + } + } + if(!has_properties && raw_filter != 0) return; + GUI::id_t section_id = gui->make_id(id); + if(!EditorGUI::collapsing_header(gui, section_id, label, + fill_width(30.0f))) return; + if(!has_properties) + { + String empty_message; + strprintf(empty_message, "No %s fields.", label); + EditorGUI::text(gui, GUI::make_scoped_id(section_id, "empty"), + empty_message.c_str(), fill_width(24.0f)); + return; + } + GUI::id_t node_scope = guid_gui_id(section_id, + document->selected_node); + for(usize property_index = 0; + property_index < document->property_editors.size(); ++property_index) + { + PropertyEditor& property = + document->property_editors[property_index]; + if(property.desc.section != section || + (raw_filter >= 0 && property.raw != (raw_filter != 0))) continue; + GUI::id_t target_scope = GUI::make_scoped_id(node_scope, + property.target == PropertyTarget::node ? "node" : "attachment"); + GUI::id_t property_id = GUI::make_scoped_id(target_scope, + property.desc.id.c_str()); + bool stretched_size = parent_flex_stretches_size(*document, property); + bool stacked = property.desc.editor == EditingPropertyEditor::float2 || + property.desc.editor == EditingPropertyEditor::float4 || + property.desc.editor == EditingPropertyEditor::color || + property.desc.editor == EditingPropertyEditor::visual_effects || + property.desc.editor == EditingPropertyEditor::size || + property.desc.editor == EditingPropertyEditor::asset || + property.desc.editor == EditingPropertyEditor::json; + f32 row_height = property.desc.editor == + EditingPropertyEditor::visual_effects ? + visual_effects_editor_height(property) : + (property.desc.editor == EditingPropertyEditor::float4 ? + 142.0f : (stretched_size ? 86.0f : 58.0f)); + GUI::ElementHandle row = stacked ? + EditorGUI::begin_v_layout(gui, property_id, + property.desc.display_name.c_str(), fill_width(row_height)) : + EditorGUI::begin_h_layout(gui, property_id, + property.desc.display_name.c_str(), fill_width(32.0f)); + EditorGUI::text(gui, GUI::make_scoped_id(property_id, "label"), + property.desc.display_name.c_str(), stacked ? + fill_width(24.0f) : fixed_layout(112.0f, 28.0f)); + GUI::ElementHandle control; + f32 minimum = property.desc.bounded ? + (f32)property.desc.minimum : 0.0f; + f32 maximum = property.desc.bounded ? + (f32)property.desc.maximum : 0.0f; + EditorGUI::DragDesc drag_desc; + drag_desc.speed = (f32)property.desc.step; + switch(property.desc.editor) + { + case EditingPropertyEditor::boolean: + control = EditorGUI::checkbox(gui, + GUI::make_scoped_id(property_id, "value"), "", + &property.boolean, fill_width(28.0f)); + break; + case EditingPropertyEditor::number: + control = EditorGUI::drag_float(gui, + GUI::make_scoped_id(property_id, "value"), &property.number, + minimum, maximum, fill_width(28.0f), drag_desc); + break; + case EditingPropertyEditor::string: + case EditingPropertyEditor::name: + case EditingPropertyEditor::json: + control = EditorGUI::input_text(gui, + GUI::make_scoped_id(property_id, "value"), property.text, + fill_width(28.0f)); + break; + case EditingPropertyEditor::enumeration: + { + Vector items; + items.reserve(property.desc.enumeration_items.size()); + for(const EditingEnumItemDesc& item : + property.desc.enumeration_items) + items.push_back(item.display_name.c_str()); + control = EditorGUI::combo(gui, + GUI::make_scoped_id(property_id, "value"), + property.desc.display_name.c_str(), &property.selected_item, + Span(items.data(), items.size()), fill_width(28.0f)); + break; + } + case EditingPropertyEditor::float2: + control = EditorGUI::drag_float2(gui, + GUI::make_scoped_id(property_id, "value"), property.vector, + minimum, maximum, fill_width(28.0f), drag_desc); + break; + case EditingPropertyEditor::float4: + { + const c8* side_ids[] = {"left", "top", "right", "bottom"}; + const c8* side_labels[] = {"Left", "Top", "Right", "Bottom"}; + for(usize side = 0; side < 4; ++side) + { + GUI::id_t side_id = GUI::make_scoped_id(property_id, + side_ids[side]); + GUI::ElementHandle side_row = EditorGUI::begin_h_layout(gui, + side_id, side_labels[side], fill_width(28.0f)); + GUI::LayoutConfig label_layout = fixed_layout(64.0f, 28.0f); + label_layout.flex_shrink = 0.0f; + EditorGUI::text(gui, GUI::make_scoped_id(side_id, "label"), + side_labels[side], label_layout); + GUI::ElementHandle side_control = EditorGUI::drag_float(gui, + GUI::make_scoped_id(side_id, "value"), + property.vector + side, minimum, maximum, + fill_width(28.0f), drag_desc); + if(!control.id) control = side_control; + EditorGUI::end_h_layout(gui, side_row); + } + break; + } + case EditingPropertyEditor::color: + control = EditorGUI::color_edit4(gui, + GUI::make_scoped_id(property_id, "value"), + property.desc.display_name.c_str(), property.vector, + fill_width(28.0f)); + break; + case EditingPropertyEditor::visual_effects: + control = build_visual_effects_editor(property, property_index, + property_id, handles); + break; + case EditingPropertyEditor::size: + { + GUI::ElementHandle size_row = EditorGUI::begin_h_layout(gui, + GUI::make_scoped_id(property_id, "value"), "Size", + fill_width(28.0f)); + const c8* modes[] = {"Auto", "Fixed", "Percent"}; + GUI::LayoutConfig mode_layout = fixed_layout(112.0f, 28.0f); + mode_layout.flex_shrink = 0.0f; + control = EditorGUI::combo(gui, + GUI::make_scoped_id(property_id, "mode"), "Size Mode", + &property.size_mode, Span(modes, 3), + mode_layout); + if(property.size_mode != 0) + { + EditorGUI::drag_float(gui, + GUI::make_scoped_id(property_id, "size"), &property.number, + minimum, maximum, fill_width(28.0f), drag_desc); + if(property.size_mode == 2) + { + EditorGUI::text(gui, + GUI::make_scoped_id(property_id, "percent_suffix"), "%", + fixed_layout(14.0f, 28.0f)); + } + } + GUI::FlexLayoutDesc flex; + flex.main_axis_gap = 4.0f; + EditorGUI::end_h_layout(gui, size_row, flex); + if(stretched_size) + { + GUI::ElementHandle warning_row = EditorGUI::begin_h_layout(gui, + GUI::make_scoped_id(property_id, "stretch_warning"), + "Stretch Warning", fill_width(24.0f)); + EditorGUI::IconDesc icon_desc; + icon_desc.tint = Float4U(0.957f, 0.678f, 0.184f, 1.0f); + icon_desc.size = 15.0f; + EditorGUI::icon(gui, + GUI::make_scoped_id(property_id, "stretch_warning.icon"), + EditorGUI::IconName::warning, fixed_layout(18.0f, 22.0f), + icon_desc); + String warning; + strprintf(warning, "Parent Flex Stretch overrides %s.", + property.desc.display_name.c_str()); + EditorGUI::TextDesc text_desc; + text_desc.typography = EditorGUI::TypographyRole::caption; + text_desc.color = icon_desc.tint; + EditorGUI::text(gui, + GUI::make_scoped_id(property_id, "stretch_warning.text"), + warning.c_str(), fill_width(22.0f), text_desc); + GUI::FlexLayoutDesc warning_flex; + warning_flex.axis = GUI::LayoutAxis::x; + warning_flex.cross_alignment = GUI::FlexAlignment::center; + warning_flex.main_axis_gap = 4.0f; + EditorGUI::end_h_layout(gui, warning_row, warning_flex); + } + break; + } + case EditingPropertyEditor::asset: + { + GUI::ElementHandle asset_row = EditorGUI::begin_h_layout(gui, + GUI::make_scoped_id(property_id, "value"), "Asset", + fill_width(28.0f)); + control = EditorGUI::input_text(gui, + GUI::make_scoped_id(property_id, "asset_guid"), property.text, + fill_width(28.0f)); + PropertyActionHit hit; + hit.property_index = property_index; + hit.element = EditorGUI::text_button(gui, + GUI::make_scoped_id(property_id, "browse"), "...", + fixed_layout(32.0f, 28.0f)); + handles.browse_assets.push_back(hit); + GUI::FlexLayoutDesc flex; + flex.main_axis_gap = 4.0f; + EditorGUI::end_h_layout(gui, asset_row, flex); + break; + } + } + if(!property.desc.description.empty() && control.id) + { + EditorGUI::set_item_tooltip(gui, + GUI::make_scoped_id(property_id, "tooltip"), control, + property.desc.description.c_str()); + } + if(stacked) EditorGUI::end_v_layout(gui, row); + else EditorGUI::end_h_layout(gui, row); + } + }; + + draw_section(EditingPropertySection::layout, + "inspector.section.layout", "Layout", 0); + draw_section(EditingPropertySection::style, + "inspector.section.style", "Style", 0); + draw_section(EditingPropertySection::property, + "inspector.section.properties", "Properties", 0); + draw_section(EditingPropertySection::property, + "inspector.section.raw", "Raw / Unknown Properties", 1); + EditorGUI::end_scroll_view(gui); + } + EditorGUI::end_v_layout(gui, root); + EditorGUI::end_dock_panel(gui); + } + + void EditorApp::build_diagnostics_panel() + { + if(!EditorGUI::begin_dock_panel(gui, gui->make_id("panel.diagnostics"), + "Diagnostics")) return; + GUI::ElementHandle root = EditorGUI::begin_v_layout(gui, + gui->make_id("diagnostics.root"), "Diagnostics Root", fill_layout()); + if(!error_message.empty()) + { + String message = "Service: "; + message.append(error_message); + EditorGUI::text(gui, gui->make_id("diagnostics.service"), message.c_str(), + fill_width(26.0f)); + } + DocumentView* document = active_document(); + if(document && document->diagnostics.type() == VariantType::array && + !document->diagnostics.empty()) + { + usize index = 0; + for(const Variant& diagnostic : document->diagnostics.values()) + { + String message; + strprintf(message, "[%s] %s", diagnostic["severity"].c_str("error"), + diagnostic["message"].c_str()); + EditorGUI::text(gui, GUI::make_scoped_id(gui->make_id("diagnostics.items"), + (u64)++index), message.c_str(), fill_width(24.0f)); + } + } + else if(error_message.empty()) + { + EditorGUI::text(gui, gui->make_id("diagnostics.clean"), + "No diagnostics for the active document.", fill_width(26.0f)); + } + EditorGUI::end_v_layout(gui, root); + EditorGUI::end_dock_panel(gui); + } + + GUI::ElementHandle EditorApp::build_editor(UIHandles& handles) + { + GUI::ElementHandle root = EditorGUI::begin_v_layout(gui, + gui->make_id("editor.root"), "GameGUI Editor Root", fill_layout()); +#if !defined(LUNA_PLATFORM_MACOS) + build_main_menu_bar(handles); +#endif + EditorGUI::begin_dock_space(gui, + gui->make_id("editor.dock_space"), "GameGUI Editor DockSpace", fill_layout()); + build_document_panels(handles); + build_explorer_panel(handles); + build_hierarchy_panel(handles); + build_palette_panel(handles); + build_inspector_panel(handles); + build_diagnostics_panel(); + EditorGUI::end_dock_space(gui); + build_asset_picker(handles); + EditorGUI::end_v_layout(gui, root); + return root; + } + } + } +} diff --git a/Programs/GameGUIEditor/Source/EditorVisualEffects.cpp b/Programs/GameGUIEditor/Source/EditorVisualEffects.cpp new file mode 100644 index 00000000..b71cdbb7 --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorVisualEffects.cpp @@ -0,0 +1,433 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorVisualEffects.cpp +* @author JXMaster +* @date 2026/9/3 +*/ +#include "EditorApp.hpp" + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { + namespace + { + constexpr const c8* EFFECT_TYPE_NAMES[] = { + "rect", "gradient_rect", "rounded_rect", "rounded_rect_stroke", "shadow" + }; + constexpr const c8* EFFECT_TYPE_LABELS[] = { + "Rectangle", "Gradient Rectangle", "Rounded Rectangle", + "Rounded Rectangle Stroke", "Shadow" + }; + constexpr usize EFFECT_TYPE_COUNT = + sizeof(EFFECT_TYPE_NAMES) / sizeof(EFFECT_TYPE_NAMES[0]); + constexpr const c8* EFFECT_PHASE_NAMES[] = { + "before_children", "after_children" + }; + constexpr const c8* EFFECT_PHASE_LABELS[] = { + "Before Children", "After Children" + }; + + i32 find_name(const Name& name, const c8* const* names, usize count) + { + for(usize i = 0; i < count; ++i) + { + if(name == Name(names[i])) return (i32)i; + } + return -1; + } + + void decode_float_array(const Variant& value, f32* output, usize count) + { + if(value.type() != VariantType::array || value.size() != count) return; + for(usize i = 0; i < count; ++i) + { + if(value[i].type() == VariantType::number) + output[i] = (f32)value[i].fnum(); + } + } + + Variant make_float_array(const f32* values, usize count) + { + Variant result(VariantType::array); + for(usize i = 0; i < count; ++i) result.push_back((f64)values[i]); + return result; + } + + void erase_known_effect_fields(Variant& value) + { + const c8* fields[] = { + "phase", "type", "inset", "color", "color_top_right", + "color_bottom_right", "color_bottom_left", "radius", "line_width", + "shadow_offset", "shadow_softness", "shadow_spread", "shadow_mode" + }; + for(const c8* field : fields) value.erase(Name(field)); + } + + GUI::LayoutConfig fixed_no_shrink(f32 width, f32 height) + { + GUI::LayoutConfig result = fixed_layout(width, height); + result.flex_shrink = 0.0f; + return result; + } + + void add_action(UIHandles& handles, usize property_index, usize effect_index, + VisualEffectAction action, const GUI::ElementHandle& element) + { + VisualEffectActionHit hit; + hit.property_index = property_index; + hit.effect_index = effect_index; + hit.action = action; + hit.element = element; + handles.visual_effect_actions.push_back(hit); + } + + GUI::ElementHandle labeled_number(EditorApp& app, GUI::id_t scope, + const c8* id, const c8* label, f32* value, f32 minimum = 0.0f, + f32 maximum = 0.0f) + { + GUI::ElementHandle row = EditorGUI::begin_h_layout(app.gui, + GUI::make_scoped_id(scope, id), label, fill_width(28.0f)); + EditorGUI::text(app.gui, GUI::make_scoped_id(scope, label), label, + fixed_no_shrink(104.0f, 28.0f)); + EditorGUI::DragDesc desc; + desc.speed = 1.0f; + String value_id = id; + value_id.append(".value"); + GUI::ElementHandle result = EditorGUI::drag_float(app.gui, + GUI::make_scoped_id(scope, value_id.c_str()), value, + minimum, maximum, fill_width(28.0f), desc); + EditorGUI::end_h_layout(app.gui, row); + return result; + } + + GUI::ElementHandle labeled_color(EditorApp& app, GUI::id_t scope, + const c8* id, const c8* label, f32* value) + { + GUI::ElementHandle row = EditorGUI::begin_h_layout(app.gui, + GUI::make_scoped_id(scope, id), label, fill_width(30.0f)); + EditorGUI::text(app.gui, GUI::make_scoped_id(scope, label), label, + fixed_no_shrink(104.0f, 28.0f)); + String value_id = id; + value_id.append(".value"); + GUI::ElementHandle result = EditorGUI::color_edit4(app.gui, + GUI::make_scoped_id(scope, value_id.c_str()), + label, value, fill_width(28.0f)); + EditorGUI::end_h_layout(app.gui, row); + return result; + } + + void labeled_combo(EditorApp& app, GUI::id_t scope, const c8* id, + const c8* label, i32* selected, const c8* const* items, usize count) + { + GUI::ElementHandle row = EditorGUI::begin_h_layout(app.gui, + GUI::make_scoped_id(scope, id), label, fill_width(28.0f)); + EditorGUI::text(app.gui, GUI::make_scoped_id(scope, label), label, + fixed_no_shrink(104.0f, 28.0f)); + String value_id = id; + value_id.append(".value"); + Vector choices; + choices.reserve(count); + for(usize i = 0; i < count; ++i) choices.push_back(items[i]); + EditorGUI::combo(app.gui, + GUI::make_scoped_id(scope, value_id.c_str()), label, + selected, Span(choices.data(), choices.size()), + fill_width(28.0f)); + EditorGUI::end_h_layout(app.gui, row); + } + + f32 effect_height(const VisualEffectEditor& effect) + { + if(!effect.supported) return 76.0f; + f32 result = 28.0f + 28.0f + 28.0f + 24.0f + 4.0f * 28.0f; + switch(effect.type) + { + case 0: result += 30.0f; break; + case 1: result += 4.0f * 30.0f; break; + case 2: result += 30.0f + 28.0f; break; + case 3: result += 30.0f + 2.0f * 28.0f; break; + case 4: result += 30.0f + 5.0f * 28.0f + 28.0f; break; + default: result += 48.0f; break; + } + return result; + } + } + + void decode_visual_effects(const Variant& value, + Vector& effects) + { + effects.clear(); + if(value.type() != VariantType::array) return; + effects.reserve(value.size()); + for(const Variant& source : value.values()) + { + VisualEffectEditor effect; + effect.source = source; + if(source.type() != VariantType::object) + { + effect.supported = false; + effects.push_back(move(effect)); + continue; + } + effect.phase = find_name(source["phase"].str(), EFFECT_PHASE_NAMES, 2); + effect.type = find_name(source["type"].str(), EFFECT_TYPE_NAMES, + EFFECT_TYPE_COUNT); + if(effect.phase < 0 || effect.type < 0) + { + effect.supported = false; + effects.push_back(move(effect)); + continue; + } + if(effect.type == 4) + { + effect.color[0] = 0.0f; + effect.color[1] = 0.0f; + effect.color[2] = 0.0f; + effect.color[3] = 0.5f; + } + decode_float_array(source["inset"], effect.inset, 4); + decode_float_array(source["color"], effect.color, 4); + decode_float_array(source["color_top_right"], effect.gradient_colors[0], 4); + decode_float_array(source["color_bottom_right"], effect.gradient_colors[1], 4); + decode_float_array(source["color_bottom_left"], effect.gradient_colors[2], 4); + effect.radius = (f32)source["radius"].fnum(); + effect.line_width = (f32)source["line_width"].fnum(1.0); + decode_float_array(source["shadow_offset"], effect.shadow_offset, 2); + effect.shadow_softness = (f32)source["shadow_softness"].fnum(); + effect.shadow_spread = (f32)source["shadow_spread"].fnum(); + effect.shadow_mode = source["shadow_mode"].str() == Name("inner") ? 1 : 0; + effects.push_back(move(effect)); + } + } + + Variant encode_visual_effects(Span effects) + { + Variant result(VariantType::array); + for(const VisualEffectEditor& effect : effects) + { + if(!effect.supported) + { + result.push_back(effect.source); + continue; + } + Variant encoded = effect.source.type() == VariantType::object ? + effect.source : Variant(VariantType::object); + erase_known_effect_fields(encoded); + encoded["phase"] = EFFECT_PHASE_NAMES[clamp(effect.phase, 0, 1)]; + i32 type = clamp(effect.type, 0, (i32)EFFECT_TYPE_COUNT - 1); + encoded["type"] = EFFECT_TYPE_NAMES[type]; + encoded["inset"] = make_float_array(effect.inset, 4); + encoded["color"] = make_float_array(effect.color, 4); + switch(type) + { + case 1: + encoded["color_top_right"] = make_float_array( + effect.gradient_colors[0], 4); + encoded["color_bottom_right"] = make_float_array( + effect.gradient_colors[1], 4); + encoded["color_bottom_left"] = make_float_array( + effect.gradient_colors[2], 4); + break; + case 2: + encoded["radius"] = (f64)effect.radius; + break; + case 3: + encoded["radius"] = (f64)effect.radius; + encoded["line_width"] = (f64)effect.line_width; + break; + case 4: + encoded["radius"] = (f64)effect.radius; + encoded["shadow_offset"] = make_float_array(effect.shadow_offset, 2); + encoded["shadow_softness"] = (f64)effect.shadow_softness; + encoded["shadow_spread"] = (f64)effect.shadow_spread; + encoded["shadow_mode"] = effect.shadow_mode == 1 ? "inner" : "outer"; + break; + default: break; + } + result.push_back(move(encoded)); + } + return result; + } + + f32 visual_effects_editor_height(const PropertyEditor& property) + { + f32 result = 24.0f + 28.0f; + for(const VisualEffectEditor& effect : property.visual_effects) + result += effect_height(effect); + return result; + } + + GUI::ElementHandle EditorApp::build_visual_effects_editor(PropertyEditor& property, + usize property_index, GUI::id_t property_id, UIHandles& handles) + { + GUI::ElementHandle add = EditorGUI::text_button(gui, + GUI::make_scoped_id(property_id, "add"), "Add Effect", fill_width(28.0f)); + add_action(handles, property_index, 0, VisualEffectAction::add, add); + for(usize i = 0; i < property.visual_effects.size(); ++i) + { + VisualEffectEditor& effect = property.visual_effects[i]; + GUI::id_t effect_id = GUI::make_scoped_id(property_id, (u64)i + 1); + GUI::ElementHandle toolbar = EditorGUI::begin_h_layout(gui, + GUI::make_scoped_id(effect_id, "toolbar"), "Effect Actions", + fill_width(28.0f)); + String title; + if(effect.supported && effect.type >= 0 && + effect.type < (i32)EFFECT_TYPE_COUNT) + { + strprintf(title, "Effect %u: %s", (u32)i + 1, + EFFECT_TYPE_LABELS[effect.type]); + } + else strprintf(title, "Effect %u: Unsupported", (u32)i + 1); + EditorGUI::text(gui, GUI::make_scoped_id(effect_id, "title"), + title.c_str(), fill_width(28.0f)); + if(i > 0) + { + GUI::ElementHandle up = EditorGUI::text_button(gui, + GUI::make_scoped_id(effect_id, "up"), "Up", + fixed_no_shrink(40.0f, 26.0f)); + add_action(handles, property_index, i, + VisualEffectAction::move_up, up); + } + if(i + 1 < property.visual_effects.size()) + { + GUI::ElementHandle down = EditorGUI::text_button(gui, + GUI::make_scoped_id(effect_id, "down"), "Down", + fixed_no_shrink(52.0f, 26.0f)); + add_action(handles, property_index, i, + VisualEffectAction::move_down, down); + } + GUI::ElementHandle remove = EditorGUI::text_button(gui, + GUI::make_scoped_id(effect_id, "remove"), "Delete", + fixed_no_shrink(58.0f, 26.0f)); + add_action(handles, property_index, i, VisualEffectAction::remove, remove); + EditorGUI::end_h_layout(gui, toolbar); + + if(!effect.supported) + { + EditorGUI::text(gui, GUI::make_scoped_id(effect_id, "unsupported"), + "This effect uses unsupported or malformed data.", fill_width(48.0f)); + continue; + } + + labeled_combo(*this, effect_id, "phase", "Phase", &effect.phase, + EFFECT_PHASE_LABELS, 2); + labeled_combo(*this, effect_id, "type", "Type", &effect.type, + EFFECT_TYPE_LABELS, EFFECT_TYPE_COUNT); + EditorGUI::text(gui, GUI::make_scoped_id(effect_id, "insets"), "Insets", + fill_width(24.0f)); + const c8* side_ids[] = {"left", "top", "right", "bottom"}; + const c8* side_labels[] = {"Left", "Top", "Right", "Bottom"}; + for(usize side = 0; side < 4; ++side) + { + labeled_number(*this, effect_id, side_ids[side], side_labels[side], + effect.inset + side); + } + + switch(effect.type) + { + case 0: + labeled_color(*this, effect_id, "color", "Color", effect.color); + break; + case 1: + labeled_color(*this, effect_id, "color_top_left", "Top Left", + effect.color); + labeled_color(*this, effect_id, "color_top_right", "Top Right", + effect.gradient_colors[0]); + labeled_color(*this, effect_id, "color_bottom_right", "Bottom Right", + effect.gradient_colors[1]); + labeled_color(*this, effect_id, "color_bottom_left", "Bottom Left", + effect.gradient_colors[2]); + break; + case 2: + labeled_color(*this, effect_id, "color", "Color", effect.color); + labeled_number(*this, effect_id, "radius", "Corner Radius", + &effect.radius, 0.0f, 1024.0f); + break; + case 3: + labeled_color(*this, effect_id, "color", "Color", effect.color); + labeled_number(*this, effect_id, "radius", "Corner Radius", + &effect.radius, 0.0f, 1024.0f); + labeled_number(*this, effect_id, "line_width", "Line Width", + &effect.line_width, 0.0f, 1024.0f); + break; + case 4: + { + labeled_color(*this, effect_id, "color", "Color", effect.color); + labeled_number(*this, effect_id, "radius", "Corner Radius", + &effect.radius, 0.0f, 1024.0f); + labeled_number(*this, effect_id, "offset_x", "Offset X", + effect.shadow_offset); + labeled_number(*this, effect_id, "offset_y", "Offset Y", + effect.shadow_offset + 1); + labeled_number(*this, effect_id, "softness", "Softness", + &effect.shadow_softness, 0.0f, 1024.0f); + labeled_number(*this, effect_id, "spread", "Spread", + &effect.shadow_spread); + const c8* modes[] = {"Outer", "Inner"}; + labeled_combo(*this, effect_id, "shadow_mode", "Mode", + &effect.shadow_mode, modes, 2); + break; + } + default: + EditorGUI::text(gui, GUI::make_scoped_id(effect_id, "invalid_type"), + "Unsupported effect type.", fill_width(48.0f)); + break; + } + } + return add; + } + + bool EditorApp::process_visual_effect_actions(DocumentView& document, + const UIHandles& handles) + { + for(const VisualEffectActionHit& hit : handles.visual_effect_actions) + { + if(!EditorGUI::is_item_clicked(gui, hit.element) || + hit.property_index >= document.property_editors.size()) continue; + PropertyEditor& property = document.property_editors[hit.property_index]; + if(property.desc.editor != EditingPropertyEditor::visual_effects) continue; + switch(hit.action) + { + case VisualEffectAction::add: + { + VisualEffectEditor effect; + effect.type = 4; + effect.color[0] = 0.0f; + effect.color[1] = 0.0f; + effect.color[2] = 0.0f; + effect.color[3] = 0.5f; + effect.shadow_offset[1] = 4.0f; + effect.shadow_softness = 8.0f; + property.visual_effects.push_back(move(effect)); + break; + } + case VisualEffectAction::move_up: + if(!hit.effect_index || hit.effect_index >= property.visual_effects.size()) + continue; + swap(property.visual_effects[hit.effect_index - 1], + property.visual_effects[hit.effect_index]); + break; + case VisualEffectAction::move_down: + if(hit.effect_index + 1 >= property.visual_effects.size()) continue; + swap(property.visual_effects[hit.effect_index], + property.visual_effects[hit.effect_index + 1]); + break; + case VisualEffectAction::remove: + if(hit.effect_index >= property.visual_effects.size()) continue; + property.visual_effects.erase( + property.visual_effects.begin() + hit.effect_index); + break; + } + apply_inspector_changes(document); + return true; + } + return false; + } + } + } +} diff --git a/Programs/GameGUIEditor/Source/EditorWorkingDirectories.cpp b/Programs/GameGUIEditor/Source/EditorWorkingDirectories.cpp new file mode 100644 index 00000000..f46f87c9 --- /dev/null +++ b/Programs/GameGUIEditor/Source/EditorWorkingDirectories.cpp @@ -0,0 +1,208 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file EditorWorkingDirectories.cpp +* @author JXMaster +* @date 2026/9/11 +*/ +#include "EditorApp.hpp" +#include +#include + +namespace Luna::GameGUIEditor::Internal +{ + Path EditorApp::directory_native_path(u64 directory_id) const + { + for(const Variant& directory : working_directories.values()) + if(directory["working_directory_id"].unum() == directory_id) + return Path(directory["native_path"].c_str()); + return Path(); + } + + bool EditorApp::refresh_working_directories() + { + Variant result; + if(!invoke(LIST_DIRECTORIES_URL, Variant(VariantType::object), result)) return false; + working_directories = move(result); + bool selected_exists = false; + for(const Variant& directory : working_directories.values()) + if(directory["working_directory_id"].unum() == selected_directory) selected_exists = true; + if(!selected_exists) + { + selected_directory = working_directories.empty() ? 0 : working_directories[0]["working_directory_id"].unum(); + selected_folder.clear(); + } + return true; + } + + bool EditorApp::open_working_directory(const Path& path) + { + Path native = path; + if(native.empty()) + { + auto selected = Window::open_dir_dialog("Open Working Directory", directory_native_path(selected_directory)); + if(!selected.valid()) + { + if(selected.errcode() != E_INTERRUPTED) + { + error_message = explain(selected.errcode()); + show_file_error("Open Directory Failed"); + } + return false; + } + native = selected.get(); + } + Variant params(VariantType::object); + params["native_path"] = native.encode().c_str(); + Variant result; + if(!invoke(OPEN_DIRECTORY_URL, params, result)) + { + String error = error_message; + refresh_working_directories(); + error_message = move(error); + show_file_error("Open Directory Failed"); + return false; + } + selected_directory = result["working_directory_id"].unum(); + selected_folder.clear(); + return refresh_working_directories(); + } + + void EditorApp::synchronize_previews() + { + if(observed_preview_revision == service->preview_revision()) return; + // Called at the frame boundary, after the previous renderer has consumed all callbacks. + for(DocumentView& document : documents) + { + bool closing = false; + for(u64 id : deferred_document_removals) if(id == document.id) closing = true; + if(!closing) refresh_snapshot(document); + } + observed_preview_revision = service->preview_revision(); + } + + bool EditorApp::confirm_directory_close(u64 directory_id, Variant& plan) + { + Variant params(VariantType::object); + params["working_directory_id"] = directory_id; + if(!invoke(PREPARE_CLOSE_DIRECTORY_URL, params, plan)) return false; + auto cancel = [&]() + { + String error = error_message; + Variant ignored; + invoke(CANCEL_CLOSE_DIRECTORY_URL, plan, ignored); + directory_dialog_active = false; + error_message = move(error); + }; + directory_dialog_active = true; + Vector to_save; + // Collect every choice first. A later Cancel must not discard an earlier document. + for(const Variant& metadata : plan["documents"].values()) + { + if(!metadata["dirty"].boolean()) continue; + const c8* buttons[] = {"Save", "Discard", "Cancel"}; + String message; + strprintf(message, "Save changes to \"%s\" before unloading its working directory?", + metadata["title"].c_str()); + auto response = Window::message_box(message.c_str(), "Unsaved Changes", + {buttons, 3}, Window::MessageBoxIcon::warning, 0, 2); + if(!response.valid() || response.get() == 2) + { + if(!response.valid()) error_message = explain(response.errcode()); + cancel(); + return false; + } + if(response.get() == 0) to_save.push_back(metadata["document_id"].unum()); + } + for(u64 id : to_save) + { + auto document = find_document(id); + if(!document || !save(*document, document->asset_path.empty(), plan["close_token"].unum())) + { + cancel(); + return false; + } + } + Variant decisions(VariantType::array); + for(DocumentView& document : documents) + { + if(document.working_directory_id != directory_id) continue; + Variant decision = editing_params(document); + decision["discard"] = document.dirty; + decisions.push_back(move(decision)); + } + plan["documents"] = move(decisions); + directory_dialog_active = false; + return true; + } + + bool EditorApp::commit_directory_close(const Variant& plan) + { + // The service owns source state; GUI-owned prepared instances must also be released. + for(DocumentView& document : documents) + { + document.preview.instance.reset(); + document.preview.revision = 0; + } + Variant result; + if(!invoke(CLOSE_DIRECTORY_URL, plan, result)) + { + String error = error_message; + Variant ignored; + invoke(CANCEL_CLOSE_DIRECTORY_URL, plan, ignored); + refresh_working_directories(); + error_message = move(error); + show_file_error("Unload Directory Failed"); + return false; + } + Vector removed; + for(const DocumentView& document : documents) + if(document.working_directory_id == plan["working_directory_id"].unum()) removed.push_back(document.id); + for(u64 id : removed) remove_document_view(id); + refresh_working_directories(); + return true; + } + + void EditorApp::process_deferred_directory_closes() + { + for(const Variant& plan : deferred_directory_closes) commit_directory_close(plan); + deferred_directory_closes.clear(); + } + + bool EditorApp::confirm_exit() + { + if(directory_dialog_active || !deferred_directory_closes.empty()) return false; + Vector plans; + Vector directory_ids; + for(const Variant& directory : working_directories.values()) + directory_ids.push_back(directory["working_directory_id"].unum()); + for(u64 directory_id : directory_ids) + { + Variant plan; + if(!confirm_directory_close(directory_id, plan)) + { + for(const Variant& previous : plans) + { + Variant ignored; + invoke(CANCEL_CLOSE_DIRECTORY_URL, previous, ignored); + } + return false; + } + plans.push_back(move(plan)); + } + // Window close callbacks run during poll_events, outside the GUI frame. + for(usize i = 0; i < plans.size(); ++i) + { + if(commit_directory_close(plans[i])) continue; + for(usize j = i + 1; j < plans.size(); ++j) + { + Variant ignored; + invoke(CANCEL_CLOSE_DIRECTORY_URL, plans[j], ignored); + } + return false; + } + return true; + } +} diff --git a/Programs/GameGUIEditor/Source/InspectorLayout.hpp b/Programs/GameGUIEditor/Source/InspectorLayout.hpp new file mode 100644 index 00000000..57cb52cb --- /dev/null +++ b/Programs/GameGUIEditor/Source/InspectorLayout.hpp @@ -0,0 +1,36 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file InspectorLayout.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include "../Service/Authoring.hpp" +#include + +namespace Luna +{ + namespace GameGUIEditor + { + namespace Internal + { + inline bool flex_stretches_child_size(const AuthoringNodeRecord& parent, + const Name& size) + { + if(parent.type != GameGUI::get_flex_node_type()) return false; + Name alignment = parent.properties["cross_alignment"].str(); + if(alignment == Name("start") || alignment == Name("center") || + alignment == Name("end") || alignment == Name("space_between") || + alignment == Name("space_around") || alignment == Name("space_evenly")) + return false; + // Omitted or unrecognized values use the runtime's default Stretch alignment. + bool horizontal = parent.properties["axis"].str() == Name("x"); + return (size == Name("width") && !horizontal) || + (size == Name("height") && horizontal); + } + } + } +} diff --git a/Programs/GameGUIEditor/Source/main.cpp b/Programs/GameGUIEditor/Source/main.cpp new file mode 100644 index 00000000..1295b41a --- /dev/null +++ b/Programs/GameGUIEditor/Source/main.cpp @@ -0,0 +1,64 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file main.cpp +* @author JXMaster +* @date 2026/8/26 +*/ +#include "EditorApp.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Luna; + +int luna_main(int argc, const char* argv[]) +{ + i32 max_frames = -1; + bool discard_smoke = false; + for(int i = 1; i < argc; ++i) + { + i32 parsed = 0; + if(sscanf(argv[i], "--frames=%d", &parsed) == 1) max_frames = parsed; + else if(!strcmp(argv[i], "--discard-smoke")) discard_smoke = true; + } + lupanic_if_failed(Luna::init()); + lupanic_if_failed(add_modules({ + module_window(), + module_rhi(), + module_rhi_utility(), + module_font(), + module_vg(), + GUI::module_gui(), + EditorGUI::module_editor_gui(), + GUIWindow::module_gui_window(), + GameGUI::module_game_gui(), + Frontend::module_frontend() + })); + Window::StartupParams startup_params; + startup_params.name = GameGUIEditor::Internal::APP_NAME; + Window::set_startup_params(startup_params); + lupanic_if_failed(init_modules()); + { + GameGUIEditor::Internal::EditorApp app; + app.max_frames = max_frames; + app.discard_smoke = discard_smoke; + for(int i = 1; i < argc; ++i) + { + if(!strncmp(argv[i], "--workspace=", 12)) app.startup_directories.push_back(argv[i] + 12); + else if(!strncmp(argv[i], "--working-directory=", 20)) app.startup_directories.push_back(argv[i] + 20); + } + lupanic_if_failed(app.init()); + lupanic_if_failed(app.run()); + } + Luna::close(); + return 0; +} diff --git a/Programs/Studio/AssetBrowser.cpp b/Programs/Studio/AssetBrowser.cpp index 6238d258..6cfb5420 100644 --- a/Programs/Studio/AssetBrowser.cpp +++ b/Programs/Studio/AssetBrowser.cpp @@ -167,7 +167,8 @@ namespace Luna return desc; } - void gui_draw_relative_rect(GUI::IContext* context, const RectF& rect, const Float4U& color, f32 radius = 0.0f, + void gui_draw_relative_rect(GUI::IContext* context, const RectF& rect, const Float4U& color, + GUI::paint_order_id_t paint_order_id, f32 radius = 0.0f, const Float4U& scale = Float4U(0.0f)) { GUI::DrawCommand command; @@ -177,12 +178,12 @@ namespace Luna command.rect_layout_scale = scale; command.color = color; command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); } void gui_draw_relative_text(GUI::IContext* context, const RectF& rect, const c8* text, - const Float4U& color = Float4U(1.0f), f32 font_size = 16.0f, - VG::TextAlignment alignment = VG::TextAlignment::center) + const Float4U& color, f32 font_size, + VG::TextAlignment alignment, GUI::paint_order_id_t paint_order_id) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::text; @@ -193,10 +194,11 @@ namespace Luna command.horizontal_alignment = alignment; command.vertical_alignment = VG::TextAlignment::center; command.text = text ? text : ""; - context->draw(command); + context->draw(command, paint_order_id); } - void gui_draw_folder_icon(GUI::IContext* context, f32 tile_size) + void gui_draw_folder_icon(GUI::IContext* context, f32 tile_size, + GUI::paint_order_id_t paint_order_id) { Float4U folder_color(0.78f, 0.78f, 0.78f, 1.0f); gui_draw_relative_rect(context, RectF( @@ -204,13 +206,13 @@ namespace Luna 5.0f + tile_size * 0.18f, tile_size * 0.44f, tile_size * 0.18f), - folder_color, 5.0f); + folder_color, paint_order_id, 5.0f); gui_draw_relative_rect(context, RectF( 5.0f + tile_size * 0.08f, 5.0f + tile_size * 0.30f, tile_size * 0.84f, tile_size * 0.56f), - folder_color, 7.0f); + folder_color, paint_order_id, 7.0f); } void create_new_asset_in_folder(const Path& folder, const Name& type, Name& editing_asset_name, String& editing_buf) @@ -281,6 +283,52 @@ namespace Luna } } + R AssetBrowser::draw_tile(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata) + { + TileDrawData& data = *(TileDrawData*)userdata; + GUI::paint_order_id_t current_order = paint_order_id; + if(data.selected) + { + gui_draw_relative_rect(context, RectF(0.0f, 0.0f, 0.0f, 0.0f), + Float4U(0.18f, 0.28f, 0.45f, 0.90f), current_order, 5.0f, + Float4U(0.0f, 0.0f, 1.0f, 1.0f)); + } + ++current_order; + constexpr f32 padding = 5.0f; + constexpr f32 label_height = 24.0f; + if(data.directory) + { + gui_draw_folder_icon(context, data.tile_size, current_order); + } + else if(data.asset) + { + R result = draw_asset_tile_preview(context, data.asset, + RectF(padding, padding, data.tile_size, data.tile_size), current_order); + if(failed(result)) return result.errcode(); + current_order = result.get(); + } + else + { + gui_draw_relative_text(context, RectF(padding, padding, data.tile_size, data.tile_size), + "Unknown", Float4U(1.0f), 16.0f, VG::TextAlignment::center, current_order); + } + ++current_order; + if(!data.directory) + { + gui_draw_relative_rect(context, + RectF(padding + data.tile_size - 16.0f, padding + data.tile_size - 16.0f, + 12.0f, 12.0f), data.state_color, current_order, 6.0f); + } + ++current_order; + gui_draw_relative_text(context, + RectF(padding, padding + data.tile_size, data.tile_size, label_height), + data.label.c_str(), Float4U(1.0f), 16.0f, VG::TextAlignment::center, + current_order); + return current_order; + } + void AssetBrowser::render(GUI::IContext* context, bool* open) { for(auto& asset : m_deleting_assets) @@ -509,6 +557,7 @@ namespace Luna } void AssetBrowser::tile_context(GUI::IContext* context) { + m_tile_draw_data.clear(); context->push_data_scope(context->make_id("tile_context")); GUI::ElementHandle scroll = EditorGUI::begin_scroll_view(context, context->make_id("scroll"), "Asset Tile Scroll", fill_layout()); @@ -521,6 +570,7 @@ namespace Luna } else { + m_tile_draw_data.reserve(assets.get().size()); constexpr f32 padding = 5.0f; constexpr f32 label_height = 24.0f; f32 tile_width = m_tile_size + padding * 2.0f; @@ -534,17 +584,11 @@ namespace Luna context->push_data_scope(context->make_id(thumbnail.m_filename.c_str())); GUI::ElementHandle tile = EditorGUI::begin_button(context, context->make_id("tile"), thumbnail.m_filename.c_str(), fixed_size(tile_width, tile_height)); - if(selected) - { - gui_draw_relative_rect(context, RectF(0.0f, 0.0f, 0.0f, 0.0f), - Float4U(0.18f, 0.28f, 0.45f, 0.90f), 5.0f); - } - - if(thumbnail.m_is_dir) - { - gui_draw_folder_icon(context, m_tile_size); - } - else + TileDrawData draw_data; + draw_data.selected = selected; + draw_data.directory = thumbnail.m_is_dir; + draw_data.tile_size = m_tile_size; + if(!thumbnail.m_is_dir) { auto meta_path = m_path; meta_path.push_back(thumbnail.m_filename); @@ -552,7 +596,7 @@ namespace Luna if(succeeded(asset)) { auto asset_type = Asset::get_asset_type(asset.get()); - draw_asset_tile_preview(context, asset.get(), RectF(padding, padding, m_tile_size, m_tile_size)); + draw_data.asset = asset.get(); auto iter = g_env->editor_types.find(asset_type); if(iter != g_env->editor_types.end() && EditorGUI::is_item_double_clicked(context, tile)) @@ -568,15 +612,11 @@ namespace Luna } Float4U state_color = succeeded(state) && state.get() == Asset::AssetDataUnitState::loaded ? Color::green() : Color::yellow(); - gui_draw_relative_rect(context, RectF(padding + m_tile_size - 16.0f, padding + m_tile_size - 16.0f, - 12.0f, 12.0f), state_color, 6.0f); + draw_data.state_color = state_color; } else { - gui_draw_relative_text(context, RectF(padding, padding, m_tile_size, m_tile_size), "Unknown", - Float4U(1.0f), 16.0f); - gui_draw_relative_rect(context, RectF(padding + m_tile_size - 16.0f, padding + m_tile_size - 16.0f, - 12.0f, 12.0f), Color::red(), 6.0f); + draw_data.state_color = Color::red(); } } @@ -592,8 +632,25 @@ namespace Luna { display_text = display_name; } - gui_draw_relative_text(context, RectF(padding, padding + m_tile_size, m_tile_size, label_height), - display_text.c_str(), Float4U(1.0f), 16.0f); + draw_data.label = move(display_text); + m_tile_draw_data.push_back(move(draw_data)); + GUI::ElementHandle visual = context->begin_element(context->make_id("tile.visual")); + GUI::LayoutConfig visual_layout = fixed_size(tile_width, tile_height); + Name button_style = context->current_style(); + if(button_style.empty()) button_style = Name(EditorGUI::DEFAULT_STYLE_NAME); + Float4U button_padding_value = context->get_style_value(button_style, + Name("gui.button.padding"), GUI::style_f32x2(Float2U(10.0f, 6.0f))).number; + visual_layout.margin = Float4U(-button_padding_value.x, -button_padding_value.y, + -button_padding_value.x, -button_padding_value.y); + visual_layout.flex_shrink = 0.0f; + context->set_layout_config(visual, visual_layout); + context->set_element_debug_name(visual, Name("Asset Tile Visual")); + GUI::DrawConfig visual_draw; + visual_draw.name = Name("studio.asset_browser.tile"); + visual_draw.callback = draw_tile; + visual_draw.userdata = &m_tile_draw_data.back(); + context->set_draw_config(visual, visual_draw); + context->end_element(); EditorGUI::end_button(context); if(EditorGUI::is_item_clicked(context, tile) || EditorGUI::is_item_right_clicked(context, tile)) diff --git a/Programs/Studio/AssetBrowser.hpp b/Programs/Studio/AssetBrowser.hpp index 2e052e7a..b2f3b2dc 100644 --- a/Programs/Studio/AssetBrowser.hpp +++ b/Programs/Studio/AssetBrowser.hpp @@ -62,6 +62,21 @@ namespace Luna void render(GUI::IContext* context, bool* open); private: + struct TileDrawData + { + bool selected = false; + bool directory = false; + Asset::asset_t asset; + Float4U state_color = Float4U(0.0f); + String label; + f32 tile_size = 0.0f; + }; + + Vector m_tile_draw_data; + + static R draw_tile(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata); void navbar(GUI::IContext* context); void tile_context(GUI::IContext* context); }; diff --git a/Programs/Studio/Assets/TextureEditor.cpp b/Programs/Studio/Assets/TextureEditor.cpp index 57f1ca77..aeb7e4f1 100644 --- a/Programs/Studio/Assets/TextureEditor.cpp +++ b/Programs/Studio/Assets/TextureEditor.cpp @@ -62,7 +62,8 @@ namespace Luna context->pop_data_scope(); } - static void on_draw_tex_tile_gui(GUI::IContext* context, object_t userdata, Asset::asset_t asset, const RectF& draw_rect) + static R on_draw_tex_tile_gui(GUI::IContext* context, object_t userdata, + Asset::asset_t asset, const RectF& draw_rect, GUI::paint_order_id_t paint_order_id) { auto state = Asset::get_asset_data_unit_state(asset, Name()); if(succeeded(state) && state.get() == Asset::AssetDataUnitState::loaded) @@ -74,8 +75,8 @@ namespace Luna command.type = GUI::DrawCommandType::image; command.rect = draw_rect; command.texture = tex.get(); - context->draw(command); - return; + context->draw(command, paint_order_id); + return paint_order_id; } } GUI::DrawCommand command; @@ -86,10 +87,11 @@ namespace Luna command.horizontal_alignment = VG::TextAlignment::center; command.vertical_alignment = VG::TextAlignment::center; command.text = "Texture"; - context->draw(command); + context->draw(command, paint_order_id); + return paint_order_id; } - static void on_draw_tex_tile_preview_gui(GUI::IContext* context, object_t userdata, Asset::asset_t asset, - const RectF& relative_rect) + static R on_draw_tex_tile_preview_gui(GUI::IContext* context, object_t userdata, + Asset::asset_t asset, const RectF& relative_rect, GUI::paint_order_id_t paint_order_id) { auto state = Asset::get_asset_data_unit_state(asset, Name()); if(succeeded(state) && state.get() == Asset::AssetDataUnitState::loaded) @@ -103,8 +105,8 @@ namespace Luna command.rect = relative_rect; command.color = Float4U(1.0f); command.texture = tex.get(); - context->draw(command); - return; + context->draw(command, paint_order_id); + return paint_order_id; } } GUI::DrawCommand command; @@ -116,7 +118,8 @@ namespace Luna command.horizontal_alignment = VG::TextAlignment::center; command.vertical_alignment = VG::TextAlignment::center; command.text = "Texture"; - context->draw(command); + context->draw(command, paint_order_id); + return paint_order_id; } static Ref new_tex_editor(object_t userdata, Asset::asset_t editing_asset) { diff --git a/Programs/Studio/MainEditor.cpp b/Programs/Studio/MainEditor.cpp index 19c580f4..d37d96fd 100644 --- a/Programs/Studio/MainEditor.cpp +++ b/Programs/Studio/MainEditor.cpp @@ -18,6 +18,7 @@ #include "Assets/Material.hpp" #include +#include #include #if defined(LUNA_PLATFORM_MACOS) #include @@ -369,7 +370,8 @@ namespace Luna // Mount Data folder. auto mount_path = project_path; mount_path.push_back("Data"); - luexp(VFS::mount(VFS::get_platform_filesystem_driver(), mount_path.encode(PathSeparator::system_preferred).c_str(), "/")); + lulet(file_system, VFS::new_native_file_system(mount_path.encode(PathSeparator::system_preferred).c_str())); + luexp(VFS::mount(file_system, "/")); // Load all asset metadata. luexp(Asset::load_assets_meta("/")); @@ -759,7 +761,8 @@ namespace Luna Asset::close(); } - void draw_asset_tile(GUI::IContext* context, Asset::asset_t asset, const RectF& draw_rect) + R draw_asset_tile(GUI::IContext* context, Asset::asset_t asset, + const RectF& draw_rect, GUI::paint_order_id_t paint_order_id) { luassert(context); if(asset) @@ -770,7 +773,8 @@ namespace Luna { if(iter->second.on_draw_tile_gui) { - iter->second.on_draw_tile_gui(context, iter->second.userdata.get(), asset, draw_rect); + return iter->second.on_draw_tile_gui(context, iter->second.userdata.get(), asset, + draw_rect, paint_order_id); } else { @@ -782,9 +786,9 @@ namespace Luna command.horizontal_alignment = VG::TextAlignment::center; command.vertical_alignment = VG::TextAlignment::center; command.text = asset_type.c_str(); - context->draw(command); + context->draw(command, paint_order_id); } - return; + return paint_order_id; } GUI::DrawCommand command; command.type = GUI::DrawCommandType::text; @@ -794,13 +798,15 @@ namespace Luna command.horizontal_alignment = VG::TextAlignment::center; command.vertical_alignment = VG::TextAlignment::center; command.text = asset_type.c_str(); - context->draw(command); + context->draw(command, paint_order_id); } + return paint_order_id; } namespace { - void draw_relative_tile_text(GUI::IContext* context, const RectF& relative_rect, const c8* text) + void draw_relative_tile_text(GUI::IContext* context, const RectF& relative_rect, const c8* text, + GUI::paint_order_id_t paint_order_id) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::text; @@ -811,11 +817,12 @@ namespace Luna command.horizontal_alignment = VG::TextAlignment::center; command.vertical_alignment = VG::TextAlignment::center; command.text = text ? text : ""; - context->draw(command); + context->draw(command, paint_order_id); } } - void draw_asset_tile_preview(GUI::IContext* context, Asset::asset_t asset, const RectF& relative_rect) + R draw_asset_tile_preview(GUI::IContext* context, Asset::asset_t asset, + const RectF& relative_rect, GUI::paint_order_id_t paint_order_id) { luassert(context); if(asset) @@ -826,15 +833,17 @@ namespace Luna { if(iter->second.on_draw_tile_preview_gui) { - iter->second.on_draw_tile_preview_gui(context, iter->second.userdata.get(), asset, relative_rect); + return iter->second.on_draw_tile_preview_gui(context, iter->second.userdata.get(), asset, + relative_rect, paint_order_id); } else { - draw_relative_tile_text(context, relative_rect, asset_type.c_str()); + draw_relative_tile_text(context, relative_rect, asset_type.c_str(), paint_order_id); } - return; + return paint_order_id; } - draw_relative_tile_text(context, relative_rect, asset_type.c_str()); + draw_relative_tile_text(context, relative_rect, asset_type.c_str(), paint_order_id); } + return paint_order_id; } } diff --git a/Programs/Studio/MainEditor.hpp b/Programs/Studio/MainEditor.hpp index 317a3c01..b9e4cc93 100644 --- a/Programs/Studio/MainEditor.hpp +++ b/Programs/Studio/MainEditor.hpp @@ -189,8 +189,10 @@ namespace Luna void run_main_editor(const Path& project_path); - void draw_asset_tile(GUI::IContext* context, Asset::asset_t asset, const RectF& draw_rect); - void draw_asset_tile_preview(GUI::IContext* context, Asset::asset_t asset, const RectF& relative_rect); + R draw_asset_tile(GUI::IContext* context, Asset::asset_t asset, + const RectF& draw_rect, GUI::paint_order_id_t paint_order_id); + R draw_asset_tile_preview(GUI::IContext* context, Asset::asset_t asset, + const RectF& relative_rect, GUI::paint_order_id_t paint_order_id); extern MainEditor* g_main_editor; } diff --git a/Programs/Studio/ProjectSelector.cpp b/Programs/Studio/ProjectSelector.cpp index 606e7580..b0282e83 100644 --- a/Programs/Studio/ProjectSelector.cpp +++ b/Programs/Studio/ProjectSelector.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include namespace Luna @@ -147,6 +148,8 @@ namespace Luna constexpr GUI::id_t PROJECT_SELECTOR_OPEN_HEADER_ID = 20; constexpr GUI::id_t PROJECT_SELECTOR_BROWSE_BUTTON_ID = 21; constexpr GUI::id_t PROJECT_SELECTOR_RECENT_VIEW_ID = 22; + constexpr GUI::id_t PROJECT_SELECTOR_RECENT_CLIP_PUSH_ID = 23; + constexpr GUI::id_t PROJECT_SELECTOR_RECENT_CLIP_POP_ID = 24; constexpr GUI::id_t PROJECT_SELECTOR_FIRST_TEXT_ID = 1000; constexpr GUI::id_t PROJECT_SELECTOR_FIRST_RECENT_ID = 2000; constexpr GUI::id_t PROJECT_SELECTOR_RECENT_STRIDE = 8; @@ -181,45 +184,77 @@ namespace Luna GUI::style_f32x4(fallback)).number; } - static void draw_label(GUI::IContext* context, GUI::id_t id, const RectF& rect, const c8* text, - f32 font_size = 16.0f) + struct ProjectSelectorDrawData + { + GUI::DrawCommand command; + }; + + using ProjectSelectorDrawStorage = Vector>; + + static R draw_project_selector_command(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata) + { + ProjectSelectorDrawData& data = *(ProjectSelectorDrawData*)userdata; + context->draw(data.command, paint_order_id); + return paint_order_id; + } + + static void submit_draw_command(GUI::IContext* context, GUI::id_t id, + GUI::DrawCommand&& command, ProjectSelectorDrawStorage& draw_data) + { + UniquePtr data(memnew()); + data->command = move(command); + GUI::ElementHandle element = context->begin_element(id); + context->set_element_debug_name(element, Name("Project Selector Draw Command")); + GUI::DrawConfig draw; + draw.name = Name("studio.project_selector.command"); + draw.callback = draw_project_selector_command; + draw.userdata = data.get(); + context->set_draw_config(element, draw); + context->end_element(); + draw_data.push_back(move(data)); + } + + static void draw_label(GUI::IContext* context, GUI::id_t id, const RectF& rect, + const c8* text, ProjectSelectorDrawStorage& draw_data, f32 font_size = 16.0f) { - (void)id; GUI::DrawCommand command; command.type = GUI::DrawCommandType::text; command.rect = rect; command.color = style_color(context, "gui.text.color", Float4U(0.15f, 0.16f, 0.17f, 1.0f)); command.font_size = font_size; command.text = text ? text : ""; - context->draw(command); + submit_draw_command(context, id, move(command), draw_data); } static void draw_rect(GUI::IContext* context, GUI::id_t id, const RectF& rect, - const Float4U& color, f32 radius = 0.0f) + const Float4U& color, ProjectSelectorDrawStorage& draw_data, f32 radius = 0.0f) { - (void)id; GUI::DrawCommand command; command.type = radius > 0.0f ? GUI::DrawCommandType::rounded_rect : GUI::DrawCommandType::rect; command.rect = rect; command.color = color; command.radius = radius; - context->draw(command); + submit_draw_command(context, id, move(command), draw_data); } - static void push_clip(GUI::IContext* context, const RectF& rect) + static void push_clip(GUI::IContext* context, GUI::id_t id, const RectF& rect, + ProjectSelectorDrawStorage& draw_data) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::push_clip; command.rect = rect; command.rect_reference = GUI::DrawCommandRectReference::layer; - context->draw(command); + submit_draw_command(context, id, move(command), draw_data); } - static void pop_clip(GUI::IContext* context) + static void pop_clip(GUI::IContext* context, GUI::id_t id, + ProjectSelectorDrawStorage& draw_data) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::pop_clip; - context->draw(command); + submit_draw_command(context, id, move(command), draw_data); } static GUI::ElementHandle begin_scroll_region(GUI::IContext* context, GUI::id_t id, const RectF& rect) @@ -235,17 +270,20 @@ namespace Luna return element; } - static GUI::ElementHandle build_project_selector_gui(GUI::IContext* context, const Float2U& surface_size, String& project_name, - bool& create_dir, const Vector& recents, f32 recent_scroll) + static GUI::ElementHandle build_project_selector_gui(GUI::IContext* context, + const Float2U& surface_size, String& project_name, bool& create_dir, + const Vector& recents, f32 recent_scroll, + ProjectSelectorDrawStorage& draw_data) { GUI::ElementHandle root = context->begin_element(PROJECT_SELECTOR_ROOT_ID); set_element_rect(context, root, RectF(0.0f, 0.0f, surface_size.x, surface_size.y)); draw_rect(context, PROJECT_SELECTOR_BACKGROUND_ID, RectF(0.0f, 0.0f, surface_size.x, surface_size.y), - style_color(context, "gui.canvas", Float4U(0.92f, 0.93f, 0.92f, 1.0f)), 0.0f); + style_color(context, "gui.canvas", Float4U(0.92f, 0.93f, 0.92f, 1.0f)), draw_data, 0.0f); f32 content_w = max(surface_size.x - 32.0f, 320.0f); f32 y = 16.0f; - draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID, RectF(16.0f, y, 360.0f, 30.0f), "Luna Studio Project Selector", 20.0f); + draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID, + RectF(16.0f, y, 360.0f, 30.0f), "Luna Studio Project Selector", draw_data, 20.0f); y += 46.0f; GUI::ElementHandle new_header; @@ -257,7 +295,8 @@ namespace Luna y += 40.0f; if(show_new_project) { - draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 1, RectF(32.0f, y, 160.0f, 30.0f), "Project Name"); + draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 1, + RectF(32.0f, y, 160.0f, 30.0f), "Project Name", draw_data); GUI::ElementHandle input = EditorGUI::input_text(context, PROJECT_SELECTOR_NAME_INPUT_ID, project_name, fixed_layout(max(content_w - 190.0f, 160.0f), 30.0f)); set_element_rect(context, input, RectF(190.0f, y, max(content_w - 190.0f, 160.0f), 30.0f)); @@ -288,19 +327,21 @@ namespace Luna if(recents.empty()) { - draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 2, RectF(32.0f, y, 260.0f, 28.0f), "No recent projects."); + draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 2, + RectF(32.0f, y, 260.0f, 28.0f), "No recent projects.", draw_data); } else { - draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 2, RectF(32.0f, y, 260.0f, 28.0f), "Recent Projects"); + draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 2, + RectF(32.0f, y, 260.0f, 28.0f), "Recent Projects", draw_data); y += 34.0f; f32 recent_h = max(surface_size.y - y - 24.0f, 120.0f); RectF view_rect(32.0f, y, max(content_w - 32.0f, 260.0f), recent_h); GUI::ElementHandle scroll = begin_scroll_region(context, PROJECT_SELECTOR_RECENT_VIEW_ID, view_rect); draw_rect(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 3, view_rect, - style_color(context, "gui.surface.0", Float4U(0.95f, 0.95f, 0.94f, 1.0f)), 4.0f); - push_clip(context, view_rect); + style_color(context, "gui.surface.0", Float4U(0.95f, 0.95f, 0.94f, 1.0f)), draw_data, 4.0f); + push_clip(context, PROJECT_SELECTOR_RECENT_CLIP_PUSH_ID, view_rect, draw_data); f32 row_h = 34.0f; f32 header_h = 30.0f; @@ -311,11 +352,16 @@ namespace Luna f32 x = view_rect.offset_x; f32 header_y = view_rect.offset_y - recent_scroll; draw_rect(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 4, RectF(x, header_y, view_rect.width, header_h), - style_color(context, "gui.surface.2", Float4U(0.93f, 0.93f, 0.92f, 1.0f)), 0.0f); - draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 5, RectF(x + 8.0f, header_y + 2.0f, path_w - 16.0f, 26.0f), "Project"); - draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 6, RectF(x + path_w + 8.0f, header_y + 2.0f, time_w - 16.0f, 26.0f), "Last Used"); - draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 7, RectF(x + path_w + time_w + 8.0f, header_y + 2.0f, open_w - 16.0f, 26.0f), "Open"); - draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 8, RectF(x + path_w + time_w + open_w + 8.0f, header_y + 2.0f, remove_w - 16.0f, 26.0f), "Remove"); + style_color(context, "gui.surface.2", Float4U(0.93f, 0.93f, 0.92f, 1.0f)), draw_data, 0.0f); + draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 5, + RectF(x + 8.0f, header_y + 2.0f, path_w - 16.0f, 26.0f), "Project", draw_data); + draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 6, + RectF(x + path_w + 8.0f, header_y + 2.0f, time_w - 16.0f, 26.0f), "Last Used", draw_data); + draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 7, + RectF(x + path_w + time_w + 8.0f, header_y + 2.0f, open_w - 16.0f, 26.0f), "Open", draw_data); + draw_label(context, PROJECT_SELECTOR_FIRST_TEXT_ID + 8, + RectF(x + path_w + time_w + open_w + 8.0f, header_y + 2.0f, remove_w - 16.0f, 26.0f), + "Remove", draw_data); for(usize i = 0; i < recents.size(); ++i) { @@ -328,12 +374,17 @@ namespace Luna Float4U row_color = (i % 2) ? style_color(context, "gui.surface.1", Float4U(0.97f, 0.97f, 0.96f, 1.0f)) : style_color(context, "gui.surface.0", Float4U(0.95f, 0.95f, 0.94f, 1.0f)); - draw_rect(context, row_base, RectF(x, row_y, view_rect.width, row_h), row_color, 0.0f); + draw_rect(context, row_base, RectF(x, row_y, view_rect.width, row_h), + row_color, draw_data, 0.0f); DateTime dt = timestamp_to_datetime(utc_timestamp_to_local_timestamp(recents[i].m_last_use_time)); String time_text; strprintf(time_text, "%hu/%hu/%hu %02hu:%02hu", dt.year, dt.month, dt.day, dt.hour, dt.minute); - draw_label(context, row_base + 1, RectF(x + 8.0f, row_y + 3.0f, path_w - 16.0f, 28.0f), recents[i].m_path.encode().c_str()); - draw_label(context, row_base + 2, RectF(x + path_w + 8.0f, row_y + 3.0f, time_w - 16.0f, 28.0f), time_text.c_str()); + draw_label(context, row_base + 1, + RectF(x + 8.0f, row_y + 3.0f, path_w - 16.0f, 28.0f), + recents[i].m_path.encode().c_str(), draw_data); + draw_label(context, row_base + 2, + RectF(x + path_w + 8.0f, row_y + 3.0f, time_w - 16.0f, 28.0f), + time_text.c_str(), draw_data); GUI::ElementHandle open = EditorGUI::text_button(context, row_base + 3, "Open", fixed_layout(open_w - 8.0f, 28.0f)); set_element_rect(context, open, @@ -343,7 +394,7 @@ namespace Luna set_element_rect(context, remove, RectF(x + path_w + time_w + open_w + 4.0f, row_y + 3.0f, remove_w - 8.0f, 28.0f)); } - pop_clip(context); + pop_clip(context, PROJECT_SELECTOR_RECENT_CLIP_POP_ID, draw_data); context->end_element(); } } @@ -384,6 +435,7 @@ namespace Luna bool create_dir = true; f32 recent_scroll = 0.0f; + ProjectSelectorDrawStorage draw_data; while (path.empty()) { @@ -420,11 +472,12 @@ namespace Luna frame.render_size = fb_sz; frame.delta_time = 1.0f / 60.0f; gui->begin_frame(frame); + draw_data.clear(); GUIWindow::update_input(&input_adapter); gui->push_layer(PROJECT_SELECTOR_LAYER_ID, Float2U(0.0f)); GUI::ElementHandle root = build_project_selector_gui(gui, frame.logical_size, new_solution_name, create_dir, - recents, recent_scroll); + recents, recent_scroll, draw_data); gui->pop_layer(); luexp(EditorGUI::layout_tree(gui, root, RectF(0.0f, 0.0f, frame.logical_size.x, frame.logical_size.y))); diff --git a/Programs/Studio/StudioEnv.hpp b/Programs/Studio/StudioEnv.hpp index a3b715b9..2d55b863 100644 --- a/Programs/Studio/StudioEnv.hpp +++ b/Programs/Studio/StudioEnv.hpp @@ -9,6 +9,7 @@ */ #pragma once #include +#include #include #include #include @@ -35,10 +36,16 @@ namespace Luna struct AssetEditorDesc { ObjRef userdata; - //! Called when the tile is going to be drawn by a GUI based asset browser. - void (*on_draw_tile_gui)(GUI::IContext* context, object_t userdata, Asset::asset_t asset, const RectF& draw_rect) = nullptr; - //! Called while a GUI tile element is being built to draw a preview relative to that tile element. - void (*on_draw_tile_preview_gui)(GUI::IContext* context, object_t userdata, Asset::asset_t asset, const RectF& relative_rect) = nullptr; + //! Called during GUI draw-command generation to draw an asset tile. + //! @param[in] paint_order_id The first Paint Order ID available to the callback. + //! @return Returns the maximum Paint Order ID used by the callback, or a failure code. + R (*on_draw_tile_gui)(GUI::IContext* context, object_t userdata, + Asset::asset_t asset, const RectF& draw_rect, GUI::paint_order_id_t paint_order_id) = nullptr; + //! Called during GUI draw-command generation to draw a preview relative to its tile element. + //! @param[in] paint_order_id The first Paint Order ID available to the callback. + //! @return Returns the maximum Paint Order ID used by the callback, or a failure code. + R (*on_draw_tile_preview_gui)(GUI::IContext* context, object_t userdata, + Asset::asset_t asset, const RectF& relative_rect, GUI::paint_order_id_t paint_order_id) = nullptr; //! Called when a new editor is requested to be open for the specified asset. Ref(*new_editor)(object_t userdata, Asset::asset_t editing_asset) = nullptr; }; diff --git a/Tests/AHITest/main.cpp b/Tests/AHITest/main.cpp index ea132194..ae9bfd24 100644 --- a/Tests/AHITest/main.cpp +++ b/Tests/AHITest/main.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -213,6 +214,11 @@ void on_capture_data(const void* src_buffer, const AHI::WaveFormat& format, u32 namespace Luna { + struct FrameDrawData + { + GUI::DrawCommand command; + }; + struct App { Ref window; @@ -223,6 +229,7 @@ namespace Luna Vector> playback_adapters; Vector> capture_adapters; Vector audio_sources; + Vector> frame_draw_data; Ref device; u32 width = 0; u32 height = 0; @@ -230,6 +237,7 @@ namespace Luna constexpr GUI::id_t DEFAULT_LAYER_ID = 1; constexpr GUI::id_t ROOT_ID = 2; + constexpr GUI::id_t BACKGROUND_ID = 3; constexpr GUI::id_t ADAPTERS_HEADER_ID = 10; constexpr GUI::id_t PLAYBACK_COMBO_ID = 11; constexpr GUI::id_t CAPTURE_COMBO_ID = 12; @@ -280,13 +288,37 @@ namespace Luna set_element_rect(context, element, rect); } - void draw_solid_rect(GUI::IContext* context, const RectF& rect, const Float4U& color) + R draw_frame_command(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, GUI::paint_order_id_t paint_order_id, + void* userdata) + { + FrameDrawData* data = (FrameDrawData*)userdata; + context->draw(data->command, paint_order_id); + return paint_order_id; + } + + void draw_solid_rect(App& app, GUI::id_t id, const RectF& rect, const RectF& clip_rect, + const Float4U& color) { + UniquePtr data(memnew()); GUI::DrawCommand command; command.type = GUI::DrawCommandType::rect; command.rect = rect; command.color = color; - context->draw(command); + data->command = move(command); + GUI::ElementHandle element = app.gui->begin_element(id); + GUI::LayoutResult layout; + layout.rect = rect; + layout.clip_rect = clip_rect; + layout.content_size = Float2U(rect.width, rect.height); + app.gui->set_layout_result(element, layout); + GUI::DrawConfig draw; + draw.name = Name("ahi_test.rect"); + draw.callback = draw_frame_command; + draw.userdata = data.get(); + app.gui->set_draw_config(element, draw); + app.gui->end_element(); + app.frame_draw_data.push_back(move(data)); } GUI::ElementHandle build_gui(App& app, const Float2U& surface_size, @@ -295,7 +327,8 @@ namespace Luna GUI::IContext* context = app.gui; GUI::ElementHandle root = context->begin_element(ROOT_ID); set_element_rect(context, root, RectF(0.0f, 0.0f, surface_size.x, surface_size.y)); - draw_solid_rect(context, RectF(0.0f, 0.0f, surface_size.x, surface_size.y), + const RectF surface_rect(0.0f, 0.0f, surface_size.x, surface_size.y); + draw_solid_rect(app, BACKGROUND_ID, surface_rect, surface_rect, style_color(context, "gui.canvas", Float4U(0.92f, 0.93f, 0.92f, 1.0f))); f32 y = 16.0f; @@ -410,7 +443,8 @@ namespace Luna Float4U row_color = (i % 2) ? style_color(context, "gui.surface.1", Float4U(0.97f, 0.97f, 0.96f, 1.0f)) : style_color(context, "gui.surface.0", Float4U(0.95f, 0.95f, 0.94f, 1.0f)); - draw_solid_rect(context, RectF(table_x, row_y, table_w, 34.0f), row_color); + draw_solid_rect(app, FIRST_SOURCE_ID + (GUI::id_t)i * SOURCE_ID_STRIDE, + RectF(table_x, row_y, table_w, 34.0f), surface_rect, row_color); draw_label(context, FIRST_SOURCE_ID + (GUI::id_t)i * SOURCE_ID_STRIDE + 1, RectF(table_x + 8.0f, row_y + 4.0f, 120.0f, 26.0f), "Audio Source"); EditorGUI::DragDesc drag_desc; @@ -512,6 +546,7 @@ namespace Luna frame.render_size = fb_sz; frame.delta_time = 1.0f / 60.0f; app.gui->begin_frame(frame); + app.frame_draw_data.clear(); GUIWindow::update_input(&input_adapter); static i32 current_playback_adapter = 0; diff --git a/Tests/AssetDatabaseTest/AssetDatabaseTest.Target.cs b/Tests/AssetDatabaseTest/AssetDatabaseTest.Target.cs new file mode 100644 index 00000000..e410cec1 --- /dev/null +++ b/Tests/AssetDatabaseTest/AssetDatabaseTest.Target.cs @@ -0,0 +1,17 @@ +namespace LunaBuild.Core.Targets; + +public sealed class AssetDatabaseTestTargetRules : TargetRules +{ + public AssetDatabaseTestTargetRules() + : base(name: "AssetDatabaseTest", targetDirectory: "Tests/AssetDatabaseTest", + rulesPath: "Tests/AssetDatabaseTest/AssetDatabaseTest.Target.cs") + { + SupportedPlatforms(BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Linux); + Category = BuildTargetCategory.Tests; + Kind = BuildTargetKind.Executable; + Headers("Source/*.hpp"); + MetaHeaders("Source/TestTypes.hpp"); + Sources("Source/*.cpp"); + DependsOn("Runtime", "VariantUtils", "VFS", "Asset", "Pak"); + } +} diff --git a/Tests/AssetDatabaseTest/Source/Main.cpp b/Tests/AssetDatabaseTest/Source/Main.cpp new file mode 100644 index 00000000..3be72aa9 --- /dev/null +++ b/Tests/AssetDatabaseTest/Source/Main.cpp @@ -0,0 +1,436 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Main.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include "TestTypes.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "AssetDatabaseTest.meta.generated.hpp" + +using namespace Luna; +using namespace Luna::Asset; + +template static T must(R result) +{ + lupanic_if_failed(result); + return move(result.get()); +} +template static void expect_error(R result, ResultCode code) +{ + luassert_always(failed(result)); + if(unwrap_errcode(result.errcode()) != code) + { + printf("Unexpected error: %s\n", explain(result.errcode())); + luassert_always(false); + } +} +static Path child(const Path& root, const c8* name) +{ + Path result(root); + result.append(Path(name)); + return result; +} +static void put(const Path& path, const c8* text) +{ + auto file = must(VFS::open_file(path, FileOpenFlag::write, FileCreationMode::create_always)); + lupanic_if_failed(file->write(text, strlen(text))); +} +static String text_at(const Path& path) +{ + auto file = must(VFS::open_file(path, FileOpenFlag::read, FileCreationMode::open_existing)); + auto bytes = must(load_file_data(file)); + return String((const c8*)bytes.data(), bytes.size()); +} +static bool exists(const Path& path) { return succeeded(VFS::get_file_attribute(path)); } +static AssetMetadata record(const c8* path) +{ + AssetMetadata value; + value.guid = random_guid(); + value.path = path; + value.type = "DatabaseTest.Type"; + value.data_units.push_back({Name("Editor"), Name("Unregistered.Loader")}); + return value; +} +static Variant encode_record(const AssetMetadata& value) +{ + Variant data(VariantType::object); + data["guid"] = must(serialize(value.guid)); + data["path"] = value.path.encode().c_str(); + data["type"] = value.type; + data["data_units"] = must(serialize(value.data_units)); + return data; +} +static void write_snapshot(const Path& root, const Variant& records) +{ + Variant data(VariantType::object); + data["format_version"] = Variant((u64)1); + data["assets"] = records; + auto text = VariantUtils::write_json(data); + put(child(root, "assets.db"), text.c_str()); +} +static Variant records_array(const AssetMetadata& value) +{ + Variant data(VariantType::array); + data.push_back(encode_record(value)); + return data; +} +static Ref seed_database(const Path& root, const AssetMetadata& value) +{ + lupanic_if_failed(VFS::create_dir(root)); + auto database = must(create_file_database(root)); + lupanic_if_failed(database->write_record(value)); + lupanic_if_failed(database->flush()); + return database; +} +static void remove_tree(const Path& root) +{ + Vector> children; + { + auto iter = must(VFS::open_dir(root)); + for(; iter->is_valid(); iter->move_next()) + { + auto name = iter->get_filename(); + if(!strcmp(name, ".") || !strcmp(name, "..")) continue; + children.push_back(make_pair(child(root, name), test_flags(iter->get_attributes(), FileAttributeFlag::directory))); + } + } + for(const auto& item : children) + { + if(item.second) remove_tree(item.first); + else lupanic_if_failed(VFS::delete_file(item.first)); + } + lupanic_if_failed(VFS::delete_file(root)); +} + +static void discovery_and_reload(CountingFileSystem* storage) +{ + Path root("/DatabaseTest/discovery"); + auto hero = record("Characters/Hero"); + auto seeded = seed_database(root, hero); + auto peer = record("Peer"); + lupanic_if_failed(seeded->write_record(peer)); + lupanic_if_failed(seeded->flush()); + // The payload deliberately does not exist. Discovery must still register the record. + storage->read_files = storage->directories = 0; + auto database = must(open_file_database(root, "assets.db", DatabaseMode::read_write)); + auto placeholder = get_asset(hero.guid); + lupanic_if_failed(register_asset_database(database)); + luassert_always(storage->read_files == 1 && storage->directories == 0); + luassert_always(must(get_asset_by_path(child(root, "Characters/Hero"))) == placeholder); + lupanic_if_failed(load_asset_meta(placeholder)); + luassert_always(storage->read_files == 1 && storage->directories == 0); + auto nested = must(new_sidecar_database(child(root, "Characters"))); + expect_error(register_asset_database(nested), E_ALREADY_EXISTS); + + AssetDataUnitDesc added{Name("Extra"), Name("Another.Loader")}; + lupanic_if_failed(add_asset_data_unit(placeholder, added)); + lupanic_if_failed(save_asset_meta(placeholder)); + luassert_always(database->is_dirty()); + expect_error(reload_asset_database(database), E_BUSY); + lupanic_if_failed(database->flush()); + + lupanic_if_failed(load_asset_data_unit_default_data(placeholder, Name())); + auto original = must(database->read_record(hero.path)); + auto changed = original; + changed.type = "Changed.Type"; + auto peer_changed = peer; + peer_changed.type = "Peer.Changed"; + auto candidate_batch = records_array(peer_changed); + candidate_batch.push_back(encode_record(changed)); + write_snapshot(root, candidate_batch); + expect_error(reload_asset_database(database), E_ASSET_DATA_UNIT_BUSY); + luassert_always(get_asset_type(placeholder) == original.type); + luassert_always(must(database->read_record(hero.path)).type == original.type); + luassert_always(get_asset_type(get_asset(peer.guid)) == peer.type); + luassert_always(must(database->read_record(peer.path)).type == peer.type); + expect_error(unregister_asset_database(database), E_ASSET_DATA_UNIT_BUSY); + lupanic_if_failed(set_asset_data_unit_object(placeholder, Name(), nullptr)); + lupanic_if_failed(reload_asset_database(database)); + luassert_always(get_asset_type(placeholder) == changed.type); + + // Invalid late records must not partially overwrite earlier registry records. + auto candidate = changed; + candidate.type = "Would.Partially.Change"; + auto duplicate = records_array(candidate); + auto second = encode_record(record("Second")); + second["guid"] = must(serialize(hero.guid)); + duplicate.push_back(second); + write_snapshot(root, duplicate); + expect_error(reload_asset_database(database), E_ALREADY_EXISTS); + luassert_always(get_asset_type(placeholder) == changed.type); + auto bad_path = records_array(changed); + bad_path[(usize)0]["path"] = "../Escape"; + write_snapshot(root, bad_path); + expect_error(reload_asset_database(database), E_BAD_DATA); + luassert_always(get_asset_type(placeholder) == changed.type); + auto bad_guid = records_array(changed); + bad_guid[(usize)0]["guid"] = Variant((u64)7); + write_snapshot(root, bad_guid); + expect_error(reload_asset_database(database), E_BAD_DATA); + bad_guid[(usize)0]["guid"] = Variant(VariantType::array); + bad_guid[(usize)0]["guid"].push_back(Variant((u64)7)); + write_snapshot(root, bad_guid); + expect_error(reload_asset_database(database), E_BAD_DATA); + put(child(root, "assets.db"), "{\"format_version\":4294967297,\"assets\":[]}"); + expect_error(reload_asset_database(database), E_BAD_DATA); + luassert_always(get_asset_type(placeholder) == changed.type); + auto main_only = records_array(changed); + main_only[(usize)0].erase("data_units"); + write_snapshot(root, main_only); + luassert_always(must(database->read_snapshot())[0].data_units.empty()); + write_snapshot(root, Variant(VariantType::array)); + lupanic_if_failed(reload_asset_database(database)); + luassert_always(get_asset_type(placeholder).empty()); + expect_error(get_asset_by_path(child(root, "Characters/Hero")), E_NOT_FOUND); + lupanic_if_failed(unregister_asset_database(database)); + luassert_always(get_asset(hero.guid) == placeholder); + puts("Metadata-only discovery, batch validation and reload passed."); +} + +static void maintenance_and_failures(CountingFileSystem* storage) +{ + Path root("/DatabaseTest/writable"); + lupanic_if_failed(VFS::create_dir(root)); + auto database = must(create_file_database(root)); + lupanic_if_failed(register_asset_database(database)); + // This base name also matches assets.db: the shared metadata file must never be payload. + auto asset = must(new_asset(child(root, "assets"), "DatabaseTest.Type")); + put(child(root, "assets.payload"), "original payload"); + luassert_always(!exists(child(root, "assets.meta"))); + Vector files; + lupanic_if_failed(get_asset_files(asset, files)); + luassert_always(files.size() == 1 && files[0] == "assets.payload"); + String old_database = text_at(child(root, "assets.db")); + storage->fail_publish = true; + expect_error(database->flush(), E_IO_ERROR); + luassert_always(database->is_dirty()); + luassert_always(!text_at(child(root, "assets.db")).compare(old_database)); + storage->fail_publish = false; + storage->reject_moves = true; + expect_error(database->flush(), E_NOT_SUPPORTED); + luassert_always(!text_at(child(root, "assets.db")).compare(old_database)); + storage->reject_moves = false; + lupanic_if_failed(database->flush()); + lupanic_if_failed(move_asset(asset, child(root, "Moved"))); + expect_error(database->read_record("assets"), E_META_FILE_NOT_FOUND); + luassert_always(must(database->read_record("Moved")).guid == get_asset_guid(asset)); + auto copy = must(copy_asset(asset, child(root, "Copied"))); + luassert_always(get_asset_guid(copy) != get_asset_guid(asset)); + luassert_always(!text_at(child(root, "Copied.payload")).compare("original payload")); + lupanic_if_failed(delete_asset(copy)); + luassert_always(exists(child(root, "assets.db"))); + + Path loose("/DatabaseTest/loose"); + lupanic_if_failed(VFS::create_dir(loose)); + auto sidecar = must(new_sidecar_database(loose)); + lupanic_if_failed(register_asset_database(sidecar)); + Guid guid = get_asset_guid(asset); + // Failure after payload transfer must restore both the source payload and registry path. + storage->fail_sidecar_publish = true; + expect_error(move_asset(asset, child(loose, "Hero")), E_IO_ERROR); + expect_error(copy_asset(asset, child(loose, "Copy")), E_IO_ERROR); + luassert_always(get_asset_path(asset) == child(root, "Moved")); + luassert_always(must(database->read_record("Moved")).guid == guid); + luassert_always(exists(child(root, "Moved.payload"))); + luassert_always(!exists(child(loose, "Hero.payload")) && !exists(child(loose, "Copy.payload"))); + luassert_always(!exists(child(loose, "Hero.meta")) && !exists(child(loose, "Copy.meta"))); + storage->fail_sidecar_publish = false; + lupanic_if_failed(move_asset(asset, child(loose, "Hero"))); + luassert_always(exists(child(loose, "Hero.meta"))); + expect_error(database->read_record("Moved"), E_META_FILE_NOT_FOUND); + lupanic_if_failed(move_asset(asset, child(root, "Returned"))); + luassert_always(!exists(child(loose, "Hero.meta"))); + luassert_always(get_asset_guid(asset) == guid); + // Explicit path edits must relocate the stored record on save. + lupanic_if_failed(VFS::move_file(child(root, "Returned.payload"), child(root, "Renamed.payload"))); + lupanic_if_failed(set_asset_path(asset, child(root, "Renamed"))); + lupanic_if_failed(save_asset_meta(asset)); + expect_error(database->read_record("Returned"), E_META_FILE_NOT_FOUND); + luassert_always(must(database->read_record("Renamed")).guid == guid); + lupanic_if_failed(flush_asset_databases()); + lupanic_if_failed(unregister_asset_database(sidecar)); + lupanic_if_failed(unregister_asset_database(database)); + + auto readonly = must(open_file_database(root)); + lupanic_if_failed(register_asset_database(readonly)); + asset = must(get_asset_by_path(child(root, "Renamed"))); + expect_error(new_asset(child(root, "Forbidden"), "DatabaseTest.Type"), E_NOT_SUPPORTED); + expect_error(save_asset_meta(asset), E_NOT_SUPPORTED); + expect_error(move_asset(asset, child(root, "Forbidden")), E_NOT_SUPPORTED); + expect_error(delete_asset(asset), E_NOT_SUPPORTED); + luassert_always(!text_at(child(root, "Renamed.payload")).compare("original payload")); + auto readonly_copy = must(copy_asset(asset, child(loose, "ReadOnlyCopy"))); + lupanic_if_failed(delete_asset(readonly_copy)); + auto path_only = must(new_asset(Path(), "DatabaseTest.Type", false)); + lupanic_if_failed(set_asset_path(path_only, child(root, "PathOnly"))); + put(child(root, "PathOnly.payload"), "preserve despite unsaved runtime path"); + expect_error(delete_asset(path_only), E_NOT_SUPPORTED); + expect_error(move_asset(path_only, child(loose, "PathOnly")), E_NOT_SUPPORTED); + luassert_always(exists(child(root, "PathOnly.payload"))); + lupanic_if_failed(set_asset_path(path_only, Path())); + lupanic_if_failed(VFS::delete_file(child(root, "PathOnly.payload"))); + lupanic_if_failed(unregister_asset_database(readonly)); + puts("Metadata maintenance, safe replacement, retry and read-only access passed."); +} + +static void conflicts_and_conversion(CountingFileSystem* storage) +{ + auto first = record("First"); + auto database = seed_database("/DatabaseTest/owner", first); + lupanic_if_failed(register_asset_database(database)); + auto second = record("Second"); + second.guid = first.guid; + auto conflict = seed_database("/DatabaseTest/conflict", second); + expect_error(register_asset_database(conflict), E_ALREADY_EXISTS); + expect_error(get_asset_by_path("/DatabaseTest/conflict/Second"), E_NOT_FOUND); + lupanic_if_failed(unregister_asset_database(database)); + + Path source_root("/DatabaseTest/convert"); + Path destination_root("/DatabaseTest/exported"); + lupanic_if_failed(VFS::create_dir(source_root)); + lupanic_if_failed(VFS::create_dir(child(source_root, "Nested"))); + lupanic_if_failed(VFS::create_dir(destination_root)); + auto sidecar = must(new_sidecar_database(source_root)); + auto original = record("Nested/Legacy"); + lupanic_if_failed(sidecar->write_record(original)); + auto before = text_at(child(source_root, "Nested/Legacy.meta")); + lupanic_if_failed(export_asset_database(sidecar, destination_root)); + expect_error(export_asset_database(sidecar, destination_root), E_ALREADY_EXISTS); + luassert_always(!text_at(child(source_root, "Nested/Legacy.meta")).compare(before)); + auto exported = must(open_file_database(destination_root)); + auto saved = must(exported->read_record(original.path)); + luassert_always(saved.guid == original.guid && saved.data_units.size() == original.data_units.size()); + lupanic_if_failed(register_asset_database(exported)); + luassert_always(get_asset_guid(must(get_asset_by_path("/DatabaseTest/exported/Nested/Legacy"))) == original.guid); + lupanic_if_failed(unregister_asset_database(exported)); + // An empty relative VFS root covers relative paths, but never owns dynamic assets without paths. + lupanic_if_failed(VFS::mount(storage, Path())); + auto relative = must(open_file_database(Path(), "exported/assets.db")); + lupanic_if_failed(register_asset_database(relative)); + luassert_always(get_asset_guid(must(get_asset_by_path("Nested/Legacy"))) == original.guid); + auto dynamic_asset = must(new_asset(Path(), "DatabaseTest.Type", false)); + lupanic_if_failed(unregister_asset_database(relative)); + luassert_always(get_asset_type(dynamic_asset) == "DatabaseTest.Type"); + lupanic_if_failed(VFS::unmount(Path())); + puts("Database ownership, independent export and relative roots passed."); +} + +static void pak_round_trip(const c8* native_directory) +{ + auto source = must(open_file_database("/DatabaseTest/exported")); + auto records = must(source->get_records()); + auto package = must(Pak::new_pak()); + { + auto input = must(VFS::open_file("/DatabaseTest/exported/assets.db", FileOpenFlag::read, FileCreationMode::open_existing)); + auto bytes = must(load_file_data(input)); + auto output = must(package->open_file("assets.db", FileOpenFlag::write, FileCreationMode::create_new)); + lupanic_if_failed(output->write(bytes.data(), bytes.size())); + } + Path native_path = child(Path(native_directory), "game.pak"); + { + auto output = must(Luna::open_file(native_path.encode().c_str(), FileOpenFlag::read | FileOpenFlag::write, FileCreationMode::create_new)); + lupanic_if_failed(package->flush(output)); + } + package = nullptr; + auto file_system = must(VFS::new_pak_file_system(native_path.encode().c_str(), Pak::OpenMode::read_write)); + lupanic_if_failed(VFS::mount(file_system, "/Packed")); + auto database = must(open_file_database("/Packed", "assets.db", DatabaseMode::read_write)); + lupanic_if_failed(register_asset_database(database)); + luassert_always(get_asset_guid(must(get_asset_by_path("/Packed/Nested/Legacy"))) == records[0].guid); + auto added = must(new_asset("/Packed/Added", "DatabaseTest.Type")); + put("/Packed/Added.payload", "package payload"); + luassert_always(!exists("/Packed/Added.meta")); + { + auto reader = must(VFS::open_file("/Packed/assets.db", FileOpenFlag::read, FileCreationMode::open_existing)); + expect_error(database->flush(), E_BUSY); + luassert_always(database->is_dirty()); + } + lupanic_if_failed(database->flush()); + // Database flush updates the mounted view; the native package is still the previous version. + { + auto input = must(Luna::open_file(native_path.encode().c_str(), FileOpenFlag::read, FileCreationMode::open_existing)); + auto old = must(Pak::open_pak(input)); + expect_error(old->get_file_attribute("Added.payload"), E_NOT_FOUND); + } + lupanic_if_failed(file_system->flush()); + lupanic_if_failed(unregister_asset_database(database)); + database = nullptr; + lupanic_if_failed(VFS::unmount("/Packed")); + file_system = nullptr; + file_system = must(VFS::new_pak_file_system(native_path.encode().c_str())); + lupanic_if_failed(VFS::mount(file_system, "/Relocated")); + database = must(open_file_database("/Relocated")); + lupanic_if_failed(register_asset_database(database)); + luassert_always(must(get_asset_by_path("/Relocated/Added")) == added); + luassert_always(!text_at("/Relocated/Added.payload").compare("package payload")); + lupanic_if_failed(unregister_asset_database(database)); + database = nullptr; + lupanic_if_failed(VFS::unmount("/Relocated")); + puts("Pak database persistence, flush ordering and mount relocation passed."); +} + +int main() +{ + lupanic_if_failed(Luna::init()); + lupanic_if_failed(add_modules({module_asset()})); + lupanic_if_failed(init_modules()); + Meta::register_AssetDatabaseTest_types(); + AssetLoaderDesc loader; + loader.name = "DatabaseTest.Loader"; + loader.on_load_asset_data_unit_default_data = [](object_t, asset_t, const Name&) -> R + { return ObjRef(new_object()); }; + register_asset_loader(loader); + register_asset_type({Name("DatabaseTest.Type"), loader.name}); + c8 guid[GUID_STRING_LENGTH + 1]{}; + lupanic_if_failed(encode_guid(random_guid(), guid, GUID_STRING_LENGTH)); + String directory("AssetDatabaseTest-"); + directory.append(guid); + lupanic_if_failed(Luna::create_dir(directory.c_str())); + { + auto storage = new_object(); + storage->storage = must(VFS::new_native_file_system(directory.c_str())); + lupanic_if_failed(VFS::mount(storage, "/DatabaseTest")); + discovery_and_reload(storage); + maintenance_and_failures(storage); + conflicts_and_conversion(storage); + pak_round_trip(directory.c_str()); + // Metadata shutdown flushes registered dirty databases before VFS shutdown. + Path closing_root("/DatabaseTest/closing"); + lupanic_if_failed(VFS::create_dir(closing_root)); + auto closing = must(create_file_database(closing_root)); + lupanic_if_failed(register_asset_database(closing)); + must(new_asset(child(closing_root, "SavedAtClose"), "DatabaseTest.Type")); + Asset::close(); + auto reopened = must(open_file_database(closing_root)); + must(reopened->read_record("SavedAtClose")); + remove_tree("/DatabaseTest/discovery"); + remove_tree("/DatabaseTest/writable"); + remove_tree("/DatabaseTest/loose"); + remove_tree("/DatabaseTest/owner"); + remove_tree("/DatabaseTest/conflict"); + remove_tree("/DatabaseTest/convert"); + remove_tree("/DatabaseTest/exported"); + remove_tree(closing_root); + lupanic_if_failed(VFS::delete_file("/DatabaseTest/game.pak")); + lupanic_if_failed(VFS::unmount("/DatabaseTest")); + } + lupanic_if_failed(Luna::delete_file(directory.c_str())); + Luna::close(); + puts("AssetDatabaseTest passed."); + return 0; +} diff --git a/Tests/AssetDatabaseTest/Source/TestTypes.hpp b/Tests/AssetDatabaseTest/Source/TestTypes.hpp new file mode 100644 index 00000000..ff2eff53 --- /dev/null +++ b/Tests/AssetDatabaseTest/Source/TestTypes.hpp @@ -0,0 +1,52 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file TestTypes.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include +#include "TestTypes.generated.hpp" + +namespace Luna +{ + struct [[Luna::struct("{F47D6E2C-D71B-44F7-B10C-9C9230BC53D7}")]] CountingFileSystem : VFS::IFileSystem + { + luiimpl(); + Ref storage; + usize read_files = 0; + usize directories = 0; + bool fail_publish = false; + bool fail_sidecar_publish = false; + bool reject_moves = false; + R> open_file(const Path& path, FileOpenFlag flags, FileCreationMode mode) override + { + if(test_flags(flags, FileOpenFlag::read)) ++read_files; + return storage->open_file(path, flags, mode); + } + R get_file_attribute(const Path& path) override { return storage->get_file_attribute(path); } + R> open_dir(const Path& path) override { ++directories; return storage->open_dir(path); } + RV move_file(const Path& from, const Path& to, VFS::IFileSystem* target, FileMoveFlag flags) override + { + if(reject_moves || (target && target != this)) return E_NOT_SUPPORTED; + if(fail_publish && test_flags(flags, FileMoveFlag::allow_overwrite)) return E_IO_ERROR; + if(fail_sidecar_publish && to.extension() == "meta") return E_IO_ERROR; + return storage->move_file(from, to, nullptr, flags); + } + RV copy_file(const Path& from, const Path& to, VFS::IFileSystem* target) override + { + if(target && target != this) return E_NOT_SUPPORTED; + return storage->copy_file(from, to); + } + RV delete_file(const Path& path) override { return storage->delete_file(path); } + RV create_dir(const Path& path) override { return storage->create_dir(path); } + RV flush() override { return storage->flush(); } + }; + struct [[Luna::struct("{06CD0C16-BF28-495D-BB82-BED90A55AE98}")]] DatabaseTestData + { + i32 value = 42; + }; +} diff --git a/Tests/AssetTest/Source/Main.cpp b/Tests/AssetTest/Source/Main.cpp index 64e16bd6..c79e84e9 100644 --- a/Tests/AssetTest/Source/Main.cpp +++ b/Tests/AssetTest/Source/Main.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include "AssetTest.meta.generated.hpp" @@ -747,8 +748,11 @@ int main() Meta::register_AssetTest_types(); String native_test_directory = create_native_test_directory(); - lupanic_if_failed(VFS::mount(VFS::get_platform_filesystem_driver(), - native_test_directory.c_str(), TEST_MOUNT_PATH)); + { + auto file_system = VFS::new_native_file_system(native_test_directory.c_str()); + lupanic_if_failed(file_system); + lupanic_if_failed(VFS::mount(file_system.get(), TEST_MOUNT_PATH)); + } register_test_asset_type(); data_unit_lifecycle_test(); diff --git a/Tests/EditorGUITest/Source/EditorGUITest.cpp b/Tests/EditorGUITest/Source/EditorGUITest.cpp index 2c874e94..3be5c567 100644 --- a/Tests/EditorGUITest/Source/EditorGUITest.cpp +++ b/Tests/EditorGUITest/Source/EditorGUITest.cpp @@ -248,12 +248,391 @@ namespace return ok; } + void verify_input_text_direction_navigation(GUI::IContext* context) + { + String value("\xE7\x94\xB2\xE4\xB9\x99"); + auto build_frame = [&]() + { + GUI::FrameDesc frame; + frame.logical_size = Float2U(320.0f, 120.0f); + frame.render_size = UInt2U(320, 120); + frame.delta_time = 1.0f / 60.0f; + context->begin_frame(frame); + context->push_layer(1, Float2U(0.0f)); + GUI::LayoutConfig root_layout; + root_layout.width.kind = GUI::SizeKind::percent; + root_layout.width.value = 1.0f; + root_layout.height.kind = GUI::SizeKind::percent; + root_layout.height.value = 1.0f; + GUI::ElementHandle root = EditorGUI::begin_v_layout( + context, context->make_id("input.navigation.test.root"), + "Input navigation test", root_layout); + GUI::LayoutConfig control_layout; + control_layout.width.kind = GUI::SizeKind::percent; + control_layout.width.value = 1.0f; + control_layout.height.kind = GUI::SizeKind::fixed; + control_layout.height.value = 40.0f; + GUI::ElementHandle input = EditorGUI::input_text( + context, context->make_id("input.navigation.test.value"), value, + control_layout); + EditorGUI::text_button(context, context->make_id("input.navigation.test.sibling"), + "Sibling", control_layout); + GUI::FlexLayoutDesc flex; + flex.axis = GUI::LayoutAxis::y; + flex.cross_alignment = GUI::FlexAlignment::stretch; + EditorGUI::end_v_layout(context, root, flex); + context->pop_layer(); + lupanic_if_failed(EditorGUI::layout_tree( + context, root, RectF(0.0f, 0.0f, 320.0f, 120.0f))); + return input; + }; + + GUI::ElementHandle input = build_frame(); + GUI::NavigationConfig navigation = context->get_navigation_config(input); + luassert(navigation.left == GUI::NavigationMode::callback); + luassert(navigation.right == GUI::NavigationMode::callback); + luassert(navigation.up == GUI::NavigationMode::callback); + luassert(navigation.down == GUI::NavigationMode::callback); + luassert(navigation.forward == GUI::NavigationMode::automatic); + context->focus_element(input.id); + context->route_input(); + EditorGUI::resolve_interactions(context); + + auto apply_direction = [&](GUI::NavigationDirection direction, const c8* text) + { + input = build_frame(); + GUI::InputEvent navigation_event; + navigation_event.type = GUI::InputEventType::navigation_dpad; + navigation_event.navigation_direction = direction; + context->add_input_event(navigation_event); + GUI::InputEvent event; + event.type = GUI::InputEventType::text_utf8; + event.text = text; + context->add_input_event(event); + context->route_input(); + EditorGUI::resolve_interactions(context); + luassert(context->focused_element() == input.id); + }; + + apply_direction(GUI::NavigationDirection::right, "X"); + apply_direction(GUI::NavigationDirection::up, "Y"); + apply_direction(GUI::NavigationDirection::down, "Z"); + apply_direction(GUI::NavigationDirection::left, "W"); + luassert(!strcmp(value.c_str(), "Y\xE7\x94\xB2X\xE4\xB9\x99WZ")); + + input = build_frame(); + GUI::InputEvent home; + home.type = GUI::InputEventType::key_down; + home.key = KeyCode::home; + context->add_input_event(home); + GUI::InputEvent home_text; + home_text.type = GUI::InputEventType::text_utf8; + home_text.text = "H"; + context->add_input_event(home_text); + context->route_input(); + EditorGUI::resolve_interactions(context); + luassert(context->focused_element() == input.id); + luassert(!strcmp(value.c_str(), "Y\xE7\x94\xB2X\xE4\xB9\x99WHZ")); + + input = build_frame(); + GUI::InputEvent end; + end.type = GUI::InputEventType::key_down; + end.key = KeyCode::end; + context->add_input_event(end); + GUI::InputEvent end_text; + end_text.type = GUI::InputEventType::text_utf8; + end_text.text = "E"; + context->add_input_event(end_text); + context->route_input(); + EditorGUI::resolve_interactions(context); + luassert(context->focused_element() == input.id); + luassert(!strcmp(value.c_str(), "Y\xE7\x94\xB2X\xE4\xB9\x99WHEZ")); + context->focus_element(0); + } + + void verify_drag_direct_input(GUI::IContext* context) + { + f32 value = 12.5f; + GUI::FrameDesc frame; + frame.logical_size = Float2U(200.0f, 60.0f); + frame.render_size = UInt2U(200, 60); + frame.delta_time = 1.0f / 60.0f; + GUI::id_t drag_id = context->make_id("drag.direct_input.value"); + auto build_frame = [&]() + { + context->begin_frame(frame); + context->push_layer(context->make_id("drag.direct_input.layer"), Float2U(0.0f)); + GUI::LayoutConfig layout; + layout.width.kind = GUI::SizeKind::fixed; + layout.width.value = 160.0f; + layout.height.kind = GUI::SizeKind::fixed; + layout.height.value = 40.0f; + GUI::ElementHandle drag = EditorGUI::drag_float(context, drag_id, &value, + 0.0f, 100.0f, layout); + context->pop_layer(); + lupanic_if_failed(EditorGUI::layout_tree(context, drag, + RectF(0.0f, 0.0f, 160.0f, 40.0f))); + return drag; + }; + + GUI::ElementHandle drag = build_frame(); + GUI::InputEvent event; + event.type = GUI::InputEventType::pointer_enter; + event.position = Float2U(20.0f, 20.0f); + context->add_input_event(event); + event.type = GUI::InputEventType::pointer_down; + context->add_input_event(event); + event.type = GUI::InputEventType::pointer_up; + context->add_input_event(event); + event.type = GUI::InputEventType::pointer_down; + context->add_input_event(event); + event.type = GUI::InputEventType::pointer_up; + context->add_input_event(event); + context->route_input(); + EditorGUI::resolve_interactions(context); + + drag = build_frame(); + luassert(context->get_layout_callback_config(drag).algorithm == Name("gui.input_text")); + event = GUI::InputEvent(); + event.type = GUI::InputEventType::text_utf8; + event.text = "42.25"; + context->add_input_event(event); + event = GUI::InputEvent(); + event.type = GUI::InputEventType::key_down; + event.key = KeyCode::enter; + context->add_input_event(event); + context->route_input(); + EditorGUI::ResolveResult result = EditorGUI::resolve_interactions(context); + luassert(result.value_changed); + luassert(value == 42.25f); + + drag = build_frame(); + luassert(context->get_layout_callback_config(drag).algorithm == Name("gui.drag")); + context->focus_element(0); + } + + R draw_invalid_returned_paint_order(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, GUI::paint_order_id_t paint_order_id, void*) + { + GUI::DrawCommand command; + command.type = GUI::DrawCommandType::rect; + context->draw(command, paint_order_id + 1); + return paint_order_id; + } + + void verify_paint_order_generation(GUI::IContext* context) + { + GUI::FrameDesc frame; + frame.logical_size = Float2U(640.0f, 240.0f); + frame.render_size = UInt2U(640, 240); + frame.delta_time = 1.0f / 60.0f; + context->begin_frame(frame); + + GUI::LayoutConfig root_layout; + root_layout.width.kind = GUI::SizeKind::fixed; + root_layout.width.value = 240.0f; + root_layout.height.kind = GUI::SizeKind::fixed; + root_layout.height.value = 48.0f; + GUI::LayoutConfig button_layout; + button_layout.width.kind = GUI::SizeKind::fixed; + button_layout.width.value = 96.0f; + button_layout.height.kind = GUI::SizeKind::fixed; + button_layout.height.value = 40.0f; + + const GUI::id_t empty_layer_id = context->make_id("paint_order.empty.layer"); + context->push_layer(empty_layer_id, Float2U(0.0f)); + context->pop_layer(); + + const GUI::id_t shared_layer_id = context->make_id("paint_order.shared.layer"); + const GUI::id_t shared_button_a_id = context->make_id("paint_order.shared.button.a"); + const GUI::id_t shared_button_b_id = context->make_id("paint_order.shared.button.b"); + const GUI::id_t shared_capture_id = context->make_id("paint_order.shared.capture"); + context->push_layer(shared_layer_id, Float2U(0.0f)); + GUI::ElementHandle shared_root = EditorGUI::begin_h_layout(context, + context->make_id("paint_order.shared.root"), "Shared button row", root_layout); + context->set_child_paint_order_mode(shared_root, GUI::ChildPaintOrderMode::shared); + GUI::ElementHandle shared_button_a = EditorGUI::text_button(context, + shared_button_a_id, "First", button_layout); + GUI::ElementHandle shared_button_b = EditorGUI::text_button(context, + shared_button_b_id, "Second", button_layout); + GUI::LayoutConfig empty_element_layout; + empty_element_layout.width.kind = GUI::SizeKind::fixed; + empty_element_layout.height.kind = GUI::SizeKind::fixed; + GUI::ElementHandle shared_capture = context->begin_element(shared_capture_id); + context->set_layout_config(shared_capture, empty_element_layout); + GUI::BackdropBlurCaptureDesc shared_capture_desc; + shared_capture_desc.softness = 1.0f; + context->set_backdrop_blur_capture(shared_capture, shared_capture_desc); + context->end_element(); + GUI::FlexLayoutDesc shared_flex; + shared_flex.main_axis_gap = 8.0f; + shared_flex.cross_alignment = GUI::FlexAlignment::center; + EditorGUI::end_h_layout(context, shared_root, shared_flex); + context->pop_layer(); + + const GUI::id_t sequential_layer_id = context->make_id("paint_order.sequential.layer"); + const GUI::id_t overlap_button_a_id = context->make_id("paint_order.overlap.button.a"); + const GUI::id_t overlap_button_b_id = context->make_id("paint_order.overlap.button.b"); + context->push_layer(sequential_layer_id, Float2U(0.0f, 64.0f)); + GUI::ElementHandle overlap_root = EditorGUI::begin_canvas_layout(context, + context->make_id("paint_order.overlap.root"), "Overlapping buttons", root_layout); + context->set_child_paint_order_mode(overlap_root, GUI::ChildPaintOrderMode::sequential); + GUI::ElementHandle overlap_button_a = EditorGUI::text_button(context, + overlap_button_a_id, "Below", button_layout); + GUI::ElementHandle overlap_button_b = EditorGUI::text_button(context, + overlap_button_b_id, "Above", button_layout); + GUI::CanvasLayoutItem overlap_items[2]; + overlap_items[0].element_id = overlap_button_a_id; + overlap_items[1].element_id = overlap_button_b_id; + GUI::CanvasLayoutDesc overlap_canvas; + overlap_canvas.items = Span(overlap_items, 2); + EditorGUI::end_canvas_layout(context, overlap_root, overlap_canvas); + context->pop_layer(); + + const GUI::id_t capture_layer_id = context->make_id("paint_order.capture.layer"); + context->push_layer(capture_layer_id, Float2U(0.0f, 128.0f)); + GUI::ElementHandle captured_button = EditorGUI::text_button(context, + context->make_id("paint_order.capture.button"), "Captured", button_layout); + GUI::BackdropBlurCaptureDesc capture; + capture.softness = 4.0f; + context->set_backdrop_blur_capture(captured_button, capture); + context->pop_layer(); + + lupanic_if_failed(EditorGUI::layout_tree(context, shared_root, + RectF(0.0f, 0.0f, 240.0f, 48.0f))); + lupanic_if_failed(EditorGUI::layout_tree(context, overlap_root, + RectF(0.0f, 0.0f, 240.0f, 48.0f))); + lupanic_if_failed(EditorGUI::layout_tree(context, captured_button, + RectF(0.0f, 0.0f, 96.0f, 40.0f))); + lupanic_if_failed(context->generate_draw_commands()); + + GUI::ElementHandle shared_text_a = context->find_element_handle( + GUI::make_scoped_id(shared_button_a_id, "text")); + GUI::ElementHandle shared_text_b = context->find_element_handle( + GUI::make_scoped_id(shared_button_b_id, "text")); + GUI::ElementHandle overlap_text_a = context->find_element_handle( + GUI::make_scoped_id(overlap_button_a_id, "text")); + GUI::paint_order_id_t shared_surface_a[2] = { + GUI::INVALID_PAINT_ORDER_ID, GUI::INVALID_PAINT_ORDER_ID }; + GUI::paint_order_id_t shared_surface_b[2] = { + GUI::INVALID_PAINT_ORDER_ID, GUI::INVALID_PAINT_ORDER_ID }; + GUI::paint_order_id_t shared_text_a_order = GUI::INVALID_PAINT_ORDER_ID; + GUI::paint_order_id_t shared_text_b_order = GUI::INVALID_PAINT_ORDER_ID; + GUI::paint_order_id_t overlap_a_max = GUI::INVALID_PAINT_ORDER_ID; + GUI::paint_order_id_t overlap_b_first = GUI::INVALID_PAINT_ORDER_ID; + GUI::paint_order_id_t shared_capture_order = GUI::INVALID_PAINT_ORDER_ID; + GUI::paint_order_id_t capture_order = GUI::INVALID_PAINT_ORDER_ID; + GUI::paint_order_id_t captured_surface_order = GUI::INVALID_PAINT_ORDER_ID; + u32 shared_surface_a_count = 0; + u32 shared_surface_b_count = 0; + for(const GUI::DrawCommand& command : context->get_draw_commands()) + { + if(command.element == shared_button_a.index && command.type == GUI::DrawCommandType::sdf && + shared_surface_a_count < 2) + shared_surface_a[shared_surface_a_count++] = command.paint_order_id; + if(command.element == shared_button_b.index && command.type == GUI::DrawCommandType::sdf && + shared_surface_b_count < 2) + shared_surface_b[shared_surface_b_count++] = command.paint_order_id; + if(command.element == shared_text_a.index && command.type == GUI::DrawCommandType::text) + shared_text_a_order = command.paint_order_id; + if(command.element == shared_text_b.index && command.type == GUI::DrawCommandType::text) + shared_text_b_order = command.paint_order_id; + if((command.element == overlap_button_a.index || command.element == overlap_text_a.index) && + (overlap_a_max == GUI::INVALID_PAINT_ORDER_ID || command.paint_order_id > overlap_a_max)) + overlap_a_max = command.paint_order_id; + if(command.element == overlap_button_b.index && command.type == GUI::DrawCommandType::sdf && + overlap_b_first == GUI::INVALID_PAINT_ORDER_ID) + overlap_b_first = command.paint_order_id; + if(command.element == shared_capture.index && + command.type == GUI::DrawCommandType::backdrop_blur_capture) + shared_capture_order = command.paint_order_id; + if(command.element == captured_button.index && + command.type == GUI::DrawCommandType::backdrop_blur_capture) + capture_order = command.paint_order_id; + if(command.element == captured_button.index && command.type == GUI::DrawCommandType::sdf && + captured_surface_order == GUI::INVALID_PAINT_ORDER_ID) + captured_surface_order = command.paint_order_id; + } + luassert(shared_surface_a_count == 2 && shared_surface_b_count == 2); + luassert(shared_surface_a[0] == shared_surface_b[0]); + luassert(shared_surface_a[1] == shared_surface_b[1]); + luassert(shared_text_a_order == shared_text_b_order); + luassert(shared_surface_a[0] < shared_surface_a[1] && + shared_surface_a[1] < shared_text_a_order); + luassert(shared_capture_order == shared_text_a_order + 1); + u32 shared_capture_event_count = 0; + for(const GUI::DrawCommand& command : context->get_draw_commands()) + { + if(command.paint_order_id == shared_capture_order) ++shared_capture_event_count; + } + luassert(shared_capture_event_count == 1); + luassert(overlap_a_max != GUI::INVALID_PAINT_ORDER_ID && + overlap_b_first == overlap_a_max + 1); + + const GUI::Layer* empty_layer = nullptr; + const GUI::Layer* shared_layer = nullptr; + const GUI::Layer* sequential_layer = nullptr; + const GUI::Layer* capture_layer = nullptr; + for(const GUI::Layer& layer : context->get_layers()) + { + if(layer.id == empty_layer_id) empty_layer = &layer; + else if(layer.id == shared_layer_id) shared_layer = &layer; + else if(layer.id == sequential_layer_id) sequential_layer = &layer; + else if(layer.id == capture_layer_id) capture_layer = &layer; + } + luassert(empty_layer && shared_layer && sequential_layer && capture_layer); + luassert(empty_layer->first_paint_order_id == GUI::INVALID_PAINT_ORDER_ID && + empty_layer->max_paint_order_id == GUI::INVALID_PAINT_ORDER_ID); + luassert(shared_layer->first_paint_order_id == 0); + luassert(sequential_layer->first_paint_order_id == shared_layer->max_paint_order_id + 1); + luassert(capture_layer->first_paint_order_id == sequential_layer->max_paint_order_id + 1); + luassert(capture_order == capture_layer->first_paint_order_id); + luassert(captured_surface_order == capture_order + 1); + + luassert(context->bring_layer_to_front(shared_layer_id)); + lupanic_if_failed(context->generate_draw_commands()); + empty_layer = nullptr; + shared_layer = nullptr; + sequential_layer = nullptr; + capture_layer = nullptr; + for(const GUI::Layer& layer : context->get_layers()) + { + if(layer.id == empty_layer_id) empty_layer = &layer; + else if(layer.id == shared_layer_id) shared_layer = &layer; + else if(layer.id == sequential_layer_id) sequential_layer = &layer; + else if(layer.id == capture_layer_id) capture_layer = &layer; + } + luassert(empty_layer && shared_layer && sequential_layer && capture_layer); + luassert(empty_layer->first_paint_order_id == GUI::INVALID_PAINT_ORDER_ID && + empty_layer->max_paint_order_id == GUI::INVALID_PAINT_ORDER_ID); + luassert(sequential_layer->first_paint_order_id == 0); + luassert(capture_layer->first_paint_order_id == sequential_layer->max_paint_order_id + 1); + luassert(shared_layer->first_paint_order_id == capture_layer->max_paint_order_id + 1); + + context->begin_frame(frame); + context->push_layer(context->make_id("paint_order.invalid.layer"), Float2U(0.0f)); + GUI::ElementHandle invalid_element = context->begin_element( + context->make_id("paint_order.invalid.element")); + context->set_layout_config(invalid_element, button_layout); + GUI::DrawConfig invalid_draw; + invalid_draw.callback = draw_invalid_returned_paint_order; + context->set_draw_config(invalid_element, invalid_draw); + context->end_element(); + context->pop_layer(); + lupanic_if_failed(EditorGUI::layout_tree(context, invalid_element, + RectF(0.0f, 0.0f, 96.0f, 40.0f))); + luassert(failed(context->generate_draw_commands())); + } + RV run_demo(const DemoOptions& options) { lutry { DemoApp app; luexp(init_demo(app, options)); + verify_input_text_direction_navigation(app.gui); + verify_drag_direct_input(app.gui); + verify_paint_order_generation(app.gui); GUIWindow::GUIWindowInputAdapter input_adapter; input_adapter.window = app.window; input_adapter.gui = app.gui; diff --git a/Tests/EditorGUITest/Source/Showcase.cpp b/Tests/EditorGUITest/Source/Showcase.cpp index 0b3a9b09..8b2298ff 100644 --- a/Tests/EditorGUITest/Source/Showcase.cpp +++ b/Tests/EditorGUITest/Source/Showcase.cpp @@ -117,7 +117,115 @@ namespace Luna GUI::style_f32(fallback)).number.x; } - void rounded_rect(GUI::IContext* context, const Float4U& color, f32 radius, + R draw_showcase_steps(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata) + { + ShowcaseDrawData* data = (ShowcaseDrawData*)userdata; + const Vector& steps = phase == GUI::DrawPhase::before_children ? + data->before_children : data->after_children; + GUI::paint_order_id_t max_paint_order_id = paint_order_id; + bool has_output = false; + for(const ShowcaseDrawStep& step : steps) + { + GUI::paint_order_id_t step_paint_order_id = has_output ? max_paint_order_id + 1 : + paint_order_id; + if(step.is_callback) + { + R result = step.callback(context, element, phase, + step_paint_order_id, step.userdata); + if(failed(result)) return result.errcode(); + max_paint_order_id = max(step_paint_order_id, result.get()); + } + else + { + context->draw(step.command, step_paint_order_id); + max_paint_order_id = step_paint_order_id; + } + has_output = true; + } + return max_paint_order_id; + } + + void refresh_showcase_draw_config(GUI::IContext* context, + const GUI::ElementHandle& element, ShowcaseDrawData* data) + { + GUI::DrawConfig draw; + draw.name = Name("guitest.composed_draw"); + draw.callback = draw_showcase_steps; + draw.userdata = data; + u8 phases = 0; + if(!data->before_children.empty()) phases |= (u8)GUI::DrawPhaseFlag::before_children; + if(!data->after_children.empty()) phases |= (u8)GUI::DrawPhaseFlag::after_children; + draw.phases = (GUI::DrawPhaseFlag)phases; + context->set_draw_config(element, draw); + } + + ShowcaseDrawData* showcase_draw_data(GUI::IContext* context, ShowcaseState& state, + const GUI::ElementHandle& element) + { + GUI::DrawConfig existing = context->get_draw_config(element); + if(existing.callback == draw_showcase_steps) + { + return (ShowcaseDrawData*)existing.userdata; + } + + UniquePtr owned(memnew()); + ShowcaseDrawData* data = owned.get(); + if(existing.callback) + { + ShowcaseDrawStep step; + step.callback = existing.callback; + step.userdata = existing.userdata; + step.is_callback = true; + if(test_flags(existing.phases, GUI::DrawPhaseFlag::before_children)) + { + data->before_children.push_back(step); + } + if(test_flags(existing.phases, GUI::DrawPhaseFlag::after_children)) + { + data->after_children.push_back(step); + } + } + state.frame_draw_data.push_back(move(owned)); + refresh_showcase_draw_config(context, element, data); + return data; + } + + void append_showcase_draw_command(GUI::IContext* context, ShowcaseState& state, + const GUI::ElementHandle& element, const GUI::DrawCommand& command, + GUI::DrawPhase phase = GUI::DrawPhase::before_children) + { + ShowcaseDrawData* data = showcase_draw_data(context, state, element); + ShowcaseDrawStep step; + step.command = command; + Vector& steps = phase == GUI::DrawPhase::before_children ? + data->before_children : data->after_children; + steps.push_back(move(step)); + refresh_showcase_draw_config(context, element, data); + } + + void append_showcase_draw_callback(GUI::IContext* context, ShowcaseState& state, + const GUI::ElementHandle& element, const GUI::DrawConfig& config) + { + ShowcaseDrawData* data = showcase_draw_data(context, state, element); + ShowcaseDrawStep step; + step.callback = config.callback; + step.userdata = config.userdata; + step.is_callback = true; + if(test_flags(config.phases, GUI::DrawPhaseFlag::before_children)) + { + data->before_children.push_back(step); + } + if(test_flags(config.phases, GUI::DrawPhaseFlag::after_children)) + { + data->after_children.push_back(step); + } + refresh_showcase_draw_config(context, element, data); + } + + void rounded_rect(GUI::IContext* context, ShowcaseState& state, + const GUI::ElementHandle& element, const Float4U& color, f32 radius, const RectF& rect = RectF()) { GUI::DrawCommand command; @@ -126,12 +234,12 @@ namespace Luna command.rect = rect; command.color = color; command.radius = radius; - context->draw(command); + append_showcase_draw_command(context, state, element, command); } - void rounded_shadow(GUI::IContext* context, const Float4U& color, f32 radius, - const Float2U& offset, f32 softness, GUI::ShadowMode mode, - const RectF& rect = RectF()) + void rounded_shadow(GUI::IContext* context, ShowcaseState& state, + const GUI::ElementHandle& element, const Float4U& color, f32 radius, + const Float2U& offset, f32 softness, GUI::ShadowMode mode, const RectF& rect = RectF()) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::shadow; @@ -142,14 +250,15 @@ namespace Luna command.shadow.offset = offset; command.shadow.softness = softness; command.shadow.mode = mode; - context->draw(command); + append_showcase_draw_command(context, state, element, command); } - RV draw_sdf_swatch(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + R draw_sdf_swatch(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { const GUI::Element* element_data = context->get_element(element.index); - if(!element_data) return ok; + if(!element_data) return paint_order_id; const RectF& element_rect = element_data->layout_result.rect; f32 width = max(element_rect.width, 1.0f); f32 height = max(element_rect.height, 1.0f); @@ -195,7 +304,7 @@ namespace Luna command.rect_reference = GUI::DrawCommandRectReference::element; command.sdf.shape = shape.get(); command.sdf.color = color.get(); - context->draw(command); + context->draw(command, paint_order_id); return ok; }; @@ -220,19 +329,22 @@ namespace Luna Float2U(width * 0.5f, height * 0.5f), -PI_DIV_TWO, Span(stops, 3)); } - return submit_color(color_floats); + RV result = submit_color(color_floats); + if(failed(result)) return result.errcode(); + return paint_order_id; } - RV finish_rounded_children_clip(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + R finish_rounded_children_clip(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { f32 radius = (f32)(usize)userdata; GUI::DrawCommand pop_clip; pop_clip.type = GUI::DrawCommandType::pop_clip; - context->draw(pop_clip); + context->draw(pop_clip, paint_order_id); const GUI::Element* element_data = context->get_element(element.index); - if(!element_data) return ok; + if(!element_data) return paint_order_id; const RectF& rect = element_data->layout_result.rect; f32 width = max(rect.width, 1.0f); f32 height = max(rect.height, 1.0f); @@ -248,7 +360,7 @@ namespace Luna command.rect_reference = GUI::DrawCommandRectReference::element; command.sdf.shape = shape.get(); command.sdf.color = color.get(); - context->draw(command); + context->draw(command, paint_order_id + 1); return ok; }; @@ -259,7 +371,9 @@ namespace Luna Vector border_color; GUI::sdf_color_add_solid(border_color, style_color(context, "gui.border.strong"), GUI::SDFClipDesc::stroke(1.0f)); - return submit(border_shape, border_color); + RV result = submit(border_shape, border_color); + if(failed(result)) return result.errcode(); + return paint_order_id + 1; } GUI::ElementHandle sdf_swatch(GUI::IContext* context, EditorGUI::id_t element_id, @@ -275,7 +389,8 @@ namespace Luna return swatch; } - void surface(GUI::IContext* context, const c8* background, f32 radius, + void surface(GUI::IContext* context, ShowcaseState& state, + const GUI::ElementHandle& element, const c8* background, f32 radius, bool raised = false, bool strong_elevation = false) { if(raised) @@ -288,16 +403,16 @@ namespace Luna if(!strong_elevation) shadow.color.w *= 0.70f; shadow.shadow.offset = strong_elevation ? Float2U(7.0f, 7.0f) : Float2U(4.0f, 5.0f); shadow.shadow.softness = strong_elevation ? 9.0f : 6.0f; - context->draw(shadow); + append_showcase_draw_command(context, state, element, shadow); shadow.color = style_color(context, "gui.shadow.light", Float4U(1.0f, 1.0f, 1.0f, 0.75f)); if(!strong_elevation) shadow.color.w *= 0.78f; shadow.shadow.offset = strong_elevation ? Float2U(-7.0f, -7.0f) : Float2U(-4.0f, -4.0f); shadow.shadow.softness = strong_elevation ? 9.0f : 5.0f; - context->draw(shadow); + append_showcase_draw_command(context, state, element, shadow); } - rounded_rect(context, style_color(context, "gui.border"), radius); - rounded_rect(context, style_color(context, background), max(radius - 1.0f, 0.0f), - RectF(1.0f, 1.0f, -2.0f, -2.0f)); + rounded_rect(context, state, element, style_color(context, "gui.border"), radius); + rounded_rect(context, state, element, style_color(context, background), + max(radius - 1.0f, 0.0f), RectF(1.0f, 1.0f, -2.0f, -2.0f)); } EditorGUI::TextDesc text_desc(GUI::IContext* context, f32 size, const c8* color = "gui.text.color") @@ -334,12 +449,13 @@ namespace Luna return desc; } - void selected_button_surface(GUI::IContext* context) + void selected_button_surface(GUI::IContext* context, ShowcaseState& state, + const GUI::ElementHandle& element) { const f32 radius = style_scalar(context, "gui.button.radius", style_scalar(context, "gui.radius.medium", 9.0f)); - rounded_rect(context, style_color(context, "gui.accent"), radius); - rounded_rect(context, style_color(context, "gui.accent.subtle"), + rounded_rect(context, state, element, style_color(context, "gui.accent"), radius); + rounded_rect(context, state, element, style_color(context, "gui.accent.subtle"), max(radius - 1.0f, 0.0f), RectF(1.0f, 1.0f, -2.0f, -2.0f)); } @@ -359,8 +475,9 @@ namespace Luna EditorGUI::end_h_layout(context, row, flex); } - void tree_label(GUI::IContext* context, EditorGUI::id_t element_id, EditorGUI::IconName icon_name, - const c8* value, i32 depth, bool expanded, bool has_children, bool selected = false) + void tree_label(GUI::IContext* context, ShowcaseState& state, EditorGUI::id_t element_id, + EditorGUI::IconName icon_name, const c8* value, i32 depth, bool expanded, + bool has_children, bool selected = false) { GUI::LayoutConfig layout = fill_width(28.0f); layout.padding.x = 4.0f + (f32)depth * 14.0f; @@ -368,7 +485,7 @@ namespace Luna GUI::ElementHandle row = EditorGUI::begin_h_layout(context, element_id, value, layout); if(selected) { - rounded_rect(context, style_color(context, "gui.accent.subtle"), 6.0f); + rounded_rect(context, state, row, style_color(context, "gui.accent.subtle"), 6.0f); } if(has_children) { @@ -399,8 +516,9 @@ namespace Luna EditorGUI::end_h_layout(context, row, flex); } - GUI::ElementHandle icon_button(GUI::IContext* context, EditorGUI::id_t element_id, - EditorGUI::IconName icon_name, const c8* value, const GUI::LayoutConfig& layout, + GUI::ElementHandle icon_button(GUI::IContext* context, ShowcaseState& state, + EditorGUI::id_t element_id, EditorGUI::IconName icon_name, const c8* value, + const GUI::LayoutConfig& layout, const EditorGUI::ButtonDesc& desc = EditorGUI::ButtonDesc(), bool show_text = true, EditorGUI::IconWeight weight = EditorGUI::IconWeight::regular, bool selected = false) { @@ -415,7 +533,7 @@ namespace Luna GUI::ElementHandle button = EditorGUI::begin_button(context, element_id, value, resolved_layout, desc); if(selected) { - selected_button_surface(context); + selected_button_surface(context, state, button); } const c8* color = !desc.enabled ? "gui.text.disabled" : (selected ? "gui.accent.active" : "gui.button.text"); @@ -451,12 +569,12 @@ namespace Luna return item; } - GUI::ElementHandle begin_panel(GUI::IContext* context, EditorGUI::id_t element_id, - const c8* name, const GUI::LayoutConfig& input, const c8* background = "gui.surface.1", - f32 radius = 12.0f, bool raised = true) + GUI::ElementHandle begin_panel(GUI::IContext* context, ShowcaseState& state, + EditorGUI::id_t element_id, const c8* name, const GUI::LayoutConfig& input, + const c8* background = "gui.surface.1", f32 radius = 12.0f, bool raised = true) { GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, element_id, name, input); - surface(context, background, radius, raised); + surface(context, state, panel, background, radius, raised); return panel; } @@ -469,20 +587,20 @@ namespace Luna EditorGUI::end_v_layout(context, panel, flex); } - void divider(GUI::IContext* context, EditorGUI::id_t element_id) + void divider(GUI::IContext* context, ShowcaseState& state, EditorGUI::id_t element_id) { GUI::ElementHandle line = EditorGUI::begin_v_layout(context, element_id, "Divider", fill_width(1.0f)); - rounded_rect(context, style_color(context, "gui.border"), 0.0f); + rounded_rect(context, state, line, style_color(context, "gui.border"), 0.0f); EditorGUI::end_v_layout(context, line); } - void led_label(GUI::IContext* context, EditorGUI::id_t element_id, - const c8* value, const c8* color_key, f32 width) + void led_label(GUI::IContext* context, ShowcaseState& state, + EditorGUI::id_t element_id, const c8* value, const c8* color_key, f32 width) { GUI::LayoutConfig layout = fixed(width, 32.0f); layout.padding = Float4U(9.0f, 5.0f, 9.0f, 5.0f); GUI::ElementHandle pill = EditorGUI::begin_h_layout(context, element_id, value, layout); - surface(context, "gui.surface.1", 16.0f, true); + surface(context, state, pill, "gui.surface.1", 16.0f, true); GUI::ElementHandle indicator = EditorGUI::begin_v_layout(context, GUI::make_scoped_id(element_id, 1), "LED", fixed(10.0f, 10.0f)); Float4U led_color = style_color(context, color_key); @@ -490,15 +608,16 @@ namespace Luna { Float4U glow_color = led_color; glow_color.w *= 0.56f; - rounded_shadow(context, glow_color, 5.0f, Float2U(0.0f), 3.0f, + rounded_shadow(context, state, indicator, glow_color, 5.0f, Float2U(0.0f), 3.0f, GUI::ShadowMode::outer); } - rounded_rect(context, style_color(context, "gui.border"), 5.0f); - rounded_rect(context, led_color, 4.0f, RectF(1.0f, 1.0f, -2.0f, -2.0f)); + rounded_rect(context, state, indicator, style_color(context, "gui.border"), 5.0f); + rounded_rect(context, state, indicator, led_color, 4.0f, + RectF(1.0f, 1.0f, -2.0f, -2.0f)); Float4U highlight = style_color(context, "gui.shadow.inset_light", Float4U(1.0f, 1.0f, 1.0f, 0.7f)); highlight.w *= 0.55f; - rounded_shadow(context, highlight, 4.0f, Float2U(0.0f, -1.0f), 1.0f, + rounded_shadow(context, state, indicator, highlight, 4.0f, Float2U(0.0f, -1.0f), 1.0f, GUI::ShadowMode::inner, RectF(1.0f, 1.0f, -2.0f, -2.0f)); EditorGUI::end_v_layout(context, indicator); label(context, GUI::make_scoped_id(element_id, 2), value, grow_x(), 11.0f, "gui.text.secondary"); @@ -532,21 +651,22 @@ namespace Luna EditorGUI::end_h_layout(context, row, flex); } - void card_title(GUI::IContext* context, EditorGUI::id_t parent, const c8* title, const c8* note) + void card_title(GUI::IContext* context, ShowcaseState& state, EditorGUI::id_t parent, + const c8* title, const c8* note) { label(context, GUI::make_scoped_id(parent, 1), title, fill_width(24.0f), 13.0f); label(context, GUI::make_scoped_id(parent, 2), note, fill_width(20.0f), 10.0f, "gui.text.muted"); - divider(context, GUI::make_scoped_id(parent, 3)); + divider(context, state, GUI::make_scoped_id(parent, 3)); } - GUI::ElementHandle begin_card(GUI::IContext* context, const c8* name, - const GUI::LayoutConfig& input, const c8* title, const c8* note) + GUI::ElementHandle begin_card(GUI::IContext* context, ShowcaseState& state, + const c8* name, const GUI::LayoutConfig& input, const c8* title, const c8* note) { GUI::LayoutConfig layout = input; layout.padding = Float4U(16.0f); - GUI::ElementHandle card = begin_panel(context, id(context, name), title, layout, + GUI::ElementHandle card = begin_panel(context, state, id(context, name), title, layout, "gui.surface.1", style_scalar(context, "gui.radius.large", 14.0f), true); - card_title(context, card.id, title, note); + card_title(context, state, card.id, title, note); return card; } @@ -640,13 +760,13 @@ namespace Luna layout.padding = Float4U(12.0f, 8.0f, 12.0f, 8.0f); GUI::ElementHandle top = EditorGUI::begin_h_layout(context, id(context, "showcase.topbar"), "Top bar", layout); - rounded_rect(context, style_color(context, "gui.surface.1"), 0.0f); + rounded_rect(context, state, top, style_color(context, "gui.surface.1"), 0.0f); GUI::ElementHandle brand = EditorGUI::begin_h_layout(context, id(context, "showcase.brand"), "Brand", fixed(245.0f, height - 16.0f)); GUI::ElementHandle logo = EditorGUI::begin_v_layout(context, id(context, "showcase.logo"), "Logo", fixed(40.0f, 40.0f)); - rounded_rect(context, style_color(context, "gui.accent"), 12.0f); + rounded_rect(context, state, logo, style_color(context, "gui.accent"), 12.0f); EditorGUI::icon(context, id(context, "showcase.logo.icon"), EditorGUI::IconName::package, fill(), icon_desc(context, "gui.accent.ink", EditorGUI::IconWeight::duotone, 24.0f)); EditorGUI::end_v_layout(context, logo); @@ -664,13 +784,13 @@ namespace Luna GUI::ElementHandle status = EditorGUI::begin_h_layout(context, id(context, "showcase.status"), "System status", grow_x()); - led_label(context, id(context, "showcase.status.renderer"), "Renderer Online", + led_label(context, state, id(context, "showcase.status.renderer"), "Renderer Online", "gui.status.success", 128.0f); - led_label(context, id(context, "showcase.status.bake"), "Bake Queue 2", + led_label(context, state, id(context, "showcase.status.bake"), "Bake Queue 2", "gui.status.busy", 112.0f); - led_label(context, id(context, "showcase.status.sync"), "Remote Sync", + led_label(context, state, id(context, "showcase.status.sync"), "Remote Sync", "gui.status.off", 106.0f); - led_label(context, id(context, "showcase.status.error"), "Error 1", + led_label(context, state, id(context, "showcase.status.error"), "Error 1", "gui.status.error", 86.0f); GUI::FlexLayoutDesc status_flex; status_flex.axis = GUI::LayoutAxis::x; @@ -697,7 +817,7 @@ namespace Luna accent_layout.padding = Float4U(9.0f, 4.0f, 9.0f, 4.0f); GUI::ElementHandle accent = EditorGUI::begin_h_layout(context, id(context, "showcase.accent"), "Accent", accent_layout); - surface(context, "gui.surface.1", 18.0f, false); + surface(context, state, accent, "gui.surface.1", 18.0f, false); EditorGUI::ShapeWidgetDesc accent_shape; accent_shape.tint = style_color(context, "gui.accent"); EditorGUI::shape(context, id(context, "showcase.accent.swatch"), state.circle, @@ -725,7 +845,7 @@ namespace Luna layout.padding = Float4U(10.0f, 14.0f, 10.0f, 12.0f); GUI::ElementHandle navigation = EditorGUI::begin_v_layout(context, id(context, "showcase.navigation"), "Navigation", layout); - rounded_rect(context, style_color(context, "gui.surface.0"), 0.0f); + rounded_rect(context, state, navigation, style_color(context, "gui.surface.0"), 0.0f); icon_label(context, id(context, "showcase.navigation.title"), EditorGUI::IconName::tree_structure, "COMPONENTS", fill_width(34.0f), 16.0f, 10.0f, "gui.text.muted", EditorGUI::IconWeight::bold); @@ -736,8 +856,8 @@ namespace Luna fill_width(style_scalar(context, "gui.control.height", 40.0f))); if(state.section == i) { - selected_button_surface(context); - rounded_rect(context, style_color(context, "gui.accent"), 1.5f, + selected_button_surface(context, state, handles.navigation[i]); + rounded_rect(context, state, handles.navigation[i], style_color(context, "gui.accent"), 1.5f, RectF(3.0f, 8.0f, 3.0f, -16.0f)); } const c8* item_color = state.section == i ? "gui.accent.active" : "gui.text.secondary"; @@ -753,7 +873,7 @@ namespace Luna EditorGUI::end_v_layout(context, spacer); GUI::LayoutConfig footer_layout = fill_width(92.0f); footer_layout.padding = Float4U(12.0f); - GUI::ElementHandle footer = begin_panel(context, id(context, "showcase.navigation.footer"), + GUI::ElementHandle footer = begin_panel(context, state, id(context, "showcase.navigation.footer"), "Active style", footer_layout, "gui.surface.1", 10.0f); label(context, id(context, "showcase.navigation.footer.label"), "ACTIVE LEAF STYLE", fill_width(18.0f), 9.0f, "gui.text.muted"); @@ -767,14 +887,14 @@ namespace Luna end_panel(context, navigation, 4.0f); } - GUI::ElementHandle style_option(GUI::IContext* context, EditorGUI::id_t element_id, - const c8* value, bool selected, const GUI::LayoutConfig& layout) + GUI::ElementHandle style_option(GUI::IContext* context, ShowcaseState& state, + EditorGUI::id_t element_id, const c8* value, bool selected, const GUI::LayoutConfig& layout) { GUI::ElementHandle option = EditorGUI::begin_button(context, element_id, value, layout); if(selected) { - rounded_rect(context, style_color(context, "gui.accent"), 9.0f); - rounded_rect(context, style_color(context, "gui.accent.subtle"), 8.0f, + rounded_rect(context, state, option, style_color(context, "gui.accent"), 9.0f); + rounded_rect(context, state, option, style_color(context, "gui.accent.subtle"), 8.0f, RectF(1.0f, 1.0f, -2.0f, -2.0f)); } label(context, GUI::make_scoped_id(element_id, 1), value, grow_x(), 11.0f, @@ -797,7 +917,7 @@ namespace Luna { i32 option_index = row_index * 2 + column; bool selected = option_index == state.theme * 2 + state.density; - handles.style_options[option_index] = style_option(context, + handles.style_options[option_index] = style_option(context, state, GUI::make_scoped_id(row.id, (u64)column + 1), labels[option_index], selected, grow_x()); } GUI::FlexLayoutDesc row_flex; @@ -818,8 +938,8 @@ namespace Luna name, layout); if(state.selected_asset == index) { - rounded_rect(context, style_color(context, "gui.accent"), 8.0f); - rounded_rect(context, style_color(context, "gui.accent.subtle"), 7.0f, + rounded_rect(context, state, handles.assets[index], style_color(context, "gui.accent"), 8.0f); + rounded_rect(context, state, handles.assets[index], style_color(context, "gui.accent.subtle"), 7.0f, RectF(1.0f, 1.0f, -2.0f, -2.0f)); } EditorGUI::image(context, GUI::make_scoped_id(parent, (u64)index + 30), material_texture(state, index), @@ -842,13 +962,14 @@ namespace Luna editor_layout.padding = Float4U(state.density == 1 ? 12.0f : 10.0f); GUI::ElementHandle editor = EditorGUI::begin_h_layout(context, id(context, "overview.editor"), "DCC editor composition", editor_layout); - surface(context, "gui.surface.0", style_scalar(context, "gui.radius.large", 16.0f), true, true); + surface(context, state, editor, "gui.surface.0", + style_scalar(context, "gui.radius.large", 16.0f), true, true); GUI::LayoutConfig library_layout = fixed(176.0f, 0.0f); library_layout.height.kind = GUI::SizeKind::percent; library_layout.height.value = 1.0f; library_layout.padding = Float4U(10.0f); - GUI::ElementHandle library = begin_panel(context, id(context, "overview.library"), + GUI::ElementHandle library = begin_panel(context, state, id(context, "overview.library"), "Library", library_layout, "gui.surface.1", 15.0f, false); GUI::ElementHandle library_title = EditorGUI::begin_h_layout(context, id(context, "overview.library.title"), "Scene Hierarchy", fill_width(34.0f)); @@ -856,7 +977,7 @@ namespace Luna fixed(17.0f, 17.0f), icon_desc(context, "gui.text.secondary", EditorGUI::IconWeight::regular, 17.0f)); label(context, id(context, "overview.library.title.text"), "Scene Hierarchy", grow_x(), 11.0f); - icon_button(context, id(context, "overview.library.add"), EditorGUI::IconName::plus, "Add object", + icon_button(context, state, id(context, "overview.library.add"), EditorGUI::IconName::plus, "Add object", fixed(28.0f, 28.0f), EditorGUI::ButtonDesc(), false, EditorGUI::IconWeight::bold); GUI::FlexLayoutDesc library_title_flex; library_title_flex.axis = GUI::LayoutAxis::x; @@ -877,15 +998,15 @@ namespace Luna search_flex.cross_alignment = GUI::FlexAlignment::center; search_flex.main_axis_gap = 6.0f; EditorGUI::end_h_layout(context, search_row, search_flex); - tree_label(context, id(context, "overview.tree.root"), EditorGUI::IconName::package, + tree_label(context, state, id(context, "overview.tree.root"), EditorGUI::IconName::package, "Desert_Outpost", 0, true, true); - tree_label(context, id(context, "overview.tree.environment"), EditorGUI::IconName::folder_open, + tree_label(context, state, id(context, "overview.tree.environment"), EditorGUI::IconName::folder_open, "Environment", 1, true, true); - tree_label(context, id(context, "overview.tree.sun"), EditorGUI::IconName::sun, + tree_label(context, state, id(context, "overview.tree.sun"), EditorGUI::IconName::sun, "Sun_Light", 2, false, false); - tree_label(context, id(context, "overview.tree.ball"), EditorGUI::IconName::bounding_box, + tree_label(context, state, id(context, "overview.tree.ball"), EditorGUI::IconName::bounding_box, "Material_Ball", 2, false, false, true); - divider(context, id(context, "overview.library.divider")); + divider(context, state, id(context, "overview.library.divider")); icon_label(context, id(context, "overview.library.assets"), EditorGUI::IconName::folder_open, "Assets - 3 items", fill_width(28.0f), 16.0f, 10.0f, "gui.text.secondary"); build_asset_row(context, state, handles, library.id, 0, "M_Desert_Sand", "Material - 3.6 MB"); @@ -897,7 +1018,7 @@ namespace Luna "Viewport", grow_x()); GUI::ElementHandle toolbar = EditorGUI::begin_h_layout(context, id(context, "overview.viewport.toolbar"), "Viewport toolbar", fill_width(48.0f)); - rounded_rect(context, style_color(context, "gui.surface.1"), 0.0f); + rounded_rect(context, state, toolbar, style_color(context, "gui.surface.1"), 0.0f); GUI::ElementHandle tool_row = EditorGUI::begin_h_layout(context, id(context, "overview.viewport.tools"), "Viewport tools", fixed(184.0f, 40.0f)); const EditorGUI::IconName tool_icons[] = { @@ -908,7 +1029,7 @@ namespace Luna for(i32 tool_index = 0; tool_index < 4; ++tool_index) { EditorGUI::id_t tool_id = GUI::make_scoped_id(tool_row.id, (u64)tool_index + 1); - GUI::ElementHandle tool = icon_button(context, tool_id, tool_icons[tool_index], + GUI::ElementHandle tool = icon_button(context, state, tool_id, tool_icons[tool_index], tool_labels[tool_index], fixed(40.0f, 40.0f), EditorGUI::ButtonDesc(), false, tool_index == state.selected_group ? EditorGUI::IconWeight::bold : EditorGUI::IconWeight::regular, tool_index == state.selected_group); @@ -925,7 +1046,7 @@ namespace Luna const c8* spaces[] = { "Local", "World" }; EditorGUI::button_group(context, id(context, "overview.viewport.space"), Span(spaces, 2), &state.space_mode, fixed(126.0f, 40.0f)); - icon_button(context, id(context, "overview.viewport.more"), EditorGUI::IconName::dots_three, + icon_button(context, state, id(context, "overview.viewport.more"), EditorGUI::IconName::dots_three, "More viewport options", fixed(40.0f, 40.0f), EditorGUI::ButtonDesc(), false, EditorGUI::IconWeight::bold); GUI::FlexLayoutDesc toolbar_flex; @@ -937,7 +1058,7 @@ namespace Luna grow_y(), flipped_image(0.14f, 0.86f)); GUI::LayoutConfig console_layout = fill_width(104.0f); console_layout.padding = Float4U(10.0f, 0.0f, 10.0f, 8.0f); - GUI::ElementHandle console = begin_panel(context, id(context, "overview.console"), + GUI::ElementHandle console = begin_panel(context, state, id(context, "overview.console"), "Console", console_layout, "gui.surface.1", 0.0f, false); const c8* console_tabs[] = { "Console", "Bake Queue 2", "Messages" }; EditorGUI::button_group(context, id(context, "overview.console.tabs"), @@ -958,7 +1079,7 @@ namespace Luna inspector_layout.height.kind = GUI::SizeKind::percent; inspector_layout.height.value = 1.0f; inspector_layout.padding = Float4U(12.0f); - GUI::ElementHandle inspector = begin_panel(context, id(context, "overview.inspector"), + GUI::ElementHandle inspector = begin_panel(context, state, id(context, "overview.inspector"), "Inspector", inspector_layout, "gui.surface.1", 15.0f, false); GUI::ElementHandle asset_header = EditorGUI::begin_h_layout(context, id(context, "overview.inspector.asset"), "Selected asset", fill_width(52.0f)); @@ -999,7 +1120,8 @@ namespace Luna banner_layout.padding = Float4U(28.0f); GUI::ElementHandle banner = EditorGUI::begin_h_layout(context, id(context, "overview.banner"), "Style summary", banner_layout); - surface(context, "gui.surface.1", style_scalar(context, "gui.radius.large", 16.0f), true, true); + surface(context, state, banner, "gui.surface.1", + style_scalar(context, "gui.radius.large", 16.0f), true, true); GUI::ElementHandle summary = EditorGUI::begin_v_layout(context, id(context, "overview.banner.summary"), "Summary", grow_x(130.0f)); label(context, id(context, "overview.banner.active"), "READY STYLE ACTIVE", fill_width(22.0f), @@ -1027,7 +1149,7 @@ namespace Luna EditorGUI::IconName::stack); GUI::ElementHandle row; page_two_columns(context, id(context, "primitives.row1"), row, 430.0f); - GUI::ElementHandle typography = begin_card(context, "primitives.typography", grow_x(), + GUI::ElementHandle typography = begin_card(context, state, "primitives.typography", grow_x(), "Typography", "Semantic roles resolved from the active Style"); typography_label(context, id(context, "primitives.type.h1"), "H1 Editor title", fill_width(42.0f), EditorGUI::TypographyRole::heading1); @@ -1053,7 +1175,7 @@ namespace Luna "Caption Secondary metadata - 12:45:08", fill_width(22.0f), EditorGUI::TypographyRole::caption); end_panel(context, typography, 4.0f); - GUI::ElementHandle progress = begin_card(context, "primitives.progress", grow_x(), + GUI::ElementHandle progress = begin_card(context, state, "primitives.progress", grow_x(), "Progress and LED", "Determinate work and semantic status"); label(context, id(context, "primitives.progress.label1"), "Baking textures 68%", fill_width(24.0f), 11.0f, "gui.text.secondary"); @@ -1065,11 +1187,11 @@ namespace Luna fill_width(24.0f), 11.0f, "gui.text.secondary"); EditorGUI::progress_bar(context, id(context, "primitives.progress.2"), 0.42f, fill_width(10.0f), progress_desc); - led_label(context, id(context, "primitives.led.online"), "Online", "gui.status.success", 100.0f); - led_label(context, id(context, "primitives.led.working"), "Working", "gui.status.busy", 100.0f); + led_label(context, state, id(context, "primitives.led.online"), "Online", "gui.status.success", 100.0f); + led_label(context, state, id(context, "primitives.led.working"), "Working", "gui.status.busy", 100.0f); end_panel(context, progress); end_two_columns(context, row); - GUI::ElementHandle sdf_card = begin_card(context, "primitives.sdf", fill_width(220.0f), + GUI::ElementHandle sdf_card = begin_card(context, state, "primitives.sdf", fill_width(220.0f), "SDF shape and paint programs", "Linear, radial, conic, CSG, capsule, and analytic shadow"); GUI::ElementHandle sdf_row = EditorGUI::begin_h_layout(context, id(context, "primitives.sdf.row"), "SDF swatches", grow_y()); @@ -1082,7 +1204,7 @@ namespace Luna sdf_flex.main_axis_gap = 14.0f; EditorGUI::end_h_layout(context, sdf_row, sdf_flex); end_panel(context, sdf_card); - GUI::ElementHandle icon_card = begin_card(context, "primitives.icons", fill_width(220.0f), + GUI::ElementHandle icon_card = begin_card(context, state, "primitives.icons", fill_width(220.0f), "Built-in icons", "Phosphor geometry rendered through the shared VG shape buffer"); struct IconSample { @@ -1140,7 +1262,7 @@ namespace Luna EditorGUI::end_h_layout(context, icon_row, icon_flex); } end_panel(context, icon_card); - GUI::ElementHandle image_card = begin_card(context, "primitives.image", fill_width(420.0f), + GUI::ElementHandle image_card = begin_card(context, state, "primitives.image", fill_width(420.0f), "Image and shape", "Raster material assets and vector primitives"); EditorGUI::image(context, id(context, "primitives.image.preview"), state.material_preview, grow_y(), flipped_image(0.05f, 0.95f, 0.08f, 0.92f)); @@ -1154,20 +1276,20 @@ namespace Luna EditorGUI::IconName::cursor_click); GUI::ElementHandle row1; page_two_columns_fit(context, id(context, "buttons.row1"), row1); - GUI::ElementHandle states = begin_card(context, "buttons.states", grow(), + GUI::ElementHandle states = begin_card(context, state, "buttons.states", grow(), "Button states", "Default, pressed, destructive, and disabled"); - icon_button(context, id(context, "buttons.primary"), EditorGUI::IconName::lightning, + icon_button(context, state, id(context, "buttons.primary"), EditorGUI::IconName::lightning, "Primary", fill_width(44.0f), EditorGUI::ButtonDesc(), true, EditorGUI::IconWeight::fill); - icon_button(context, id(context, "buttons.save"), EditorGUI::IconName::floppy_disk, + icon_button(context, state, id(context, "buttons.save"), EditorGUI::IconName::floppy_disk, "Save", fill_width(44.0f)); - icon_button(context, id(context, "buttons.upload"), EditorGUI::IconName::upload_simple, + icon_button(context, state, id(context, "buttons.upload"), EditorGUI::IconName::upload_simple, "Upload", fill_width(44.0f)); EditorGUI::ButtonDesc disabled; disabled.enabled = false; - icon_button(context, id(context, "buttons.disabled"), EditorGUI::IconName::gear_six, + icon_button(context, state, id(context, "buttons.disabled"), EditorGUI::IconName::gear_six, "Disabled", fill_width(44.0f), disabled); end_panel(context, states); - GUI::ElementHandle groups = begin_card(context, "buttons.groups", grow(), + GUI::ElementHandle groups = begin_card(context, state, "buttons.groups", grow(), "Button groups", "Single and multiple selection"); const c8* items[] = { "Build", "Run", "Profile", "Ship" }; EditorGUI::button_group(context, id(context, "buttons.group.single"), Span(items, 4), @@ -1178,7 +1300,7 @@ namespace Luna end_two_columns(context, row1); GUI::ElementHandle row2; page_two_columns(context, id(context, "buttons.row2"), row2, 380.0f); - GUI::ElementHandle choices = begin_card(context, "buttons.choices", grow_x(), + GUI::ElementHandle choices = begin_card(context, state, "buttons.choices", grow_x(), "Selection controls", "Checkbox, radio, and switch"); EditorGUI::checkbox(context, id(context, "buttons.checkbox"), "Visible in render", &state.checkbox_value, fill_width(48.0f)); @@ -1191,7 +1313,7 @@ namespace Luna EditorGUI::toggle_switch(context, id(context, "buttons.toggle"), "Live preview", &state.live_preview, fill_width(48.0f)); end_panel(context, choices); - GUI::ElementHandle disclosure = begin_card(context, "buttons.disclosure", grow_x(), + GUI::ElementHandle disclosure = begin_card(context, state, "buttons.disclosure", grow_x(), "Disclosure and tree", "Expanded, selected, and leaf states"); EditorGUI::ScrollViewDesc disclosure_scroll_desc; disclosure_scroll_desc.horizontal = false; @@ -1231,7 +1353,7 @@ namespace Luna EditorGUI::IconName::sliders_horizontal); GUI::ElementHandle row1; page_two_columns(context, id(context, "input.row1"), row1, 330.0f); - GUI::ElementHandle fields = begin_card(context, "input.fields", grow_x(), + GUI::ElementHandle fields = begin_card(context, state, "input.fields", grow_x(), "Text input", "Editable, read-only, and disabled"); EditorGUI::input_text(context, id(context, "input.asset"), state.asset_name, fill_width(48.0f)); EditorGUI::TextInputDesc read_only; @@ -1243,7 +1365,7 @@ namespace Luna EditorGUI::input_text(context, id(context, "input.disabled"), state.readonly_value, fill_width(48.0f), disabled); end_panel(context, fields); - GUI::ElementHandle sliders = begin_card(context, "input.sliders", grow_x(), + GUI::ElementHandle sliders = begin_card(context, state, "input.sliders", grow_x(), "Slider and drag editors", "Scalar, integer, and vector values"); EditorGUI::slider_float(context, id(context, "input.roughness"), &state.roughness, 0.0f, 1.0f, fill_width(48.0f)); @@ -1257,12 +1379,12 @@ namespace Luna end_two_columns(context, row1); GUI::ElementHandle row2; page_two_columns(context, id(context, "input.row2"), row2, 250.0f); - GUI::ElementHandle color = begin_card(context, "input.color", grow_x(), + GUI::ElementHandle color = begin_card(context, state, "input.color", grow_x(), "Color edit", "RGBA preview and numeric channels"); EditorGUI::color_edit4(context, id(context, "input.color.value"), "Preview color", state.preview_color, fill_width(48.0f)); end_panel(context, color); - GUI::ElementHandle combo = begin_card(context, "input.combo", grow_x(), + GUI::ElementHandle combo = begin_card(context, state, "input.combo", grow_x(), "Combo and search", "Popup-ready selector and filtered query"); const c8* render_modes[] = { "PBR Metallic Roughness", "Subsurface", "Unlit" }; GUI::ElementHandle combo_row = EditorGUI::begin_h_layout(context, id(context, "input.combo.row"), @@ -1296,7 +1418,7 @@ namespace Luna EditorGUI::IconName::grid_four); GUI::ElementHandle row1; page_two_columns(context, id(context, "layouts.row1"), row1, 300.0f); - GUI::ElementHandle flex_card = begin_card(context, "layouts.flex", grow_x(), + GUI::ElementHandle flex_card = begin_card(context, state, "layouts.flex", grow_x(), "Flex and grid", "Fixed, growing, and repeated cells"); GUI::ElementHandle flex_row = EditorGUI::begin_h_layout(context, id(context, "layouts.flex.row"), "Flex row", fill_width(48.0f)); @@ -1318,7 +1440,7 @@ namespace Luna end_two_columns(context, grid_row); } end_panel(context, flex_card); - GUI::ElementHandle assets = begin_card(context, "layouts.assets", grow_x(), + GUI::ElementHandle assets = begin_card(context, state, "layouts.assets", grow_x(), "Asset grid", "Image, selection, and metadata"); GUI::ElementHandle asset_row = EditorGUI::begin_h_layout(context, id(context, "layouts.assets.row"), "Asset row", grow_y()); @@ -1326,7 +1448,8 @@ namespace Luna { GUI::ElementHandle item = EditorGUI::begin_v_layout(context, GUI::make_scoped_id(asset_row.id, (u64)i + 1), "Asset", grow_x()); - surface(context, i == state.selected_asset ? "gui.accent.subtle" : "gui.surface.2", 10.0f, false); + surface(context, state, item, + i == state.selected_asset ? "gui.accent.subtle" : "gui.surface.2", 10.0f, false); EditorGUI::image(context, GUI::make_scoped_id(item.id, 1), material_texture(state, i), grow_y(), flipped_image()); const c8* names[] = { "Sand", "Rusted Metal", "Concrete" }; @@ -1338,7 +1461,7 @@ namespace Luna end_two_columns(context, row1); } - void build_scroll_views(GUI::IContext* context) + void build_scroll_views(GUI::IContext* context, ShowcaseState& state) { section_heading(context, "scroll", "GUITEST - SCROLL VIEWS", "Overlay and reserved scrollbars", "Compact pointer mode favors overlay scrollbars; touch mode keeps broad, visible affordances.", @@ -1348,7 +1471,7 @@ namespace Luna for(i32 column = 0; column < 2; ++column) { const c8* card_name = column == 0 ? "scroll.overlay" : "scroll.visible"; - GUI::ElementHandle card = begin_card(context, card_name, grow_x(), + GUI::ElementHandle card = begin_card(context, state, card_name, grow_x(), column == 0 ? "Dynamic overlay" : "Always visible", column == 0 ? "Appears during interaction" : "Reserves viewport space"); EditorGUI::ScrollViewDesc desc; @@ -1375,8 +1498,9 @@ namespace Luna const c8* status_color; }; - void table_cell(GUI::IContext* context, EditorGUI::id_t element_id, const c8* value, - bool header, bool status, const c8* status_color) + void table_cell(GUI::IContext* context, ShowcaseState& state, + EditorGUI::id_t element_id, const c8* value, bool header, bool status, + const c8* status_color) { GUI::LayoutConfig layout; layout.padding = Float4U(12.0f, 0.0f, 12.0f, 0.0f); @@ -1389,9 +1513,11 @@ namespace Luna Float4U led = style_color(context, status_color); Float4U glow = led; glow.w *= 0.45f; - rounded_shadow(context, glow, 5.0f, Float2U(), 3.0f, GUI::ShadowMode::outer); - rounded_rect(context, style_color(context, "gui.border"), 5.0f); - rounded_rect(context, led, 4.0f, RectF(1.0f, 1.0f, -2.0f, -2.0f)); + rounded_shadow(context, state, indicator, glow, 5.0f, Float2U(), 3.0f, + GUI::ShadowMode::outer); + rounded_rect(context, state, indicator, style_color(context, "gui.border"), 5.0f); + rounded_rect(context, state, indicator, led, 4.0f, + RectF(1.0f, 1.0f, -2.0f, -2.0f)); EditorGUI::end_v_layout(context, indicator); } typography_label(context, GUI::make_scoped_id(element_id, 2), value, @@ -1408,7 +1534,7 @@ namespace Luna section_heading(context, "tables", "GUITEST - TABLES", "Dense, resizable data", "Readable rows, clear selection, subtle hierarchy, and generous splitter hit targets.", EditorGUI::IconName::list_bullets); - GUI::ElementHandle card = begin_card(context, "tables.memory", fill_width(470.0f), + GUI::ElementHandle card = begin_card(context, state, "tables.memory", fill_width(470.0f), "Memory Profiler", "Visual row states and resize-handle targets"); GUI::ElementHandle toolbar = EditorGUI::begin_h_layout(context, id(context, "tables.toolbar"), "Table toolbar", fill_width(44.0f)); @@ -1417,9 +1543,9 @@ namespace Luna EditorGUI::TextInputDesc filter_desc; filter_desc.placeholder = "Filter allocations..."; EditorGUI::input_text(context, id(context, "tables.filter"), state.table_filter, grow_x(), filter_desc); - icon_button(context, id(context, "tables.snapshot"), EditorGUI::IconName::camera, + icon_button(context, state, id(context, "tables.snapshot"), EditorGUI::IconName::camera, "Snapshot", fixed(130.0f, 44.0f)); - icon_button(context, id(context, "tables.settings"), EditorGUI::IconName::gear_six, + icon_button(context, state, id(context, "tables.settings"), EditorGUI::IconName::gear_six, "Table settings", fixed(44.0f, 44.0f), EditorGUI::ButtonDesc(), false); GUI::FlexLayoutDesc toolbar_flex; toolbar_flex.axis = GUI::LayoutAxis::x; @@ -1436,12 +1562,12 @@ namespace Luna table_desc.fixed_row_height = 48.0f; GUI::ElementHandle table = EditorGUI::begin_table_layout(context, id(context, "tables.grid"), "Memory allocation table", table_layout, table_desc); - rounded_rect(context, style_color(context, "gui.border"), + rounded_rect(context, state, table, style_color(context, "gui.border"), style_scalar(context, "gui.radius.medium", 9.0f)); - rounded_rect(context, style_color(context, "gui.surface.1"), + rounded_rect(context, state, table, style_color(context, "gui.surface.1"), max(style_scalar(context, "gui.radius.medium", 9.0f) - 1.0f, 0.0f), RectF(1.0f, 1.0f, -2.0f, -2.0f)); - rounded_rect(context, style_color(context, "gui.surface.2"), 0.0f, + rounded_rect(context, state, table, style_color(context, "gui.surface.2"), 0.0f, RectF(1.0f, 1.0f, -2.0f, 48.0f)); static const f32 COLUMN_WIDTHS[] = { 0.06f, 0.23f, 0.16f, 0.13f, 0.16f, 0.26f }; @@ -1464,7 +1590,7 @@ namespace Luna EditorGUI::begin_table_row(context); for(usize column = 0; column < 6; ++column) { - table_cell(context, GUI::make_scoped_id(table.id, (u64)column + 1), + table_cell(context, state, GUI::make_scoped_id(table.id, (u64)column + 1), HEADERS[column], true, false, nullptr); } EditorGUI::end_table_row(context); @@ -1473,23 +1599,24 @@ namespace Luna f32 row_y = 50.0f + (f32)row * 49.0f; if((i32)row == state.table_selection) { - rounded_rect(context, style_color(context, "gui.accent.subtle"), 0.0f, + rounded_rect(context, state, table, style_color(context, "gui.accent.subtle"), 0.0f, RectF(1.0f, row_y, -2.0f, 48.0f)); - rounded_rect(context, style_color(context, "gui.accent"), 0.0f, + rounded_rect(context, state, table, style_color(context, "gui.accent"), 0.0f, RectF(1.0f, row_y, 3.0f, 48.0f)); } else if(row & 1) { Float4U zebra = style_color(context, "gui.surface.2"); zebra.w *= 0.52f; - rounded_rect(context, zebra, 0.0f, RectF(1.0f, row_y, -2.0f, 48.0f)); + rounded_rect(context, state, table, zebra, 0.0f, + RectF(1.0f, row_y, -2.0f, 48.0f)); } EditorGUI::begin_table_row(context); for(usize column = 0; column < 6; ++column) { EditorGUI::id_t cell_id = GUI::make_scoped_id( GUI::make_scoped_id(table.id, (u64)row + 10), (u64)column + 1); - table_cell(context, cell_id, RECORDS[row].cells[column], false, + table_cell(context, state, cell_id, RECORDS[row].cells[column], false, column == 5, RECORDS[row].status_color); } EditorGUI::end_table_row(context); @@ -1505,7 +1632,7 @@ namespace Luna EditorGUI::IconName::bell); GUI::ElementHandle row; page_two_columns(context, id(context, "overlay.row"), row, 420.0f); - GUI::ElementHandle menu_card = begin_card(context, "overlay.menu", grow_x(), + GUI::ElementHandle menu_card = begin_card(context, state, "overlay.menu", grow_x(), "Menu bar", "Shortcut, checked, disabled, and separator states"); GUI::ElementHandle menu_bar = EditorGUI::begin_menu_bar(context, id(context, "overlay.menu.bar"), "Menu", fill_width(style_scalar(context, "gui.control.height", 32.0f))); @@ -1524,11 +1651,11 @@ namespace Luna } EditorGUI::end_menu_bar(context, menu_bar); end_panel(context, menu_card); - GUI::ElementHandle popup_card = begin_card(context, "overlay.popup", grow_x(), + GUI::ElementHandle popup_card = begin_card(context, state, "overlay.popup", grow_x(), "Popup and tooltip", "Independent GUI layers with captured backdrop blur"); - handles.popup_button = icon_button(context, id(context, "overlay.popup.open"), + handles.popup_button = icon_button(context, state, id(context, "overlay.popup.open"), EditorGUI::IconName::squares_four, "Open Popup", fill_width(48.0f)); - GUI::ElementHandle tooltip_button = icon_button(context, id(context, "overlay.tooltip"), + GUI::ElementHandle tooltip_button = icon_button(context, state, id(context, "overlay.tooltip"), EditorGUI::IconName::info, "Hover for tooltip", fill_width(48.0f)); EditorGUI::set_item_tooltip(context, id(context, "overlay.tooltip.layer"), tooltip_button, "Available on hover or pointer focus."); @@ -1542,7 +1669,7 @@ namespace Luna GUI::ElementHandle sample = EditorGUI::begin_v_layout(context, GUI::make_scoped_id(backdrop_source.id, (u64)i + 1), "Backdrop color sample", grow_x(56.0f)); - rounded_rect(context, style_color(context, backdrop_colors[i]), 9.0f); + rounded_rect(context, state, sample, style_color(context, backdrop_colors[i]), 9.0f); EditorGUI::end_v_layout(context, sample); } GUI::FlexLayoutDesc backdrop_flex; @@ -1550,9 +1677,9 @@ namespace Luna backdrop_flex.cross_alignment = GUI::FlexAlignment::stretch; backdrop_flex.main_axis_gap = 6.0f; EditorGUI::end_h_layout(context, backdrop_source, backdrop_flex); - led_label(context, id(context, "overlay.notification.success"), "Build complete", + led_label(context, state, id(context, "overlay.notification.success"), "Build complete", "gui.status.success", 150.0f); - led_label(context, id(context, "overlay.notification.warning"), "2 warnings", + led_label(context, state, id(context, "overlay.notification.warning"), "2 warnings", "gui.status.warning", 150.0f); end_panel(context, popup_card); end_two_columns(context, row); @@ -1565,7 +1692,7 @@ namespace Luna label(context, id(context, "overlay.popup.title"), "Popup layer", fill_width(34.0f), 13.0f); label(context, id(context, "overlay.popup.copy"), "Dismiss with Done or Escape.", fill_width(34.0f), 11.0f, "gui.text.secondary"); - icon_button(context, id(context, "overlay.popup.done"), EditorGUI::IconName::check, + icon_button(context, state, id(context, "overlay.popup.done"), EditorGUI::IconName::check, "Done", fill_width(44.0f), EditorGUI::ButtonDesc(), true, EditorGUI::IconWeight::bold); lupanic_if_failed(EditorGUI::end_popup(context, popup, RectF(0.0f, 0.0f, 260.0f, 154.0f))); } @@ -1590,18 +1717,18 @@ namespace Luna GUI::ElementHandle workspace = EditorGUI::begin_v_layout(context, id(context, "workspace.demo"), "Workspace", fill_width(660.0f)); f32 workspace_radius = style_scalar(context, "gui.radius.large", 16.0f); - surface(context, "gui.surface.0", workspace_radius, true, true); + surface(context, state, workspace, "gui.surface.0", workspace_radius, true, true); GUI::DrawCommand push_workspace_clip; push_workspace_clip.type = GUI::DrawCommandType::push_clip; push_workspace_clip.rect_reference = GUI::DrawCommandRectReference::element; push_workspace_clip.radius = workspace_radius; - context->draw(push_workspace_clip); + append_showcase_draw_command(context, state, workspace, push_workspace_clip); GUI::DrawConfig workspace_clip; workspace_clip.name = Name("guitest.workspace.rounded_clip"); workspace_clip.callback = finish_rounded_children_clip; workspace_clip.userdata = (void*)(usize)(u32)workspace_radius; workspace_clip.phases = GUI::DrawPhaseFlag::after_children; - context->set_draw_config(workspace, workspace_clip); + append_showcase_draw_callback(context, state, workspace, workspace_clip); GUI::ElementHandle reset_layout_item; GUI::ElementHandle menu_bar = EditorGUI::begin_menu_bar(context, id(context, "workspace.menu"), @@ -1735,21 +1862,21 @@ namespace Luna if(EditorGUI::begin_dock_panel(context, hierarchy_id, "Hierarchy", nullptr, panel_desc)) { - GUI::LayoutConfig panel_layout = grow_y(); - panel_layout.padding = Float4U(10.0f); GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, - id(context, "workspace.hierarchy.content"), "Hierarchy content", panel_layout); - tree_label(context, id(context, "workspace.scene"), EditorGUI::IconName::folder_open, + id(context, "workspace.hierarchy.content"), "Hierarchy content", grow_y()); + tree_label(context, state, id(context, "workspace.scene"), EditorGUI::IconName::folder_open, "Scene", 0, true, true); - tree_label(context, id(context, "workspace.camera"), EditorGUI::IconName::camera, + tree_label(context, state, id(context, "workspace.camera"), EditorGUI::IconName::camera, "Camera", 1, false, false); - tree_label(context, id(context, "workspace.ball"), EditorGUI::IconName::bounding_box, + tree_label(context, state, id(context, "workspace.ball"), EditorGUI::IconName::bounding_box, "Material Ball", 1, false, false, true); end_panel(context, panel); EditorGUI::end_dock_panel(context); } - if(EditorGUI::begin_dock_panel(context, viewport_id, "Viewport", nullptr, panel_desc)) + EditorGUI::DockPanelDesc viewport_panel_desc = panel_desc; + viewport_panel_desc.content_padding = 0.0f; + if(EditorGUI::begin_dock_panel(context, viewport_id, "Viewport", nullptr, viewport_panel_desc)) { GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, id(context, "workspace.viewport.content"), "Viewport content", grow_y()); @@ -1762,7 +1889,7 @@ namespace Luna for(i32 tool_index = 0; tool_index < 3; ++tool_index) { EditorGUI::id_t tool_id = GUI::make_scoped_id(tools.id, (u64)tool_index + 1); - GUI::ElementHandle tool = icon_button(context, tool_id, tool_icons[tool_index], + GUI::ElementHandle tool = icon_button(context, state, tool_id, tool_icons[tool_index], tool_labels[tool_index], fixed(40.0f, 40.0f), EditorGUI::ButtonDesc(), false, tool_index == state.space_mode ? EditorGUI::IconWeight::bold : EditorGUI::IconWeight::regular, tool_index == state.space_mode); @@ -1783,10 +1910,8 @@ namespace Luna } if(EditorGUI::begin_dock_panel(context, material_editor_id, "Material Editor", nullptr, panel_desc)) { - GUI::LayoutConfig panel_layout = grow_y(); - panel_layout.padding = Float4U(12.0f); GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, - id(context, "workspace.material.content"), "Material editor content", panel_layout); + id(context, "workspace.material.content"), "Material editor content", grow_y()); icon_label(context, id(context, "workspace.material.title"), EditorGUI::IconName::palette, "M_Rusted_Metal", fill_width(34.0f), 18.0f, 13.0f); EditorGUI::slider_float(context, id(context, "workspace.material.roughness"), &state.roughness, @@ -1798,10 +1923,8 @@ namespace Luna } if(EditorGUI::begin_dock_panel(context, shader_graph_id, "Shader Graph", nullptr, panel_desc)) { - GUI::LayoutConfig panel_layout = grow_y(); - panel_layout.padding = Float4U(12.0f); GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, - id(context, "workspace.shader.content"), "Shader graph content", panel_layout); + id(context, "workspace.shader.content"), "Shader graph content", grow_y()); icon_label(context, id(context, "workspace.shader.title"), EditorGUI::IconName::code, "Surface to PBR Output", fill_width(40.0f), 18.0f, 13.0f); label(context, id(context, "workspace.shader.note"), @@ -1813,10 +1936,8 @@ namespace Luna if(EditorGUI::begin_dock_panel(context, inspector_id, "Inspector", nullptr, panel_desc)) { - GUI::LayoutConfig panel_layout = grow_y(); - panel_layout.padding = Float4U(12.0f); GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, - id(context, "workspace.inspector.content"), "Inspector content", panel_layout); + id(context, "workspace.inspector.content"), "Inspector content", grow_y()); icon_label(context, id(context, "workspace.inspector.transform"), EditorGUI::IconName::bounding_box, "Transform", fill_width(34.0f), 18.0f, 12.0f); EditorGUI::drag_float3(context, id(context, "workspace.position"), state.position, -100.0f, 100.0f, @@ -1830,10 +1951,8 @@ namespace Luna } if(EditorGUI::begin_dock_panel(context, layers_id, "Layers", nullptr, panel_desc)) { - GUI::LayoutConfig panel_layout = grow_y(); - panel_layout.padding = Float4U(12.0f); GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, - id(context, "workspace.layers.content"), "Layers content", panel_layout); + id(context, "workspace.layers.content"), "Layers content", grow_y()); EditorGUI::checkbox(context, id(context, "workspace.layers.geometry"), "Geometry", &state.checkbox_value, fill_width(40.0f)); EditorGUI::checkbox(context, id(context, "workspace.layers.lighting"), "Lighting", @@ -1844,10 +1963,8 @@ namespace Luna if(EditorGUI::begin_dock_panel(context, console_id, "Console", nullptr, panel_desc)) { - GUI::LayoutConfig panel_layout = grow_y(); - panel_layout.padding = Float4U(10.0f); GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, - id(context, "workspace.console.content"), "Console content", panel_layout); + id(context, "workspace.console.content"), "Console content", grow_y()); icon_label(context, id(context, "workspace.console.info"), EditorGUI::IconName::info, "Dock workspace initialized.", fill_width(30.0f), 15.0f, 10.0f, "gui.text.secondary"); icon_label(context, id(context, "workspace.console.warning"), EditorGUI::IconName::warning, @@ -1858,10 +1975,8 @@ namespace Luna } if(EditorGUI::begin_dock_panel(context, build_id, "Build", nullptr, panel_desc)) { - GUI::LayoutConfig panel_layout = grow_y(); - panel_layout.padding = Float4U(10.0f); GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, - id(context, "workspace.build.content"), "Build content", panel_layout); + id(context, "workspace.build.content"), "Build content", grow_y()); icon_label(context, id(context, "workspace.build.title"), EditorGUI::IconName::spinner_gap, "Material bake queue", fill_width(30.0f), 15.0f, 10.0f, "gui.text.secondary", EditorGUI::IconWeight::bold); @@ -1878,10 +1993,8 @@ namespace Luna if(EditorGUI::begin_dock_panel(context, pinned_inspector_id, "Inspector (Pinned)", &state.pinned_inspector_open, floating_panel_desc)) { - GUI::LayoutConfig panel_layout = grow_y(); - panel_layout.padding = Float4U(12.0f); GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, - id(context, "workspace.pinned.content"), "Pinned inspector content", panel_layout); + id(context, "workspace.pinned.content"), "Pinned inspector content", grow_y()); label(context, id(context, "workspace.pinned.scale"), "Scale", fill_width(30.0f), 11.0f, "gui.text.secondary"); EditorGUI::drag_float3(context, id(context, "workspace.pinned.position"), state.position, @@ -1905,7 +2018,7 @@ namespace Luna layout.padding = state.section == 0 ? Float4U(18.0f) : Float4U(0.0f); GUI::ElementHandle content = EditorGUI::begin_v_layout(context, id(context, "showcase.content"), "Content", layout); - rounded_rect(context, style_color(context, "gui.canvas"), 0.0f); + rounded_rect(context, state, content, style_color(context, "gui.canvas"), 0.0f); if(state.section == 0) { build_overview(context, state, handles); @@ -1923,7 +2036,7 @@ namespace Luna else if(state.section == 2) build_buttons(context, state); else if(state.section == 3) build_input(context, state); else if(state.section == 4) build_layouts(context, state); - else if(state.section == 5) build_scroll_views(context); + else if(state.section == 5) build_scroll_views(context, state); else if(state.section == 6) build_tables(context, state); else if(state.section == 7) build_overlay(context, state, handles); else build_workspace(context, state); @@ -1970,13 +2083,13 @@ namespace Luna layout.padding = Float4U(14.0f); GUI::ElementHandle panel = EditorGUI::begin_v_layout(context, id(context, "showcase.tokens"), "Style Inspector", layout); - rounded_rect(context, style_color(context, "gui.surface.0"), 0.0f); + rounded_rect(context, state, panel, style_color(context, "gui.surface.0"), 0.0f); GUI::ElementHandle title = EditorGUI::begin_h_layout(context, id(context, "showcase.tokens.title"), "Style Inspector", fill_width(34.0f)); EditorGUI::icon(context, id(context, "showcase.tokens.title.icon"), EditorGUI::IconName::palette, fixed(20.0f, 20.0f), icon_desc(context, "gui.text.color", EditorGUI::IconWeight::duotone, 20.0f)); label(context, id(context, "showcase.tokens.title.text"), "Style Inspector", grow_x(), 13.0f); - icon_button(context, id(context, "showcase.tokens.settings"), EditorGUI::IconName::gear_six, + icon_button(context, state, id(context, "showcase.tokens.settings"), EditorGUI::IconName::gear_six, "Style settings", fixed(30.0f, 30.0f), EditorGUI::ButtonDesc(), false); GUI::FlexLayoutDesc title_flex; title_flex.axis = GUI::LayoutAxis::x; @@ -1987,7 +2100,7 @@ namespace Luna (state.density == 0 ? "dark.compact" : "dark.touch"); label(context, id(context, "showcase.tokens.style"), style_name, fill_width(20.0f), 9.0f, "gui.text.muted"); - divider(context, id(context, "showcase.tokens.divider1")); + divider(context, state, id(context, "showcase.tokens.divider1")); label(context, id(context, "showcase.tokens.accent.title"), "ACCENT STATES", fill_width(24.0f), 9.0f, "gui.text.muted"); token_row(context, state, id(context, "showcase.tokens.accent"), "Accent", "--accent", "gui.accent"); @@ -1996,7 +2109,7 @@ namespace Luna token_row(context, state, id(context, "showcase.tokens.subtle"), "Subtle", "--accent-soft", "gui.accent.subtle"); token_row(context, state, id(context, "showcase.tokens.disabled"), "Disabled", "--accent-disabled", "gui.accent.disabled"); token_row(context, state, id(context, "showcase.tokens.focus"), "Focus", "--focus", "gui.focus"); - divider(context, id(context, "showcase.tokens.divider2")); + divider(context, state, id(context, "showcase.tokens.divider2")); label(context, id(context, "showcase.tokens.gray.title"), "GRAY HIERARCHY", fill_width(24.0f), 9.0f, "gui.text.muted"); token_row(context, state, id(context, "showcase.tokens.surface0"), "Surface 0", "--surface-0", "gui.surface.0"); @@ -2005,7 +2118,7 @@ namespace Luna token_row(context, state, id(context, "showcase.tokens.surface3"), "Surface 3", "--surface-3", "gui.surface.3"); token_row(context, state, id(context, "showcase.tokens.surface4"), "Surface 4", "--surface-4", "gui.surface.4"); token_row(context, state, id(context, "showcase.tokens.surface5"), "Surface 5", "--surface-5", "gui.surface.5"); - divider(context, id(context, "showcase.tokens.divider3")); + divider(context, state, id(context, "showcase.tokens.divider3")); label(context, id(context, "showcase.tokens.density.title"), "DENSITY", fill_width(24.0f), 9.0f, "gui.text.muted"); c8 density_text[96]; @@ -2021,7 +2134,7 @@ namespace Luna state.density == 1 ? 16 : 10); label(context, id(context, "showcase.tokens.density.gap"), density_text, fill_width(28.0f), 10.0f, "gui.text.secondary"); - divider(context, id(context, "showcase.tokens.divider4")); + divider(context, state, id(context, "showcase.tokens.divider4")); label(context, id(context, "showcase.tokens.note"), "Soft shadow is rendered analytically. Blur surfaces use ordinary alpha in this phase.", fill_width(72.0f), 10.0f, "gui.text.secondary"); @@ -2032,10 +2145,11 @@ namespace Luna GUI::ElementHandle build_showcase(GUI::IContext* context, ShowcaseState& state, ShowcaseHandles& handles) { + state.frame_draw_data.clear(); context->push_layer(1, Float2U(0.0f)); GUI::ElementHandle root = EditorGUI::begin_v_layout(context, id(context, "showcase.root"), "GUI Design Language Lab", fill()); - rounded_rect(context, style_color(context, "gui.canvas"), 0.0f); + rounded_rect(context, state, root, style_color(context, "gui.canvas"), 0.0f); build_top_bar(context, state); GUI::ElementHandle body = EditorGUI::begin_h_layout(context, id(context, "showcase.body"), "Application body", grow_y()); diff --git a/Tests/EditorGUITest/Source/Showcase.hpp b/Tests/EditorGUITest/Source/Showcase.hpp index 2a4d1022..c7d14e00 100644 --- a/Tests/EditorGUITest/Source/Showcase.hpp +++ b/Tests/EditorGUITest/Source/Showcase.hpp @@ -10,11 +10,26 @@ #pragma once #include #include +#include namespace Luna { namespace EditorGUITest { + struct ShowcaseDrawStep + { + GUI::DrawCommand command; + GUI::DrawCallback callback = nullptr; + void* userdata = nullptr; + bool is_callback = false; + }; + + struct ShowcaseDrawData + { + Vector before_children; + Vector after_children; + }; + struct ShowcaseState { i32 section = 0; @@ -53,6 +68,7 @@ namespace Luna Ref material_concrete; Ref circle_buffer; GUI::ShapeDesc circle; + Vector> frame_draw_data; }; struct ShowcaseHandles diff --git a/Tests/GUITest/Source/BlurSection.cpp b/Tests/GUITest/Source/BlurSection.cpp index 478494a8..e91eb0f9 100644 --- a/Tests/GUITest/Source/BlurSection.cpp +++ b/Tests/GUITest/Source/BlurSection.cpp @@ -107,25 +107,27 @@ namespace Luna::GUITest } }; - void draw_backdrop(GUI::IContext* context, const RectF& rect, f32 radius) + void draw_backdrop(GUI::IContext* context, const RectF& rect, f32 radius, + GUI::paint_order_id_t paint_order_id) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::backdrop_blur; command.rect_reference = GUI::DrawCommandRectReference::element; command.rect = rect; command.radius = radius; - context->draw(command); + context->draw(command, paint_order_id); } - RV draw_animated_background(GUI::IContext* context, - const GUI::ElementHandle&, GUI::DrawPhase, void* userdata) + R draw_animated_background(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { SheetState* state = (SheetState*)userdata; if(!state) return E_BAD_ARGUMENTS; f32 time = state->animation_time; draw_rect(context, RectF(0.0f, 0.0f, SECTION_WIDTH, SECTION_HEIGHT), - Float4U(0.095f, 0.106f, 0.132f, 1.0f)); + Float4U(0.095f, 0.106f, 0.132f, 1.0f), 0.0f, paint_order_id); struct BackdropShape { @@ -178,13 +180,15 @@ namespace Luna::GUITest cos(time * shape.speed * 0.73f + shape.phase * 1.41f) * shape.travel.y; draw_rect(context, RectF(x, y, shape.rect.width, shape.rect.height), - shape.color, shape.circle ? shape.rect.width * 0.5f : 0.0f); + shape.color, shape.circle ? shape.rect.width * 0.5f : 0.0f, + paint_order_id + 1); } - return ok; + return paint_order_id + 1; } - RV draw_glass_panel(GUI::IContext* context, - const GUI::ElementHandle&, GUI::DrawPhase, void* userdata) + R draw_glass_panel(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { const GlassMaterialSample* material = (const GlassMaterialSample*)userdata; @@ -195,15 +199,15 @@ namespace Luna::GUITest shadow.softness = 18.0f; shadow.spread = -2.0f; draw_shadow(context, RectF(0.0f, 0.0f, PANEL_WIDTH, PANEL_HEIGHT), - Float4U(0.0f, 0.0f, 0.0f, 0.28f), PANEL_RADIUS, shadow); + Float4U(0.0f, 0.0f, 0.0f, 0.28f), PANEL_RADIUS, shadow, paint_order_id); draw_rect(context, RectF(0.0f, 0.0f, PANEL_WIDTH, PANEL_HEIGHT), - material->border, PANEL_RADIUS); + material->border, PANEL_RADIUS, paint_order_id + 1); draw_backdrop(context, RectF(1.0f, 1.0f, PANEL_WIDTH - 2.0f, PANEL_HEIGHT - 2.0f), - PANEL_RADIUS - 1.0f); + PANEL_RADIUS - 1.0f, paint_order_id + 2); draw_rect(context, RectF(1.0f, 1.0f, PANEL_WIDTH - 2.0f, PANEL_HEIGHT - 2.0f), - material->tint, PANEL_RADIUS - 1.0f); + material->tint, PANEL_RADIUS - 1.0f, paint_order_id + 3); u32 grain_count = (u32)(material->grain * 32.0f); for(u32 i = 0; i < grain_count; ++i) @@ -213,19 +217,22 @@ namespace Luna::GUITest f32 y = 12.0f + (f32)((hash >> 9u) % 132u); f32 alpha = 0.025f + (f32)(hash & 3u) * 0.012f; draw_rect(context, RectF(x, y, 1.5f, 1.5f), - Float4U(1.0f, 1.0f, 1.0f, alpha), 0.75f); + Float4U(1.0f, 1.0f, 1.0f, alpha), 0.75f, paint_order_id + 4); } draw_rect(context, RectF(12.0f, 142.0f, PANEL_WIDTH - 24.0f, 87.0f), - Float4U(0.015f, 0.025f, 0.045f, 0.24f), 16.0f); + Float4U(0.015f, 0.025f, 0.045f, 0.24f), 16.0f, paint_order_id + 5); draw_text(context, RectF(24.0f, 151.0f, PANEL_WIDTH - 48.0f, 34.0f), - material->title, 25.0f, Float4U(1.0f, 1.0f, 1.0f, 1.0f)); + material->title, 25.0f, Float4U(1.0f, 1.0f, 1.0f, 1.0f), + VG::TextAlignment::begin, paint_order_id + 6); draw_text(context, RectF(24.0f, 183.0f, PANEL_WIDTH - 48.0f, 24.0f), - material->caption, 14.0f, Float4U(0.94f, 0.97f, 1.0f, 0.84f)); + material->caption, 14.0f, Float4U(0.94f, 0.97f, 1.0f, 0.84f), + VG::TextAlignment::begin, paint_order_id + 6); draw_text(context, RectF(24.0f, 208.0f, PANEL_WIDTH - 48.0f, 18.0f), material->parameters, 11.5f, - Float4U(0.90f, 0.95f, 1.0f, 0.72f)); - return ok; + Float4U(0.90f, 0.95f, 1.0f, 0.72f), VG::TextAlignment::begin, + paint_order_id + 6); + return paint_order_id + 6; } void build_glass_panel(GUI::IContext* context, GUI::id_t id, diff --git a/Tests/GUITest/Source/GUITest.hpp b/Tests/GUITest/Source/GUITest.hpp index f352bf72..848d3084 100644 --- a/Tests/GUITest/Source/GUITest.hpp +++ b/Tests/GUITest/Source/GUITest.hpp @@ -9,6 +9,7 @@ */ #pragma once #include +#include #include namespace Luna::GUITest @@ -68,6 +69,12 @@ namespace Luna::GUITest ID_LAYOUT_DEMO_BASE = 1000 }; + struct FrameDrawData + { + Vector before_children; + Vector after_children; + }; + struct SheetState { u32 slice_index = 0; @@ -95,23 +102,49 @@ namespace Luna::GUITest GUI::CanvasLayoutDesc sdf_canvas; Vector blur_items; GUI::CanvasLayoutDesc blur_canvas; + Vector> frame_draw_data; + Vector frame_draw_element_stack; + Vector frame_draw_data_stack; + Vector frame_draw_has_children_stack; }; + void begin_draw_recording(SheetState& state); + void end_draw_recording(); + GUI::ElementHandle begin_element(GUI::IContext* context, GUI::id_t id); + void end_element(GUI::IContext* context); + void record_draw_command(GUI::IContext* context, const GUI::DrawCommand& command); + GUI::LayoutConfig fixed_layout(f32 width, f32 height); void set_canvas_layout(GUI::IContext* context, const GUI::ElementHandle& element, GUI::CanvasLayoutDesc* desc); void add_canvas_item(Vector& items, GUI::id_t id, f32 x, f32 y); - void draw_rect(GUI::IContext* context, const RectF& rect, const Float4U& color, f32 radius = 0.0f); + void draw_rect(GUI::IContext* context, const RectF& rect, const Float4U& color, + f32 radius = 0.0f); + void draw_rect(GUI::IContext* context, const RectF& rect, const Float4U& color, + f32 radius, GUI::paint_order_id_t paint_order_id); void draw_shadow(GUI::IContext* context, const RectF& rect, const Float4U& color, f32 radius, const GUI::ShadowDesc& desc); + void draw_shadow(GUI::IContext* context, const RectF& rect, const Float4U& color, + f32 radius, const GUI::ShadowDesc& desc, GUI::paint_order_id_t paint_order_id); void draw_gradient_rect(GUI::IContext* context, const RectF& rect, const Float4U& top_left, const Float4U& top_right, const Float4U& bottom_right, const Float4U& bottom_left); + void draw_gradient_rect(GUI::IContext* context, const RectF& rect, const Float4U& top_left, + const Float4U& top_right, const Float4U& bottom_right, const Float4U& bottom_left, + GUI::paint_order_id_t paint_order_id); void draw_line(GUI::IContext* context, const Float2U& begin, const Float2U& end, const Float4U& color, f32 width = 1.0f); - void draw_outline(GUI::IContext* context, const RectF& rect, const Float4U& color, f32 width = 1.0f); + void draw_line(GUI::IContext* context, const Float2U& begin, const Float2U& end, + const Float4U& color, f32 width, GUI::paint_order_id_t paint_order_id); + void draw_outline(GUI::IContext* context, const RectF& rect, const Float4U& color, + f32 width = 1.0f); + void draw_outline(GUI::IContext* context, const RectF& rect, const Float4U& color, + f32 width, GUI::paint_order_id_t paint_order_id); void draw_text(GUI::IContext* context, const RectF& rect, const c8* text, f32 size, const Float4U& color, VG::TextAlignment alignment = VG::TextAlignment::begin); + void draw_text(GUI::IContext* context, const RectF& rect, const c8* text, f32 size, + const Float4U& color, VG::TextAlignment alignment, + GUI::paint_order_id_t paint_order_id); void bullet(GUI::IContext* context, f32 x, f32 y, const c8* text); GUI::ElementHandle begin_panel(GUI::IContext* context, GUI::id_t id, const c8* title, @@ -121,8 +154,9 @@ namespace Luna::GUITest bool circle_hit_test(const GUI::IContext*, const GUI::ElementHitTestRequest& request, void*); void set_interactable(GUI::IContext* context, const GUI::ElementHandle& element, GUI::PointerHitBehavior hit_behavior, GUI::InteractableFlag flags); - RV draw_sheet_callback(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase phase, void* userdata); + R draw_sheet_callback(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata); void build_slide_header(GUI::IContext* context, const SheetState& state); void build_pointer_input_slice(GUI::IContext* context, SheetState& state); diff --git a/Tests/GUITest/Source/HeaderSection.cpp b/Tests/GUITest/Source/HeaderSection.cpp index 0d6865f6..ddd94917 100644 --- a/Tests/GUITest/Source/HeaderSection.cpp +++ b/Tests/GUITest/Source/HeaderSection.cpp @@ -54,7 +54,7 @@ namespace Luna::GUITest break; } - GUI::ElementHandle header = context->begin_element(ID_HEADER); + GUI::ElementHandle header = begin_element(context, ID_HEADER); context->set_layout_config(header, fixed_layout(SHEET_WIDTH - 96.0f, 108.0f)); draw_text(context, RectF(0.0f, 0.0f, 760.0f, 56.0f), title, 46.0f, Float4U(0.0f, 0.0f, 0.0f, 1.0f)); draw_text(context, RectF(2.0f, 60.0f, 980.0f, 34.0f), subtitle, 22.0f, Float4U(0.20f, 0.20f, 0.20f, 1.0f)); @@ -67,6 +67,6 @@ namespace Luna::GUITest Float4U(0.0f, 0.0f, 0.0f, 1.0f), VG::TextAlignment::end); draw_text(context, RectF(SHEET_WIDTH - 420.0f, 62.0f, 320.0f, 28.0f), "Z previous X next", 18.0f, Float4U(0.30f, 0.30f, 0.30f, 1.0f), VG::TextAlignment::end); - context->end_element(); + end_element(context); } } diff --git a/Tests/GUITest/Source/InputSection.cpp b/Tests/GUITest/Source/InputSection.cpp index 90cb94c4..d56ada48 100644 --- a/Tests/GUITest/Source/InputSection.cpp +++ b/Tests/GUITest/Source/InputSection.cpp @@ -17,7 +17,7 @@ namespace Luna::GUITest void input_sample(GUI::IContext* context, GUI::id_t id, const c8* label, const Float4U& color, GUI::PointerHitBehavior behavior, bool circle = false) { - GUI::ElementHandle element = context->begin_element(id); + GUI::ElementHandle element = begin_element(context, id); context->set_layout_config(element, fixed_layout(circle ? 132.0f : 220.0f, circle ? 132.0f : 92.0f)); set_interactable(context, element, behavior, GUI::InteractableFlag::hoverable | GUI::InteractableFlag::activatable | @@ -43,7 +43,7 @@ namespace Luna::GUITest draw_outline(context, RectF(0.0f, 0.0f, 220.0f, 92.0f), Float4U(0.0f, 0.0f, 0.0f, 1.0f), 1.25f); draw_text(context, RectF(14.0f, 32.0f, 192.0f, 30.0f), label, 21.0f, text, VG::TextAlignment::center); } - context->end_element(); + end_element(context); } const c8* key_name(KeyCode key) @@ -200,7 +200,7 @@ namespace Luna::GUITest void ime_input_element(GUI::IContext* context, SheetState& state) { - GUI::ElementHandle element = context->begin_element(ID_IME_INPUT); + GUI::ElementHandle element = begin_element(context, ID_IME_INPUT); context->set_layout_config(element, fixed_layout(560.0f, 68.0f)); set_interactable(context, element, GUI::PointerHitBehavior::target, GUI::InteractableFlag::hoverable | GUI::InteractableFlag::activatable | @@ -235,7 +235,7 @@ namespace Luna::GUITest { context->request_text_input(element, (i32)state.ime_text.size()); } - context->end_element(); + end_element(context); } void narrow_bullet(GUI::IContext* context, f32 x, f32 y, const c8* text) @@ -271,7 +271,7 @@ namespace Luna::GUITest void navigation_sample(GUI::IContext* context, GUI::id_t id, const c8* label, SheetState& sheet_state, bool submenu_item = false) { - GUI::ElementHandle element = context->begin_element(id); + GUI::ElementHandle element = begin_element(context, id); context->set_layout_config(element, fixed_layout(submenu_item ? 260.0f : 154.0f, submenu_item ? 74.0f : 82.0f)); set_interactable(context, element, GUI::PointerHitBehavior::target, GUI::InteractableFlag::hoverable | GUI::InteractableFlag::activatable | @@ -299,7 +299,7 @@ namespace Luna::GUITest draw_rect(context, RectF(0.0f, 0.0f, width, height), fill, 4.0f); draw_outline(context, RectF(0.0f, 0.0f, width, height), Float4U(0.0f, 0.0f, 0.0f, 1.0f), interaction.focused ? 3.0f : 1.25f); draw_text(context, RectF(10.0f, height * 0.5f - 14.0f, width - 20.0f, 30.0f), label, 22.0f, text, VG::TextAlignment::center); - context->end_element(); + end_element(context); } bool id_in_range(GUI::id_t id, GUI::id_t begin, u32 count) @@ -322,7 +322,7 @@ namespace Luna::GUITest void pointer_demo_element(GUI::IContext* context, GUI::id_t id, f32 width, f32 height, const c8* name, const c8* behavior, GUI::PointerHitBehavior hit_behavior, const c8* note = nullptr) { - GUI::ElementHandle element = context->begin_element(id); + GUI::ElementHandle element = begin_element(context, id); context->set_layout_config(element, fixed_layout(width, height)); if(hit_behavior != GUI::PointerHitBehavior::none) { @@ -338,13 +338,13 @@ namespace Luna::GUITest Float4U text = highlighted ? Float4U(1.0f, 1.0f, 1.0f, 1.0f) : Float4U(0.0f, 0.0f, 0.0f, 1.0f); draw_rect(context, RectF(0.0f, 0.0f, width, height), fill, 0.0f); pointer_label(context, 26.0f, note ? 16.0f : 14.0f, name, behavior, note, text, 23.0f); - context->end_element(); + end_element(context); } } void build_pointer_input_slice(GUI::IContext* context, SheetState& state) { - GUI::ElementHandle panel = context->begin_element(ID_INPUT); + GUI::ElementHandle panel = begin_element(context, ID_INPUT); context->set_layout_config(panel, fixed_layout(SHEET_WIDTH - 128.0f, 528.0f)); state.pointer_items.clear(); add_canvas_item(state.pointer_items, ID_POINTER_BASE_LAYER, 0.0f, 0.0f); @@ -356,7 +356,7 @@ namespace Luna::GUITest add_canvas_item(state.pointer_base_items, ID_POINTER_A, 62.0f, 104.0f); add_canvas_item(state.pointer_base_items, ID_POINTER_B, 320.0f, 104.0f); - GUI::ElementHandle base = context->begin_element(ID_POINTER_BASE_LAYER); + GUI::ElementHandle base = begin_element(context, ID_POINTER_BASE_LAYER); context->set_layout_config(base, fixed_layout(1160.0f, 290.0f)); draw_rect(context, RectF(0.0f, 0.0f, 1160.0f, 290.0f), Float4U(0.05f, 0.92f, 0.48f, 1.0f), 0.0f); pointer_label(context, 28.0f, 26.0f, "Bottom Layer Element", "PHB: none"); @@ -368,13 +368,13 @@ namespace Luna::GUITest state.pointer_base_canvas.default_item = GUI::CanvasLayoutItem(); state.pointer_base_canvas.clip_children = true; set_canvas_layout(context, base, &state.pointer_base_canvas); - context->end_element(); + end_element(context); - GUI::ElementHandle top = context->begin_element(ID_POINTER_TOP_LAYER); + GUI::ElementHandle top = begin_element(context, ID_POINTER_TOP_LAYER); context->set_layout_config(top, fixed_layout(608.0f, 264.0f)); draw_rect(context, RectF(0.0f, 0.0f, 608.0f, 264.0f), Float4U(1.0f, 0.52f, 0.52f, 1.0f), 0.0f); pointer_label(context, 32.0f, 28.0f, "Top Layer Element", "PHB: none"); - context->end_element(); + end_element(context); pointer_demo_element(context, ID_POINTER_C, 206.0f, 76.0f, "Element C", "PHB: block", GUI::PointerHitBehavior::block); @@ -408,12 +408,12 @@ namespace Luna::GUITest state.pointer_canvas.default_item = GUI::CanvasLayoutItem(); state.pointer_canvas.clip_children = false; set_canvas_layout(context, panel, &state.pointer_canvas); - context->end_element(); + end_element(context); } void build_keyboard_input_slice(GUI::IContext* context, SheetState& state) { - GUI::ElementHandle panel = context->begin_element(ID_KEYBOARD); + GUI::ElementHandle panel = begin_element(context, ID_KEYBOARD); context->set_layout_config(panel, fixed_layout(SHEET_WIDTH - 128.0f, 500.0f)); state.keyboard_items.clear(); add_canvas_item(state.keyboard_items, ID_IME_INPUT, 704.0f, 286.0f); @@ -467,12 +467,12 @@ namespace Luna::GUITest state.keyboard_canvas.default_item = GUI::CanvasLayoutItem(); state.keyboard_canvas.clip_children = false; set_canvas_layout(context, panel, &state.keyboard_canvas); - context->end_element(); + end_element(context); } void build_navigation_input_slice(GUI::IContext* context, SheetState& state) { - GUI::ElementHandle panel = context->begin_element(ID_NAVIGATION); + GUI::ElementHandle panel = begin_element(context, ID_NAVIGATION); context->set_layout_config(panel, fixed_layout(SHEET_WIDTH - 128.0f, 500.0f)); draw_text(context, RectF(0.0f, 0.0f, 620.0f, 46.0f), "Navigation demo", 32.0f, Float4U(0.0f, 0.0f, 0.0f, 1.0f)); @@ -547,6 +547,6 @@ namespace Luna::GUITest state.navigation_canvas.default_item = GUI::CanvasLayoutItem(); state.navigation_canvas.clip_children = false; set_canvas_layout(context, panel, &state.navigation_canvas); - context->end_element(); + end_element(context); } } diff --git a/Tests/GUITest/Source/LayoutSection.cpp b/Tests/GUITest/Source/LayoutSection.cpp index d3c61571..e6fe1390 100644 --- a/Tests/GUITest/Source/LayoutSection.cpp +++ b/Tests/GUITest/Source/LayoutSection.cpp @@ -148,21 +148,21 @@ namespace Luna::GUITest command.horizontal_alignment = VG::TextAlignment::center; command.vertical_alignment = VG::TextAlignment::center; command.text = text ? text : ""; - context->draw(command); + record_draw_command(context, command); } void text_block(GUI::IContext* context, const c8* text) { - GUI::ElementHandle body = context->begin_element(ID_LAYOUT); + GUI::ElementHandle body = begin_element(context, ID_LAYOUT); context->set_layout_config(body, fixed_layout(SHEET_WIDTH - 128.0f, 66.0f)); draw_text(context, RectF(0.0f, 0.0f, 1180.0f, 34.0f), text, 23.0f, Float4U(0.10f, 0.10f, 0.10f, 1.0f)); - context->end_element(); + end_element(context); } void demo_child(GUI::IContext* context, GUI::id_t id, const DemoChild& desc, GUI::LayoutAxis axis) { - GUI::ElementHandle child = context->begin_element(id); + GUI::ElementHandle child = begin_element(context, id); context->set_layout_config(child, flex_child_layout(axis, desc)); if(desc.main_kind == GUI::SizeKind::fit) { @@ -174,13 +174,13 @@ namespace Luna::GUITest } draw_rect(context, RectF(0.0f, 0.0f, 0.0f, 0.0f), desc.color, 0.0f); draw_element_text(context, desc.label, 15.0f); - context->end_element(); + end_element(context); } void flex_case(GUI::IContext* context, GUI::id_t id, const c8* title, f32 width, f32 height, GUI::FlexLayoutDesc* desc, Span children) { - GUI::ElementHandle container = context->begin_element(id); + GUI::ElementHandle container = begin_element(context, id); context->set_layout_config(container, flex_container_layout(width, height)); context->set_layout_callback_config(container, flex_container_callbacks(desc)); draw_text(context, RectF(0.0f, -32.0f, width, 26.0f), title, 20.0f, @@ -191,23 +191,23 @@ namespace Luna::GUITest { demo_child(context, child_id(id, i), children[i], desc->axis); } - context->end_element(); + end_element(context); } void plain_box(GUI::IContext* context, GUI::id_t id, const c8* label, f32 width, f32 height, const Float4U& color = Float4U(0.93f, 0.93f, 0.93f, 1.0f)) { - GUI::ElementHandle child = context->begin_element(id); + GUI::ElementHandle child = begin_element(context, id); context->set_layout_config(child, fixed_layout(width, height)); draw_rect(context, RectF(0.0f, 0.0f, 0.0f, 0.0f), color, 0.0f); draw_element_text(context, label, 17.0f); - context->end_element(); + end_element(context); } void marker_box(GUI::IContext* context, GUI::id_t id, const c8* label, f32 width, f32 height, const Float4U& color, bool outline) { - GUI::ElementHandle marker = context->begin_element(id); + GUI::ElementHandle marker = begin_element(context, id); context->set_layout_config(marker, fixed_layout(width, height)); if(outline) { @@ -219,12 +219,12 @@ namespace Luna::GUITest { draw_rect(context, RectF(0.0f, 0.0f, width, height), color, min(width, height) * 0.5f); } - context->end_element(); + end_element(context); } void concept_case(GUI::IContext* context, GUI::id_t id, const c8* title, const c8* detail) { - GUI::ElementHandle box = context->begin_element(id); + GUI::ElementHandle box = begin_element(context, id); context->set_layout_config(box, fixed_layout(360.0f, 126.0f)); draw_rect(context, RectF(0.0f, 0.0f, 0.0f, 0.0f), Float4U(0.97f, 0.97f, 0.97f, 1.0f), 0.0f); draw_outline(context, RectF(0.0f, 0.0f, 360.0f, 126.0f), Float4U(0.0f, 0.0f, 0.0f, 1.0f), 1.0f); @@ -232,7 +232,7 @@ namespace Luna::GUITest Float4U(0.0f, 0.0f, 0.0f, 1.0f)); draw_text(context, RectF(18.0f, 58.0f, 320.0f, 46.0f), detail, 17.0f, Float4U(0.22f, 0.22f, 0.22f, 1.0f)); - context->end_element(); + end_element(context); } void add_case_grid(SheetState& state, u32 count) @@ -525,7 +525,7 @@ namespace Luna::GUITest columns.column_count = 4; for(u32 c = 0; c < 2; ++c) { - GUI::ElementHandle grid = context->begin_element(demo_id(c)); + GUI::ElementHandle grid = begin_element(context, demo_id(c)); context->set_layout_config(grid, fixed_layout(430.0f, 170.0f)); GUI::LayoutConfig layout = fixed_layout(430.0f, 170.0f); layout.padding = Float4U(10.0f); @@ -545,7 +545,7 @@ namespace Luna::GUITest snprintf(label, sizeof(label), "%u", i + 1); plain_box(context, child_id(demo_id(c), i), label, 48.0f, 36.0f); } - context->end_element(); + end_element(context); } } @@ -586,7 +586,7 @@ namespace Luna::GUITest descs[case_index].items = Span(items[case_index], 3); descs[case_index].clip_children = true; - GUI::ElementHandle canvas = context->begin_element(id); + GUI::ElementHandle canvas = begin_element(context, id); GUI::LayoutConfig layout = fixed_layout(WIDTH, HEIGHT); layout.padding = Float4U(PADDING); context->set_layout_config(canvas, layout); @@ -603,7 +603,7 @@ namespace Luna::GUITest marker_box(context, anchor_id, "anchor point", 18.0f, 18.0f, Float4U(0.0f, 0.36f, 0.95f, 1.0f), true); marker_box(context, pivot_id, "pivot point", 8.0f, 8.0f, Float4U(0.95f, 0.12f, 0.12f, 1.0f), false); set_canvas_layout(context, canvas, &descs[case_index]); - context->end_element(); + end_element(context); } void build_canvas_non_stretch(GUI::IContext* context) @@ -649,7 +649,7 @@ namespace Luna::GUITest f32 anchor_width = content_width * (anchor_max.x - anchor_min.x); f32 anchor_height = content_height * (anchor_max.y - anchor_min.y); - GUI::ElementHandle canvas = context->begin_element(id); + GUI::ElementHandle canvas = begin_element(context, id); GUI::LayoutConfig layout = fixed_layout(width, height); layout.padding = Float4U(PADDING); context->set_layout_config(canvas, layout); @@ -663,13 +663,13 @@ namespace Luna::GUITest Float4U(0.22f, 0.22f, 0.22f, 1.0f), VG::TextAlignment::center); plain_box(context, child, "stretched child", 80.0f, 40.0f, Float4U(0.90f, 0.92f, 0.96f, 0.92f)); - GUI::ElementHandle marker = context->begin_element(anchor_rect); + GUI::ElementHandle marker = begin_element(context, anchor_rect); context->set_layout_config(marker, fixed_layout(anchor_width, anchor_height)); draw_outline(context, RectF(0.0f, 0.0f, anchor_width, anchor_height), Float4U(0.95f, 0.55f, 0.0f, 1.0f), 2.0f); - context->end_element(); + end_element(context); set_canvas_layout(context, canvas, &descs[case_index]); - context->end_element(); + end_element(context); } void build_canvas_stretch(GUI::IContext* context) @@ -692,7 +692,7 @@ namespace Luna::GUITest desc.clip_children = true; GUI::LayoutConfig layout = fixed_layout(SCROLL_VIEWPORT_WIDTH, SCROLL_VIEWPORT_HEIGHT); layout.padding = Float4U(SCROLL_VIEWPORT_PADDING); - GUI::ElementHandle viewport = context->begin_element(demo_id(0)); + GUI::ElementHandle viewport = begin_element(context, demo_id(0)); context->set_layout_config(viewport, layout); GUI::LayoutCallbackConfig callbacks; callbacks.algorithm = Name("gui.test.scroll_viewport"); @@ -731,7 +731,7 @@ namespace Luna::GUITest SCROLL_ROW_WIDTH, SCROLL_ROW_HEIGHT, row_color); context->set_layout_config(context->find_element_handle(child_id(demo_id(0), i)), item); } - context->end_element(); + end_element(context); } void build_table_layout(GUI::IContext* context) @@ -769,7 +769,7 @@ namespace Luna::GUITest desc.gap = Float2U(6.0f, 6.0f); GUI::LayoutConfig layout = fixed_layout(560.0f, 170.0f); layout.padding = Float4U(10.0f); - GUI::ElementHandle table = context->begin_element(demo_id(0)); + GUI::ElementHandle table = begin_element(context, demo_id(0)); context->set_layout_config(table, layout); GUI::LayoutCallbackConfig callbacks; callbacks.algorithm = Name("gui.test.table"); @@ -785,7 +785,7 @@ namespace Luna::GUITest { plain_box(context, child_id(demo_id(0), i), labels[i], 60.0f, 34.0f); } - context->end_element(); + end_element(context); } } diff --git a/Tests/GUITest/Source/Main.cpp b/Tests/GUITest/Source/Main.cpp index 66af238e..07b280f8 100644 --- a/Tests/GUITest/Source/Main.cpp +++ b/Tests/GUITest/Source/Main.cpp @@ -71,15 +71,16 @@ namespace Float4x4U(Float4x4::identity()), hit)); } - RV draw_blur_planning_sample(GUI::IContext* context, - const GUI::ElementHandle&, GUI::DrawPhase, void*) + R draw_blur_planning_sample(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void*) { GUI::DrawCommand command; command.type = GUI::DrawCommandType::backdrop_blur; command.rect_reference = GUI::DrawCommandRectReference::element; - context->draw(command); - return ok; + context->draw(command, paint_order_id); + return paint_order_id; } RV run_blur_planning_case(GUITestApp& app, u32 size, @@ -254,7 +255,7 @@ namespace add_sdf_slice_items(state); } - GUI::ElementHandle sheet = context->begin_element(ID_SHEET); + GUI::ElementHandle sheet = begin_element(context, ID_SHEET); context->set_layout_config(sheet, fixed_layout(SHEET_WIDTH, SHEET_HEIGHT)); GUI::DrawConfig sheet_draw; sheet_draw.name = Name("gui.test.sheet"); @@ -292,7 +293,7 @@ namespace state.sheet_canvas.default_item = GUI::CanvasLayoutItem(); state.sheet_canvas.clip_children = false; set_canvas_layout(context, sheet, &state.sheet_canvas); - context->end_element(); + end_element(context); } GUI::ElementHandle build_frame(GUI::IContext* context, GUITest::SheetState& state) @@ -305,13 +306,15 @@ namespace state.screen_canvas.items = Span(&state.screen_item, 1); state.screen_canvas.clip_children = false; + begin_draw_recording(state); context->push_layer(1, Float2U(0.0f)); - GUI::ElementHandle root = context->begin_element(ID_SCREEN_ROOT); + GUI::ElementHandle root = begin_element(context, ID_SCREEN_ROOT); context->set_layout_config(root, fixed_layout(SHEET_WIDTH, SHEET_HEIGHT)); build_sheet(context, state); set_canvas_layout(context, root, &state.screen_canvas); - context->end_element(); + end_element(context); context->pop_layer(); + end_draw_recording(); return root; } diff --git a/Tests/GUITest/Source/SDFSection.cpp b/Tests/GUITest/Source/SDFSection.cpp index c3e1946f..e1152bce 100644 --- a/Tests/GUITest/Source/SDFSection.cpp +++ b/Tests/GUITest/Source/SDFSection.cpp @@ -138,15 +138,18 @@ namespace Luna::GUITest } } - RV draw_sdf_sample(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase, void* userdata) + R draw_sdf_sample(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase, + GUI::paint_order_id_t paint_order_id, void* userdata) { SDFSample sample = (SDFSample)(u32)(usize)userdata; u32 sample_index = (u32)sample; - if(sample_index >= (u32)SDFSample::count) return ok; + if(sample_index >= (u32)SDFSample::count) return paint_order_id; - draw_rect(context, RectF(), Float4U(0.975f, 0.975f, 0.975f, 1.0f), 8.0f); - draw_rect(context, SAMPLE_RECT, Float4U(0.91f, 0.91f, 0.91f, 1.0f), 7.0f); + draw_rect(context, RectF(), Float4U(0.975f, 0.975f, 0.975f, 1.0f), 8.0f, + paint_order_id); + draw_rect(context, SAMPLE_RECT, Float4U(0.91f, 0.91f, 0.91f, 1.0f), 7.0f, + paint_order_id + 1); Vector shape_floats; build_sample_shape(shape_floats, sample); @@ -163,7 +166,8 @@ namespace Luna::GUITest {0.0f, highlight, 0.42f}, {0.5f, accent, 0.58f}, {1.0f, accent_dark, 0.5f} }; - auto submit_color = [&](Vector& color_floats) -> RV + auto submit_color = [&](Vector& color_floats, + GUI::paint_order_id_t color_order) -> RV { auto color = context->append_sdf_color_program(color_floats.cspan()); if(failed(color)) return color.errcode(); @@ -173,7 +177,7 @@ namespace Luna::GUITest command.rect = SAMPLE_RECT; command.sdf.shape = shape.get(); command.sdf.color = color.get(); - context->draw(command); + context->draw(command, color_order); return ok; }; @@ -228,23 +232,25 @@ namespace Luna::GUITest Float2U(SAMPLE_RECT.width, SAMPLE_RECT.height), Span(stops, 3)); } - RV color_result = submit_color(color_floats); - if(failed(color_result)) return color_result; + RV color_result = submit_color(color_floats, paint_order_id + 2); + if(failed(color_result)) return color_result.errcode(); if(sample >= SDFSample::no_clip) { Vector boundary_floats; sdf_color_add_solid(boundary_floats, accent_dark, SDFClipDesc::stroke(1.5f)); - RV result = submit_color(boundary_floats); - if(failed(result)) return result; + RV result = submit_color(boundary_floats, paint_order_id + 3); + if(failed(result)) return result.errcode(); } draw_outline(context, RectF(0.0f, 0.0f, TILE_WIDTH, TILE_HEIGHT), - Float4U(0.77f, 0.77f, 0.77f, 1.0f), 1.0f); + Float4U(0.77f, 0.77f, 0.77f, 1.0f), 1.0f, paint_order_id + 4); draw_text(context, RectF(10.0f, 6.0f, TILE_WIDTH - 20.0f, 18.0f), - SAMPLE_TITLES[sample_index], 15.0f, Float4U(0.03f, 0.03f, 0.03f, 1.0f)); + SAMPLE_TITLES[sample_index], 15.0f, Float4U(0.03f, 0.03f, 0.03f, 1.0f), + VG::TextAlignment::begin, paint_order_id + 4); draw_text(context, RectF(10.0f, 23.0f, TILE_WIDTH - 20.0f, 14.0f), - SAMPLE_OPCODES[sample_index], 11.0f, Float4U(0.38f, 0.38f, 0.38f, 1.0f)); - return ok; + SAMPLE_OPCODES[sample_index], 11.0f, Float4U(0.38f, 0.38f, 0.38f, 1.0f), + VG::TextAlignment::begin, paint_order_id + 4); + return paint_order_id + 4; } void build_sdf_sample(GUI::IContext* context, SDFSample sample) diff --git a/Tests/GUITest/Source/Shared.cpp b/Tests/GUITest/Source/Shared.cpp index e709224e..7c1e7588 100644 --- a/Tests/GUITest/Source/Shared.cpp +++ b/Tests/GUITest/Source/Shared.cpp @@ -11,6 +11,120 @@ namespace Luna::GUITest { + static SheetState* g_draw_recording_state = nullptr; + + static R draw_recorded_commands(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase phase, GUI::paint_order_id_t paint_order_id, + void* userdata) + { + FrameDrawData* data = (FrameDrawData*)userdata; + if(!data) return paint_order_id; + const Vector& commands = phase == GUI::DrawPhase::before_children ? + data->before_children : data->after_children; + if(commands.empty()) return paint_order_id; + for(usize i = 0; i < commands.size(); ++i) + { + context->draw(commands[i], paint_order_id + (GUI::paint_order_id_t)i); + } + return paint_order_id + (GUI::paint_order_id_t)commands.size() - 1; + } + + static void submit_draw_command(GUI::IContext* context, const GUI::DrawCommand& command, + GUI::paint_order_id_t paint_order_id) + { + context->draw(command, paint_order_id); + } + + void begin_draw_recording(SheetState& state) + { + luassert(!g_draw_recording_state); + state.frame_draw_data.clear(); + state.frame_draw_element_stack.clear(); + state.frame_draw_data_stack.clear(); + state.frame_draw_has_children_stack.clear(); + g_draw_recording_state = &state; + } + + void end_draw_recording() + { + luassert(g_draw_recording_state); + luassert(g_draw_recording_state->frame_draw_element_stack.empty()); + luassert(g_draw_recording_state->frame_draw_data_stack.empty()); + luassert(g_draw_recording_state->frame_draw_has_children_stack.empty()); + g_draw_recording_state = nullptr; + } + + GUI::ElementHandle begin_element(GUI::IContext* context, GUI::id_t id) + { + GUI::ElementHandle element = context->begin_element(id); + if(g_draw_recording_state) + { + if(!g_draw_recording_state->frame_draw_has_children_stack.empty()) + { + FrameDrawData* parent_data = + g_draw_recording_state->frame_draw_data_stack.back(); + luassert(!parent_data || parent_data->after_children.empty()); + g_draw_recording_state->frame_draw_has_children_stack.back() = true; + } + g_draw_recording_state->frame_draw_element_stack.push_back(element); + g_draw_recording_state->frame_draw_data_stack.push_back(nullptr); + g_draw_recording_state->frame_draw_has_children_stack.push_back(false); + } + return element; + } + + void end_element(GUI::IContext* context) + { + if(g_draw_recording_state) + { + luassert(!g_draw_recording_state->frame_draw_element_stack.empty()); + luassert(!g_draw_recording_state->frame_draw_data_stack.empty()); + luassert(!g_draw_recording_state->frame_draw_has_children_stack.empty()); + g_draw_recording_state->frame_draw_element_stack.pop_back(); + g_draw_recording_state->frame_draw_data_stack.pop_back(); + g_draw_recording_state->frame_draw_has_children_stack.pop_back(); + } + context->end_element(); + } + + void record_draw_command(GUI::IContext* context, const GUI::DrawCommand& command) + { + luassert(g_draw_recording_state); + luassert(!g_draw_recording_state->frame_draw_element_stack.empty()); + luassert(!g_draw_recording_state->frame_draw_has_children_stack.empty()); + bool after_children = g_draw_recording_state->frame_draw_has_children_stack.back(); + FrameDrawData*& data = g_draw_recording_state->frame_draw_data_stack.back(); + if(!data) + { + GUI::ElementHandle element = g_draw_recording_state->frame_draw_element_stack.back(); + GUI::DrawConfig existing = context->get_draw_config(element); + luassert(!existing.callback); + UniquePtr new_data(memnew()); + data = new_data.get(); + g_draw_recording_state->frame_draw_data.push_back(move(new_data)); + GUI::DrawConfig draw; + draw.name = Name("gui.test.recorded"); + draw.callback = draw_recorded_commands; + draw.userdata = data; + draw.phases = after_children ? GUI::DrawPhaseFlag::after_children : + GUI::DrawPhaseFlag::before_children; + context->set_draw_config(element, draw); + } + Vector& commands = after_children ? data->after_children : + data->before_children; + bool enable_both_phases = commands.empty() && + (!data->before_children.empty() || !data->after_children.empty()); + commands.push_back(command); + if(enable_both_phases) + { + GUI::ElementHandle element = g_draw_recording_state->frame_draw_element_stack.back(); + GUI::DrawConfig draw = context->get_draw_config(element); + draw.phases = GUI::DrawPhaseFlag::before_children | + GUI::DrawPhaseFlag::after_children; + context->set_draw_config(element, draw); + } + } + GUI::LayoutConfig fixed_layout(f32 width, f32 height) { GUI::LayoutConfig layout; @@ -42,7 +156,7 @@ namespace Luna::GUITest items.push_back(item); } - void draw_rect(GUI::IContext* context, const RectF& rect, const Float4U& color, f32 radius) + static GUI::DrawCommand make_rect_command(const RectF& rect, const Float4U& color, f32 radius) { GUI::DrawCommand command; command.type = radius > 0.0f ? GUI::DrawCommandType::rounded_rect : GUI::DrawCommandType::rect; @@ -50,10 +164,21 @@ namespace Luna::GUITest command.rect = rect; command.color = color; command.radius = radius; - context->draw(command); + return command; } - void draw_shadow(GUI::IContext* context, const RectF& rect, const Float4U& color, + void draw_rect(GUI::IContext* context, const RectF& rect, const Float4U& color, f32 radius) + { + record_draw_command(context, make_rect_command(rect, color, radius)); + } + + void draw_rect(GUI::IContext* context, const RectF& rect, const Float4U& color, f32 radius, + GUI::paint_order_id_t paint_order_id) + { + submit_draw_command(context, make_rect_command(rect, color, radius), paint_order_id); + } + + static GUI::DrawCommand make_shadow_command(const RectF& rect, const Float4U& color, f32 radius, const GUI::ShadowDesc& desc) { GUI::DrawCommand command; @@ -63,10 +188,22 @@ namespace Luna::GUITest command.color = color; command.radius = radius; command.shadow = desc; - context->draw(command); + return command; } - void draw_gradient_rect(GUI::IContext* context, const RectF& rect, const Float4U& top_left, + void draw_shadow(GUI::IContext* context, const RectF& rect, const Float4U& color, + f32 radius, const GUI::ShadowDesc& desc) + { + record_draw_command(context, make_shadow_command(rect, color, radius, desc)); + } + + void draw_shadow(GUI::IContext* context, const RectF& rect, const Float4U& color, + f32 radius, const GUI::ShadowDesc& desc, GUI::paint_order_id_t paint_order_id) + { + submit_draw_command(context, make_shadow_command(rect, color, radius, desc), paint_order_id); + } + + static GUI::DrawCommand make_gradient_rect_command(const RectF& rect, const Float4U& top_left, const Float4U& top_right, const Float4U& bottom_right, const Float4U& bottom_left) { GUI::DrawCommand command; @@ -77,10 +214,26 @@ namespace Luna::GUITest command.color_top_right = top_right; command.color_bottom_right = bottom_right; command.color_bottom_left = bottom_left; - context->draw(command); + return command; } - void draw_line(GUI::IContext* context, const Float2U& begin, const Float2U& end, + void draw_gradient_rect(GUI::IContext* context, const RectF& rect, const Float4U& top_left, + const Float4U& top_right, const Float4U& bottom_right, const Float4U& bottom_left) + { + record_draw_command(context, + make_gradient_rect_command(rect, top_left, top_right, bottom_right, bottom_left)); + } + + void draw_gradient_rect(GUI::IContext* context, const RectF& rect, const Float4U& top_left, + const Float4U& top_right, const Float4U& bottom_right, const Float4U& bottom_left, + GUI::paint_order_id_t paint_order_id) + { + submit_draw_command(context, + make_gradient_rect_command(rect, top_left, top_right, bottom_right, bottom_left), + paint_order_id); + } + + static GUI::DrawCommand make_line_command(const Float2U& begin, const Float2U& end, const Float4U& color, f32 width) { GUI::DrawCommand command; @@ -90,7 +243,19 @@ namespace Luna::GUITest command.point1 = end; command.color = color; command.line_width = width; - context->draw(command); + return command; + } + + void draw_line(GUI::IContext* context, const Float2U& begin, const Float2U& end, + const Float4U& color, f32 width) + { + record_draw_command(context, make_line_command(begin, end, color, width)); + } + + void draw_line(GUI::IContext* context, const Float2U& begin, const Float2U& end, + const Float4U& color, f32 width, GUI::paint_order_id_t paint_order_id) + { + submit_draw_command(context, make_line_command(begin, end, color, width), paint_order_id); } void draw_outline(GUI::IContext* context, const RectF& rect, const Float4U& color, f32 width) @@ -105,7 +270,20 @@ namespace Luna::GUITest Float2U(rect.offset_x, rect.offset_y), color, width); } - void draw_text(GUI::IContext* context, const RectF& rect, const c8* text, f32 size, + void draw_outline(GUI::IContext* context, const RectF& rect, const Float4U& color, f32 width, + GUI::paint_order_id_t paint_order_id) + { + draw_line(context, Float2U(rect.offset_x, rect.offset_y), + Float2U(rect.offset_x + rect.width, rect.offset_y), color, width, paint_order_id); + draw_line(context, Float2U(rect.offset_x + rect.width, rect.offset_y), + Float2U(rect.offset_x + rect.width, rect.offset_y + rect.height), color, width, paint_order_id); + draw_line(context, Float2U(rect.offset_x + rect.width, rect.offset_y + rect.height), + Float2U(rect.offset_x, rect.offset_y + rect.height), color, width, paint_order_id); + draw_line(context, Float2U(rect.offset_x, rect.offset_y + rect.height), + Float2U(rect.offset_x, rect.offset_y), color, width, paint_order_id); + } + + static GUI::DrawCommand make_text_command(const RectF& rect, const c8* text, f32 size, const Float4U& color, VG::TextAlignment alignment) { GUI::DrawCommand command; @@ -118,7 +296,20 @@ namespace Luna::GUITest command.horizontal_alignment = alignment; command.vertical_alignment = VG::TextAlignment::begin; command.text = text ? text : ""; - context->draw(command); + return command; + } + + void draw_text(GUI::IContext* context, const RectF& rect, const c8* text, f32 size, + const Float4U& color, VG::TextAlignment alignment) + { + record_draw_command(context, make_text_command(rect, text, size, color, alignment)); + } + + void draw_text(GUI::IContext* context, const RectF& rect, const c8* text, f32 size, + const Float4U& color, VG::TextAlignment alignment, GUI::paint_order_id_t paint_order_id) + { + submit_draw_command(context, make_text_command(rect, text, size, color, alignment), + paint_order_id); } void bullet(GUI::IContext* context, f32 x, f32 y, const c8* text) @@ -130,7 +321,7 @@ namespace Luna::GUITest GUI::ElementHandle begin_panel(GUI::IContext* context, GUI::id_t id, const c8* title, f32 width, f32 height) { - GUI::ElementHandle panel = context->begin_element(id); + GUI::ElementHandle panel = begin_element(context, id); context->set_layout_config(panel, fixed_layout(width, height)); draw_rect(context, RectF(0.0f, 0.0f, 0.0f, 0.0f), Float4U(1.0f, 1.0f, 1.0f, 1.0f), 0.0f); draw_line(context, Float2U(0.0f, 44.0f), Float2U(width, 44.0f), Float4U(0.0f, 0.0f, 0.0f, 1.0f), 1.25f); @@ -141,7 +332,7 @@ namespace Luna::GUITest void end_panel(GUI::IContext* context) { - context->end_element(); + end_element(context); } void panel_label_value(GUI::IContext* context, f32 y, const c8* label, const c8* value) @@ -167,8 +358,9 @@ namespace Luna::GUITest context->set_interactable(element, interactable); } - RV draw_sheet_callback(GUI::IContext* context, const GUI::ElementHandle& element, - GUI::DrawPhase phase, void* userdata) + R draw_sheet_callback(GUI::IContext* context, + const GUI::ElementHandle& element, GUI::DrawPhase phase, + GUI::paint_order_id_t paint_order_id, void* userdata) { (void)element; (void)userdata; @@ -179,15 +371,16 @@ namespace Luna::GUITest shadow.softness = 18.0f; shadow.spread = 1.0f; draw_shadow(context, RectF(0.0f, 0.0f, SHEET_WIDTH, SHEET_HEIGHT), - Float4U(0.0f, 0.0f, 0.0f, 0.24f), 2.0f, shadow); + Float4U(0.0f, 0.0f, 0.0f, 0.24f), 2.0f, shadow, paint_order_id); draw_rect(context, RectF(0.0f, 0.0f, SHEET_WIDTH, SHEET_HEIGHT), - Float4U(1.0f, 1.0f, 1.0f, 1.0f)); + Float4U(1.0f, 1.0f, 1.0f, 1.0f), 0.0f, paint_order_id + 1); + return paint_order_id + 1; } else { draw_outline(context, RectF(0.0f, 0.0f, SHEET_WIDTH, SHEET_HEIGHT), - Float4U(0.74f, 0.74f, 0.74f, 1.0f), 1.0f); + Float4U(0.74f, 0.74f, 0.74f, 1.0f), 1.0f, paint_order_id); } - return ok; + return paint_order_id; } } diff --git a/Tests/GameGUIEditorServiceTest/GameGUIEditorServiceTest.Target.cs b/Tests/GameGUIEditorServiceTest/GameGUIEditorServiceTest.Target.cs new file mode 100644 index 00000000..8ab8fba8 --- /dev/null +++ b/Tests/GameGUIEditorServiceTest/GameGUIEditorServiceTest.Target.cs @@ -0,0 +1,17 @@ +namespace LunaBuild.Core.Targets; + +public sealed class GameGUIEditorServiceTestTargetRules : TargetRules +{ + public GameGUIEditorServiceTestTargetRules() + : base( + name: "GameGUIEditorServiceTest", + targetDirectory: "Tests/GameGUIEditorServiceTest", + rulesPath: "Tests/GameGUIEditorServiceTest/GameGUIEditorServiceTest.Target.cs") + { + SupportedPlatforms(BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Linux); + Category = BuildTargetCategory.Tests; + Kind = BuildTargetKind.Executable; + Sources("Source/**.cpp"); + DependsOn("Runtime", "GameGUI", "GameGUIEditorService", "Frontend", "VariantUtils"); + } +} diff --git a/Tests/GameGUIEditorServiceTest/Source/Main.cpp b/Tests/GameGUIEditorServiceTest/Source/Main.cpp new file mode 100644 index 00000000..073c3d81 --- /dev/null +++ b/Tests/GameGUIEditorServiceTest/Source/Main.cpp @@ -0,0 +1,639 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Main.cpp +* @author JXMaster +* @date 2026/8/26 +*/ +#include "../../../Programs/GameGUIEditor/Service/GameGUIEditorService.hpp" +#include "../../../Programs/GameGUIEditor/Source/InspectorLayout.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Luna; + +#define lutest luassert_always + +namespace +{ + usize g_authoring_migration_count = 0; + const c8* MOUNT_PATH = nullptr; + const c8* SAVE_PATH = nullptr; + const c8* SOURCE_PATH = nullptr; + const c8* COOK_PATH = nullptr; + + String guid_string(const Guid& guid) + { + c8 buffer[GUID_STRING_LENGTH]; + lupanic_if_failed(encode_guid(guid, buffer, sizeof(buffer))); + return String(buffer, sizeof(buffer)); + } + + Variant document_params(const Variant& metadata) + { + Variant params(VariantType::object); + params["document_id"] = metadata["document_id"]; + params["expected_revision"] = metadata["revision"]; + return params; + } + + Variant invoke(Frontend::IFrontend* frontend, const c8* url, + const Variant& params = Variant(VariantType::object)) + { + auto result = frontend->invoke(url, params); + lutest(result.valid()); + return move(result.get()); + } + + Variant command_batch(const Variant& metadata, Variant&& command, + const c8* label, const c8* coalesce_key = "") + { + Variant params = document_params(metadata); + params["label"] = label; + if(coalesce_key[0]) params["coalesce_key"] = coalesce_key; + params["commands"] = Variant(VariantType::array); + params["commands"].push_back(move(command)); + return params; + } + + Variant set_property_command(const Guid& node, const c8* property, + const Variant& value) + { + Variant command(VariantType::object); + command["kind"] = "set_property"; + command["node"] = guid_string(node).c_str(); + command["property"] = property; + command["value"] = value; + return command; + } + + const Variant* find_node_type_schema(const Variant& schemas, const c8* name) + { + for(const Variant& schema : schemas.values()) + { + if(schema["name"].str() == Name(name)) return &schema; + } + return nullptr; + } + + const Variant* find_editing_property(const Variant& schema, const c8* id) + { + for(const Variant& property : schema["properties"].values()) + { + if(property["id"].str() == Name(id)) return &property; + } + return nullptr; + } + + void editing_schema_test(const Variant& schemas) + { + lutest(schemas.type() == VariantType::array && schemas.size() >= 6); + for(const Variant& node_type : schemas.values()) + { + const Variant& property_schema = node_type["property_schema"]; + const Variant& attachment_schema = node_type["child_attachment_schema"]; + lutest(property_schema.type() == VariantType::object && + property_schema["properties"].type() == VariantType::array); + lutest(attachment_schema.type() == VariantType::object && + attachment_schema["properties"].type() == VariantType::array); + const Variant& properties = property_schema["properties"]; + for(usize i = 0; i < properties.size(); ++i) + { + lutest(!properties[i]["id"].str().empty()); + lutest(!properties[i]["display_name"].str().empty()); + for(usize j = i + 1; j < properties.size(); ++j) + lutest(properties[i]["id"].str() != properties[j]["id"].str()); + } + } + + const Variant* flex = find_node_type_schema(schemas, "Flex"); + const Variant* canvas = find_node_type_schema(schemas, "Canvas"); + const Variant* panel = find_node_type_schema(schemas, "Panel"); + const Variant* text = find_node_type_schema(schemas, "Text"); + const Variant* button = find_node_type_schema(schemas, "Button"); + const Variant* asset_instance = find_node_type_schema(schemas, "AssetInstance"); + lutest(flex && canvas && panel && text && button && asset_instance); + + const Variant* width = find_editing_property((*flex)["property_schema"], "width"); + lutest(width && (*width)["section"].str() == Name("layout") && + (*width)["editor"].str() == Name("size") && + (*width)["alternate_id"].str() == Name("width_percent")); + const Variant* wrap = find_editing_property((*flex)["property_schema"], "wrap"); + lutest(wrap && (*wrap)["editor"].str() == Name("enumeration") && + (*wrap)["default_value"].str() == Name("none") && (*wrap)["items"].size() == 3); + const Variant* main_alignment = find_editing_property( + (*flex)["property_schema"], "main_alignment"); + lutest(main_alignment && (*main_alignment)["editor"].str() == Name("enumeration") && + (*main_alignment)["default_value"].str() == Name("start") && + (*main_alignment)["items"].size() == 6); + const Variant* cross_alignment = find_editing_property( + (*flex)["property_schema"], "cross_alignment"); + lutest(cross_alignment && (*cross_alignment)["editor"].str() == Name("enumeration") && + (*cross_alignment)["default_value"].str() == Name("stretch") && + (*cross_alignment)["items"].size() == 4); + const Variant* line_alignment = find_editing_property( + (*flex)["property_schema"], "line_alignment"); + lutest(line_alignment && (*line_alignment)["editor"].str() == Name("enumeration") && + (*line_alignment)["default_value"].str() == Name("start") && + (*line_alignment)["items"].size() == 7); + const Variant* visual_effects = find_editing_property( + (*flex)["property_schema"], "visual_effects"); + lutest(visual_effects && (*visual_effects)["section"].str() == Name("style") && + (*visual_effects)["editor"].str() == Name("visual_effects") && + (*visual_effects)["default_value"].type() == VariantType::array); + const Variant* color = find_editing_property((*panel)["property_schema"], "color"); + lutest(color && (*color)["section"].str() == Name("style") && + (*color)["editor"].str() == Name("color")); + const Variant* content = find_editing_property((*text)["property_schema"], "text"); + lutest(content && (*content)["section"].str() == Name("property")); + const Variant* action = find_editing_property((*button)["property_schema"], "action"); + lutest(action && (*action)["editor"].str() == Name("name")); + const Variant* asset = find_editing_property((*asset_instance)["property_schema"], "asset"); + lutest(asset && (*asset)["editor"].str() == Name("asset") && + (*asset)["asset_type"].str() == GameGUI::get_asset_type()); + + const Variant& canvas_attachment = (*canvas)["child_attachment_schema"]; + lutest(canvas_attachment["properties"].size() == 4); + lutest(find_editing_property(canvas_attachment, "anchor_min")); + lutest(find_editing_property(canvas_attachment, "anchor_max")); + lutest(find_editing_property(canvas_attachment, "offset")); + lutest(find_editing_property(canvas_attachment, "pivot")); + } + + void inspector_stretch_warning_test() + { + using GameGUIEditor::Internal::flex_stretches_child_size; + GameGUIEditor::AuthoringNodeRecord parent; + parent.type = GameGUI::get_flex_node_type(); + parent.properties = Variant(VariantType::object); + lutest(flex_stretches_child_size(parent, "width")); + lutest(!flex_stretches_child_size(parent, "height")); + for(const c8* axis : {"x", "y"}) + { + parent.properties["axis"] = axis; + bool horizontal = !strcmp(axis, "x"); + for(const c8* alignment : {"start", "center", "end", "space_between", + "space_around", "space_evenly"}) + { + parent.properties["cross_alignment"] = alignment; + lutest(!flex_stretches_child_size(parent, "width")); + lutest(!flex_stretches_child_size(parent, "height")); + } + for(const c8* alignment : {"stretch", "unknown"}) + { + parent.properties["cross_alignment"] = alignment; + lutest(flex_stretches_child_size(parent, "width") == !horizontal); + lutest(flex_stretches_child_size(parent, "height") == horizontal); + } + parent.properties.erase("cross_alignment"); + lutest(flex_stretches_child_size(parent, "width") == !horizontal); + lutest(flex_stretches_child_size(parent, "height") == horizontal); + } + lutest(!flex_stretches_child_size(parent, "padding")); + parent.type = GameGUI::get_canvas_node_type(); + lutest(!flex_stretches_child_size(parent, "width")); + lutest(!flex_stretches_child_size(parent, "height")); + } + + void remove_file_if_present(const c8* path) + { + RV result = VFS::delete_file(path); + lutest(succeeded(result) || result.errcode() == E_NOT_FOUND); + } + + R> decode_snapshot(const Variant& data) + { + lutry + { + lulet(authoring, GameGUIEditor::decode_authoring_document(data)); + return GameGUIEditor::cook_authoring_document(*authoring); + } + lucatchret; + return E_FAILURE; + } + + R build_test_node(GameGUI::BuildContext& context, + const GameGUI::NodeRecord& node, object_t userdata) + { + GUI::ElementHandle element = context.gui()->begin_element(context.make_id("element")); + context.gui()->end_element(); + return element; + } + + RV migrate_test_node(Variant& properties, u32 from_version, u32 to_version, + object_t userdata) + { + lutest(from_version == 1 && to_version == 2); + properties["migrated"] = true; + ++g_authoring_migration_count; + return ok; + } + + void authoring_migration_test() + { + Guid type = random_guid(); + GameGUI::NodeTypeDesc runtime; + runtime.type = type; + runtime.name = "GameGUIEditorServiceTest.Migrated"; + runtime.build = build_test_node; + lupanic_if_failed(GameGUI::register_node_type(runtime)); + + GameGUIEditor::AuthoringNodeTypeDesc authoring; + authoring.type = type; + authoring.name = runtime.name; + authoring.display_name = "Migrated Test Node"; + authoring.category = "Test"; + authoring.current_version = 2; + authoring.migrate = migrate_test_node; + lupanic_if_failed(GameGUIEditor::register_authoring_node_type(authoring)); + + GameGUIEditor::AuthoringNodeRecord root; + root.id = random_guid(); + root.type = type; + root.type_version = 1; + Ref source = + new_object(); + source->root = root.id; + source->nodes.push_back(root); + auto encoded = GameGUIEditor::encode_authoring_document(*source); + lutest(encoded.valid()); + auto decoded = GameGUIEditor::decode_authoring_document(encoded.get()); + lutest(decoded.valid()); + lutest(g_authoring_migration_count == 1); + lutest(decoded.get()->nodes[0].type_version == 2); + lutest(decoded.get()->nodes[0].properties["migrated"].boolean()); + auto cooked = GameGUIEditor::cook_authoring_document(*decoded.get()); + lutest(cooked.valid() && cooked.get()->nodes.size() == 1); + lutest(cooked.get()->nodes[0].properties["migrated"].boolean()); + lupanic_if_failed(GameGUI::unregister_node_type(type)); + } + + void service_history_test(GameGUIEditor::Service& service) + { + Frontend::IFrontend* frontend = service.frontend(); + Variant first = invoke(frontend, GameGUIEditor::CREATE_DOCUMENT_URL); + Variant second = invoke(frontend, GameGUIEditor::CREATE_DOCUMENT_URL); + lutest(first["document_id"].unum() != second["document_id"].unum()); + lutest(first["dirty"].boolean() && second["dirty"].boolean()); + + Variant listed = invoke(frontend, GameGUIEditor::LIST_DOCUMENTS_URL); + lutest(listed.type() == VariantType::array && listed.size() == 2); + Variant schemas = invoke(frontend, GameGUIEditor::GET_NODE_TYPES_URL); + editing_schema_test(schemas); + + Variant snapshot = invoke(frontend, GameGUIEditor::GET_SNAPSHOT_URL, + document_params(first)); + auto document_result = decode_snapshot(snapshot["document"]); + lutest(document_result.valid()); + Ref document = document_result.get(); + Guid root = document->root; + + Variant insert(VariantType::object); + insert["kind"] = "insert_node"; + insert["parent"] = guid_string(root).c_str(); + insert["type"] = guid_string(GameGUI::get_text_node_type()).c_str(); + insert["name"] = "Child Text"; + Variant edit = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(first, move(insert), "Insert text")); + lutest(edit["revision"].unum() > first["revision"].unum()); + lutest(edit["created_nodes"].size() == 1); + Guid child; + lupanic_if_failed(decode_guid(edit["created_nodes"][0].c_str(), + edit["created_nodes"][0].str().size(), child)); + + Variant insert_parent(VariantType::object); + insert_parent["kind"] = "insert_node"; + insert_parent["parent"] = guid_string(root).c_str(); + insert_parent["type"] = guid_string(GameGUI::get_canvas_node_type()).c_str(); + insert_parent["name"] = "Container"; + Variant parent_inserted = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(edit, move(insert_parent), "Insert container")); + Guid container; + lupanic_if_failed(decode_guid(parent_inserted["created_nodes"][0].c_str(), + parent_inserted["created_nodes"][0].str().size(), container)); + + Variant insert_sibling(VariantType::object); + insert_sibling["kind"] = "insert_node"; + insert_sibling["parent"] = guid_string(root).c_str(); + insert_sibling["type"] = guid_string(GameGUI::get_text_node_type()).c_str(); + insert_sibling["name"] = "Sibling Text"; + Variant sibling_inserted = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(parent_inserted, move(insert_sibling), "Insert sibling")); + Guid sibling; + lupanic_if_failed(decode_guid(sibling_inserted["created_nodes"][0].c_str(), + sibling_inserted["created_nodes"][0].str().size(), sibling)); + + Variant reorder(VariantType::object); + reorder["kind"] = "move_node"; + reorder["node"] = guid_string(child).c_str(); + reorder["parent"] = guid_string(root).c_str(); + reorder["index"] = (u64)2; + Variant reordered = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(sibling_inserted, move(reorder), "Reorder child")); + snapshot = invoke(frontend, GameGUIEditor::GET_SNAPSHOT_URL, + document_params(reordered)); + document_result = decode_snapshot(snapshot["document"]); + lutest(document_result.valid()); + const GameGUI::NodeRecord* reordered_root = GameGUI::find_node( + *document_result.get(), root); + lutest(reordered_root && reordered_root->children.size() == 3); + lutest(reordered_root->children[0].child == container); + lutest(reordered_root->children[1].child == sibling); + lutest(reordered_root->children[2].child == child); + + Variant reparent(VariantType::object); + reparent["kind"] = "move_node"; + reparent["node"] = guid_string(child).c_str(); + reparent["parent"] = guid_string(container).c_str(); + reparent["index"] = (u64)0; + edit = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(reordered, move(reparent), "Reparent child")); + snapshot = invoke(frontend, GameGUIEditor::GET_SNAPSHOT_URL, + document_params(edit)); + document_result = decode_snapshot(snapshot["document"]); + lutest(document_result.valid()); + const GameGUI::NodeRecord* reparented_root = GameGUI::find_node( + *document_result.get(), root); + const GameGUI::NodeRecord* reparented_container = GameGUI::find_node( + *document_result.get(), container); + lutest(reparented_root && reparented_root->children.size() == 2); + lutest(reparented_container && reparented_container->children.size() == 1); + lutest(reparented_container->children[0].child == child); + + Variant attachment(VariantType::object); + attachment["anchor_min"] = Variant(VariantType::array); + attachment["anchor_min"].push_back(0.25); + attachment["anchor_min"].push_back(0.5); + Variant set_attachment(VariantType::object); + set_attachment["kind"] = "set_attachment"; + set_attachment["node"] = guid_string(child).c_str(); + set_attachment["attachment"] = move(attachment); + edit = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(edit, move(set_attachment), "Edit canvas attachment")); + snapshot = invoke(frontend, GameGUIEditor::GET_SNAPSHOT_URL, + document_params(edit)); + document_result = decode_snapshot(snapshot["document"]); + lutest(document_result.valid()); + reparented_container = GameGUI::find_node(*document_result.get(), container); + lutest(reparented_container && + reparented_container->children[0].attachment["anchor_min"].size() == 2); + + Variant set_root(VariantType::object); + set_root["kind"] = "set_root"; + set_root["node"] = guid_string(child).c_str(); + Variant root_changed = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(edit, move(set_root), "Set root node")); + snapshot = invoke(frontend, GameGUIEditor::GET_SNAPSHOT_URL, + document_params(root_changed)); + document_result = decode_snapshot(snapshot["document"]); + lutest(document_result.valid()); + lutest(document_result.get()->root == child); + const GameGUI::NodeRecord* promoted_root = GameGUI::find_node( + *document_result.get(), child); + const GameGUI::NodeRecord* demoted_root = GameGUI::find_node( + *document_result.get(), root); + const GameGUI::NodeRecord* detached_container = GameGUI::find_node( + *document_result.get(), container); + lutest(promoted_root && promoted_root->children.size() == 1); + lutest(promoted_root->children[0].child == root); + lutest(demoted_root && demoted_root->children.size() == 2); + lutest(demoted_root->children[0].child == container); + lutest(demoted_root->children[1].child == sibling); + lutest(detached_container && detached_container->children.empty()); + + Variant root_undone = invoke(frontend, GameGUIEditor::UNDO_URL, + document_params(root_changed)); + snapshot = invoke(frontend, GameGUIEditor::GET_SNAPSHOT_URL, + document_params(root_undone)); + document_result = decode_snapshot(snapshot["document"]); + lutest(document_result.valid()); + lutest(document_result.get()->root == root); + const GameGUI::NodeRecord* restored_container = GameGUI::find_node( + *document_result.get(), container); + lutest(restored_container && restored_container->children.size() == 1); + lutest(restored_container->children[0].child == child); + + edit = invoke(frontend, GameGUIEditor::REDO_URL, + document_params(root_undone)); + snapshot = invoke(frontend, GameGUIEditor::GET_SNAPSHOT_URL, + document_params(edit)); + document_result = decode_snapshot(snapshot["document"]); + lutest(document_result.valid()); + lutest(document_result.get()->root == child); + + Variant stale_command = set_property_command(child, "text", "stale"); + auto stale = frontend->invoke(GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(first, move(stale_command), "Stale edit")); + lutest(!stale.valid()); + + Variant property = set_property_command(child, "text", "First"); + Variant first_text = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(edit, move(property), "Edit text", "text-edit")); + property = set_property_command(child, "text", "Second"); + Variant second_text = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(first_text, move(property), "Edit text", "text-edit")); + lutest(second_text["history_state"] == first_text["history_state"]); + + property = set_property_command(child, "width", 320.0); + Variant different_field = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(second_text, move(property), "Edit width", "width-edit")); + lutest(different_field["history_state"] != second_text["history_state"]); + Variant back_to_text = invoke(frontend, GameGUIEditor::UNDO_URL, + document_params(different_field)); + lutest(back_to_text["history_state"] == second_text["history_state"]); + + Variant undone = invoke(frontend, GameGUIEditor::UNDO_URL, + document_params(back_to_text)); + snapshot = invoke(frontend, GameGUIEditor::GET_SNAPSHOT_URL, + document_params(undone)); + document_result = decode_snapshot(snapshot["document"]); + lutest(document_result.valid()); + lutest(GameGUI::find_node(*document_result.get(), child)->properties["text"].str() == + Name("Text")); + Variant redone = invoke(frontend, GameGUIEditor::REDO_URL, + document_params(undone)); + snapshot = invoke(frontend, GameGUIEditor::GET_SNAPSHOT_URL, + document_params(redone)); + document_result = decode_snapshot(snapshot["document"]); + lutest(!strcmp(GameGUI::find_node(*document_result.get(), child)->properties["text"].c_str(), + "Second")); + + Variant branch_base = invoke(frontend, GameGUIEditor::UNDO_URL, + document_params(redone)); + property = set_property_command(child, "text", "Branch"); + Variant branch = invoke(frontend, GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(branch_base, move(property), "Branch edit")); + auto cannot_redo = frontend->invoke(GameGUIEditor::REDO_URL, + document_params(branch)); + lutest(!cannot_redo.valid()); + + Variant save_as = document_params(branch); + save_as["path"] = COOK_PATH; + Variant saved = invoke(frontend, GameGUIEditor::SAVE_AS_URL, save_as); + invoke(frontend, GameGUIEditor::COOK_URL, document_params(saved)); + auto cooked_asset_result = Asset::get_asset_by_path(COOK_PATH); + lutest(cooked_asset_result.valid()); + Asset::asset_t cooked_asset = cooked_asset_result.get(); + lupanic_if_failed(Asset::set_asset_data_unit_object(cooked_asset, Name(), nullptr)); + lupanic_if_failed(Asset::load_asset_data_unit(cooked_asset, Name())); + auto cooked_document = Asset::get_asset_data_unit_object( + cooked_asset, Name()); + lutest(cooked_document.valid() && cooked_document.get()); + + auto refused_close = frontend->invoke(GameGUIEditor::CLOSE_DOCUMENT_URL, + document_params(second)); + lutest(!refused_close.valid()); + Variant close_params = document_params(second); + close_params["discard"] = true; + invoke(frontend, GameGUIEditor::CLOSE_DOCUMENT_URL, close_params); + } + + void asset_boundary_test(GameGUIEditor::Service& service) + { + GameGUIEditor::AuthoringNodeRecord unknown; + unknown.id = random_guid(); + unknown.type = random_guid(); + unknown.type_version = 19; + unknown.name = "Unsupported"; + unknown.properties = Variant(VariantType::object); + unknown.properties["opaque"] = "preserve"; + Ref source = + new_object(); + source->root = unknown.id; + source->nodes.push_back(unknown); + auto source_asset_result = Asset::new_asset(SOURCE_PATH, + GameGUI::get_asset_type(), false); + lutest(source_asset_result.valid()); + Asset::asset_t source_asset = source_asset_result.get(); + lupanic_if_failed(GameGUIEditor::ensure_authoring_data_unit(source_asset)); + auto encoded_source = GameGUIEditor::encode_authoring_document(*source); + lutest(encoded_source.valid()); + Path source_path(SOURCE_PATH); + source_path.append_extension("json"); + { + auto source_file = VFS::open_file(source_path, FileOpenFlag::write, + FileCreationMode::create_always); + lutest(source_file.valid()); + VariantUtils::JSONWriteOptions json_options; + json_options.indent = true; + json_options.encode_blobs = false; + json_options.allow_non_finite_numbers = false; + lupanic_if_failed(VariantUtils::write_json(source_file.get(), encoded_source.get(), + json_options)); + } + + Variant open_params(VariantType::object); + open_params["asset_guid"] = guid_string(Asset::get_asset_guid(source_asset)).c_str(); + Variant opened = invoke(service.frontend(), GameGUIEditor::OPEN_DOCUMENT_URL, open_params); + Variant duplicate = invoke(service.frontend(), GameGUIEditor::OPEN_DOCUMENT_URL, open_params); + lutest(opened["document_id"] == duplicate["document_id"]); + lutest(!opened["dirty"].boolean()); + + Variant rename(VariantType::object); + rename["kind"] = "set_name"; + rename["node"] = guid_string(unknown.id).c_str(); + rename["name"] = "Renamed unsupported"; + Variant edited = invoke(service.frontend(), GameGUIEditor::APPLY_COMMANDS_URL, + command_batch(opened, move(rename), "Rename unsupported")); + lutest(edited["dirty"].boolean()); + + Variant save_as_params = document_params(edited); + save_as_params["path"] = SAVE_PATH; + Variant saved = invoke(service.frontend(), GameGUIEditor::SAVE_AS_URL, + save_as_params); + lutest(!saved["dirty"].boolean()); + + auto registry_source = Asset::get_asset_data_unit_object< + GameGUIEditor::AuthoringDocument>(source_asset, + GameGUIEditor::get_authoring_data_unit()); + lutest(registry_source.valid() && registry_source.get() && + registry_source.get()->nodes[0].name == Name("Unsupported")); + + auto saved_asset_result = Asset::get_asset_by_path(SAVE_PATH); + lutest(saved_asset_result.valid()); + Asset::asset_t saved_asset = saved_asset_result.get(); + lupanic_if_failed(Asset::set_asset_data_unit_object(saved_asset, + GameGUIEditor::get_authoring_data_unit(), nullptr)); + lupanic_if_failed(Asset::load_asset_data_unit(saved_asset, + GameGUIEditor::get_authoring_data_unit(), true)); + auto loaded_result = Asset::get_asset_data_unit_object< + GameGUIEditor::AuthoringDocument>(saved_asset, + GameGUIEditor::get_authoring_data_unit()); + lutest(loaded_result.valid()); + Ref loaded = loaded_result.get(); + lutest(loaded && loaded->nodes.size() == 1); + lutest(loaded->nodes[0].type == unknown.type); + lutest(loaded->nodes[0].type_version == 19); + lutest(loaded->nodes[0].properties["opaque"] == unknown.properties["opaque"]); + lutest(loaded->nodes[0].name == Name("Renamed unsupported")); + + auto cannot_cook = service.frontend()->invoke(GameGUIEditor::COOK_URL, + document_params(saved)); + lutest(!cannot_cook.valid()); + + Variant close_params = document_params(saved); + invoke(service.frontend(), GameGUIEditor::CLOSE_DOCUMENT_URL, close_params); + } +} + +void working_directory_test(); + +int main() +{ + lupanic_if_failed(Luna::init()); + lupanic_if_failed(add_modules({ + GameGUI::module_game_gui(), + Frontend::module_frontend() + })); + lupanic_if_failed(init_modules()); + working_directory_test(); + const c8* current_dir = get_current_dir(); + Path fixture(current_dir); + release_current_dir(current_dir); + c8 name[GUID_STRING_LENGTH + 1] = {}; + lupanic_if_failed(encode_guid(random_guid(), name, GUID_STRING_LENGTH)); + fixture.push_back(name); + lupanic_if_failed(create_dir(fixture.encode().c_str())); + { + auto service_result = GameGUIEditor::new_service(); + lutest(service_result.valid()); + UniquePtr service = move(service_result.get()); + Variant params(VariantType::object); + params["native_path"] = fixture.encode().c_str(); + Variant directory = invoke(service->frontend(), GameGUIEditor::OPEN_DIRECTORY_URL, params); + String mount = directory["vfs_path"].c_str(); + String save_path = mount; + save_path.append("/SavedDocument"); + String source_path = mount; + source_path.append("/UnknownSource"); + String cook_path = mount; + cook_path.append("/CookedDocument"); + MOUNT_PATH = mount.c_str(); + SAVE_PATH = save_path.c_str(); + SOURCE_PATH = source_path.c_str(); + COOK_PATH = cook_path.c_str(); + authoring_migration_test(); + inspector_stretch_warning_test(); + service_history_test(*service); + asset_boundary_test(*service); + // Delete only assets created by these tests, before their database is unmounted. + for(const c8* path : {SAVE_PATH, SOURCE_PATH, COOK_PATH}) + { + auto asset = Asset::get_asset_by_path(path); + if(asset.valid()) lupanic_if_failed(Asset::delete_asset(asset.get())); + } + } + lupanic_if_failed(delete_file(fixture.encode().c_str())); + Luna::close(); + return 0; +} diff --git a/Tests/GameGUIEditorServiceTest/Source/WorkingDirectoryTest.cpp b/Tests/GameGUIEditorServiceTest/Source/WorkingDirectoryTest.cpp new file mode 100644 index 00000000..c7cfe2fe --- /dev/null +++ b/Tests/GameGUIEditorServiceTest/Source/WorkingDirectoryTest.cpp @@ -0,0 +1,322 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file WorkingDirectoryTest.cpp +* @author JXMaster +* @date 2026/9/11 +*/ +#include "../../../Programs/GameGUIEditor/Service/GameGUIEditorService.hpp" +#include +#include +#include +#include +#include +#include +#include + +using namespace Luna; +using namespace Luna::GameGUIEditor; + +namespace +{ + Path child_path(Path parent, const c8* name) { parent.push_back(name); return parent; } + Variant call(Service& service, const c8* url, const Variant& params = Variant(VariantType::object)) + { + auto result = service.frontend()->invoke(url, params); + lupanic_if_failed(result); + return move(result.get()); + } + Variant document_params(const Variant& metadata) + { + Variant params(VariantType::object); + params["document_id"] = metadata["document_id"]; + params["expected_revision"] = metadata["revision"]; + return params; + } + Variant directory_params(const Variant& metadata) + { + Variant params(VariantType::object); + params["working_directory_id"] = metadata["working_directory_id"]; + return params; + } + Variant open_directory(Service& service, const Path& path) + { + Variant params(VariantType::object); + params["native_path"] = path.encode().c_str(); + return call(service, OPEN_DIRECTORY_URL, params); + } + Path asset_path(const Variant& directory, const c8* name) + { + return child_path(Path(directory["vfs_path"].c_str()), name); + } + Variant save_as(Service& service, const Variant& document, const Path& path) + { + Variant params = document_params(document); + params["path"] = path.encode().c_str(); + return call(service, SAVE_AS_URL, params); + } + Variant rename_root(Service& service, const Variant& document, const c8* name) + { + Variant snapshot = call(service, GET_SNAPSHOT_URL, document_params(document)); + Variant command(VariantType::object); + command["kind"] = "set_name"; + command["node"] = snapshot["document"]["root"]; + command["name"] = name; + Variant params = document_params(snapshot); + params["commands"] = Variant(VariantType::array); + params["commands"].push_back(move(command)); + return call(service, APPLY_COMMANDS_URL, params); + } + Variant add_reference(Service& service, const Variant& document, const Variant& referenced) + { + Variant snapshot = call(service, GET_SNAPSHOT_URL, document_params(document)); + Variant command(VariantType::object); + command["kind"] = "insert_node"; + command["parent"] = snapshot["document"]["root"]; + c8 type[GUID_STRING_LENGTH + 1] = {}; + lupanic_if_failed(encode_guid(GameGUI::get_asset_instance_node_type(), type, GUID_STRING_LENGTH)); + command["type"] = type; + command["properties"] = Variant(VariantType::object); + command["properties"]["asset"] = referenced["asset_guid"]; + Variant params = document_params(snapshot); + params["commands"] = Variant(VariantType::array); + params["commands"].push_back(move(command)); + return call(service, APPLY_COMMANDS_URL, params); + } + bool preview_ready(Service& service, const Variant& document) + { + auto preview = service.prepare_preview(document["document_id"].unum()); + lupanic_if_failed(preview); + if(!preview.get().document) return false; + auto instance = GameGUI::new_instance(preview.get()); + return succeeded(instance->prepare()); + } + Variant close_plan(Service& service, const Variant& directory, bool discard) + { + Variant plan = call(service, PREPARE_CLOSE_DIRECTORY_URL, directory_params(directory)); + Variant decisions(VariantType::array); + for(const Variant& metadata : plan["documents"].values()) + { + Variant decision = document_params(metadata); + decision["discard"] = discard; + decisions.push_back(move(decision)); + } + plan["documents"] = move(decisions); + return plan; + } +} + +void working_directory_test() +{ + const c8* current = get_current_dir(); + Path fixture(current); + release_current_dir(current); + c8 unique[GUID_STRING_LENGTH + 1] = {}; + lupanic_if_failed(encode_guid(random_guid(), unique, GUID_STRING_LENGTH)); + String fixture_name = "GameGUIWorkingDirectoryTest-"; + fixture_name.append(unique); + fixture.push_back(fixture_name); + lupanic_if_failed(create_dir(fixture.encode().c_str())); + Path a = child_path(fixture, "Central"); + Path b = child_path(fixture, "Sidecar"); + Path duplicate = child_path(fixture, "Duplicate"); + for(const Path& path : {a, b, duplicate}) lupanic_if_failed(create_dir(path.encode().c_str())); + lupanic_if_failed(create_dir(child_path(b, "Empty Folder").encode().c_str())); + { + auto fs = VFS::new_native_file_system(a.encode().c_str()); + lupanic_if_failed(fs); + lupanic_if_failed(VFS::mount(fs.get(), "/GameGUIWorkingDirectoryFixture")); + auto db = Asset::create_file_database("/GameGUIWorkingDirectoryFixture"); + lupanic_if_failed(db); + db.get() = nullptr; + lupanic_if_failed(VFS::unmount("/GameGUIWorkingDirectoryFixture")); + } + { + auto created = new_service(); + lupanic_if_failed(created); + auto service = move(created.get()); + luassert_always(!service->frontend()->invoke(CREATE_DOCUMENT_URL, Variant(VariantType::object)).valid()); + Variant central = open_directory(*service, a); + Variant sidecar = open_directory(*service, b); + luassert_always(open_directory(*service, b)["working_directory_id"] == sidecar["working_directory_id"]); + luassert_always(call(*service, LIST_DIRECTORIES_URL).size() == 2); + luassert_always(sidecar["folders"].size() == 1); + Variant params(VariantType::object); + params["native_path"] = child_path(b, "Empty Folder").encode().c_str(); + luassert_always(!service->frontend()->invoke(OPEN_DIRECTORY_URL, params).valid()); + params["native_path"] = child_path(fixture, "Outside.json").encode().c_str(); + luassert_always(!service->frontend()->invoke(RESOLVE_PATH_URL, params).valid()); + params["native_path"] = child_path(b, "Menu with spaces.json").encode().c_str(); + Variant resolved = call(*service, RESOLVE_PATH_URL, params); + luassert_always(resolved["working_directory_id"] == sidecar["working_directory_id"]); + + Variant first = call(*service, CREATE_DOCUMENT_URL, directory_params(central)); + Variant second = call(*service, CREATE_DOCUMENT_URL, directory_params(sidecar)); + params = document_params(first); + params["path"] = "/OutsideWorkingDirectories/NoAsset"; + luassert_always(!service->frontend()->invoke(SAVE_AS_URL, params).valid()); + first = save_as(*service, first, asset_path(central, "Menu")); + second = save_as(*service, second, asset_path(sidecar, "Menu")); + luassert_always(first["asset_guid"] != second["asset_guid"]); + luassert_always(get_file_attribute(child_path(a, "Menu.json").encode().c_str()).valid()); + luassert_always(!get_file_attribute(child_path(a, "Menu.meta").encode().c_str()).valid()); + luassert_always(get_file_attribute(child_path(b, "Menu.meta").encode().c_str()).valid()); + + // Saving an untitled document during close needs the current close token. A stale + // pre-save decision cannot close it, and cancellation leaves the successful save intact. + Variant untitled = call(*service, CREATE_DOCUMENT_URL, directory_params(sidecar)); + Variant closing = close_plan(*service, sidecar, true); + params = document_params(untitled); + params["path"] = asset_path(sidecar, "SavedWhileClosing").encode().c_str(); + luassert_always(!service->frontend()->invoke(SAVE_AS_URL, params).valid()); + params["close_token"] = closing["close_token"]; + untitled = call(*service, SAVE_AS_URL, params); + luassert_always(!untitled["dirty"].boolean()); + luassert_always(!service->frontend()->invoke(CLOSE_DIRECTORY_URL, closing).valid()); + call(*service, CANCEL_CLOSE_DIRECTORY_URL, closing); + call(*service, CLOSE_DOCUMENT_URL, document_params(untitled)); + auto closing_asset = Asset::get_asset_by_path(asset_path(sidecar, "SavedWhileClosing")); + lupanic_if_failed(closing_asset); + lupanic_if_failed(Asset::delete_asset(closing_asset.get())); + + // Existing destinations require explicit replacement and never overwrite an open peer. + params = document_params(first); + params["path"] = asset_path(sidecar, "Menu").encode().c_str(); + params["overwrite"] = true; + luassert_always(!service->frontend()->invoke(SAVE_AS_URL, params).valid()); + + // Register non-GameGUI metadata and cooked-only GameGUI metadata, without loading either. + auto foreign = Asset::new_asset(asset_path(sidecar, "Foreign"), "Tests.UnknownAssetType", true); + lupanic_if_failed(foreign); + auto cooked_only = Asset::new_asset(asset_path(sidecar, "CookedOnly"), GameGUI::get_asset_type(), true); + lupanic_if_failed(cooked_only); + sidecar = call(*service, REFRESH_DIRECTORY_URL, directory_params(sidecar)); + luassert_always(sidecar["assets"].size() == 3); + params = Variant(VariantType::object); + params["path"] = asset_path(sidecar, "CookedOnly").encode().c_str(); + luassert_always(!service->frontend()->invoke(OPEN_DOCUMENT_URL, params).valid()); + Vector units; + lupanic_if_failed(Asset::get_asset_data_units(cooked_only.get(), units)); + luassert_always(units.size() == 1); + second = call(*service, GET_SNAPSHOT_URL, document_params(second)); + + // Current edits in another directory are available without publishing or saving cooked data. + second = rename_root(*service, second, "Unsaved nested edit"); + first = add_reference(*service, first, second); + luassert_always(preview_ready(*service, first)); + { + auto preview = service->prepare_preview(first["document_id"].unum()); + lupanic_if_failed(preview); + auto nested_asset = Asset::get_asset_by_path(asset_path(sidecar, "Menu")); + lupanic_if_failed(nested_asset); + auto nested = preview.get().resource_resolver.resolve(preview.get().resource_resolver.userdata.get(), nested_asset.get()); + lupanic_if_failed(nested); + auto document = cast_object(nested.get().get()); + luassert_always(document && document->nodes[0].name == Name("Unsaved nested edit")); + auto state = Asset::get_asset_data_unit_state(nested_asset.get(), Name()); + luassert_always(state.valid() && state.get() == Asset::AssetDataUnitState::unloaded); + } + luassert_always(!get_file_attribute(child_path(b, "Menu.cooked").encode().c_str()).valid()); + first = call(*service, SAVE_URL, document_params(first)); + + // Prepare is non-destructive, freezes edits, and cannot close without explicit decisions. + Variant plan = call(*service, PREPARE_CLOSE_DIRECTORY_URL, directory_params(sidecar)); + luassert_always(!service->frontend()->invoke(CREATE_DOCUMENT_URL, directory_params(sidecar)).valid()); + luassert_always(!service->frontend()->invoke(SAVE_URL, document_params(second)).valid()); + luassert_always(!service->frontend()->invoke(CLOSE_DIRECTORY_URL, plan).valid()); + call(*service, CANCEL_CLOSE_DIRECTORY_URL, plan); + auto unchanged = call(*service, GET_SNAPSHOT_URL, document_params(second)); + luassert_always(unchanged["dirty"].boolean() && unchanged["revision"] == second["revision"]); + second = call(*service, SAVE_URL, document_params(second)); + + // A live file handle prevents VFS unmount; the directory and documents remain recoverable. + { + auto file = VFS::open_file(child_path(Path(sidecar["vfs_path"].c_str()), "Menu.json"), FileOpenFlag::read, + FileCreationMode::open_existing); + lupanic_if_failed(file); + plan = close_plan(*service, sidecar, false); + luassert_always(!service->frontend()->invoke(CLOSE_DIRECTORY_URL, plan).valid()); + luassert_always(call(*service, LIST_DOCUMENTS_URL).size() == 2); + luassert_always(Asset::get_asset_by_path(asset_path(sidecar, "Menu")).valid()); + } + plan = close_plan(*service, sidecar, false); + call(*service, CLOSE_DIRECTORY_URL, plan); + luassert_always(!preview_ready(*service, first)); + sidecar = open_directory(*service, b); + luassert_always(preview_ready(*service, first)); + params = Variant(VariantType::object); + params["asset_guid"] = second["asset_guid"]; + second = call(*service, OPEN_DOCUMENT_URL, params); + luassert_always(!second["dirty"].boolean()); + + // A duplicated GUID rejects the whole directory, without a partial registration. + lupanic_if_failed(copy_file(child_path(b, "Menu.meta").encode().c_str(), child_path(duplicate, "Copied.meta").encode().c_str())); + params = Variant(VariantType::object); + params["native_path"] = duplicate.encode().c_str(); + luassert_always(!service->frontend()->invoke(OPEN_DIRECTORY_URL, params).valid()); + luassert_always(call(*service, LIST_DIRECTORIES_URL).size() == 2); + // Centralized metadata is authoritative even when an unrelated sidecar is present. + lupanic_if_failed(copy_file(child_path(b, "Menu.meta").encode().c_str(), child_path(a, "Stray.meta").encode().c_str())); + central = call(*service, REFRESH_DIRECTORY_URL, directory_params(central)); + luassert_always(central["assets"].size() == 1); + first = call(*service, GET_SNAPSHOT_URL, document_params(first)); + + // A failed metadata flush must preserve the previous binding and the dirty save point. + first = rename_root(*service, first, "After failed flush"); + Path database = child_path(a, "assets.db"); + Path backup = child_path(a, "DatabaseBackup"); + lupanic_if_failed(move_file(database.encode().c_str(), backup.encode().c_str())); + lupanic_if_failed(create_dir(database.encode().c_str())); + luassert_always(!service->frontend()->invoke(SAVE_URL, document_params(first)).valid()); + auto failed = call(*service, GET_SNAPSHOT_URL, document_params(first)); + luassert_always(failed["dirty"].boolean() && failed["revision"] == first["revision"]); + lupanic_if_failed(delete_file(database.encode().c_str())); + lupanic_if_failed(move_file(backup.encode().c_str(), database.encode().c_str())); + first = call(*service, SAVE_URL, document_params(failed)); + luassert_always(!first["dirty"].boolean()); + +#if !defined(LUNA_PLATFORM_WINDOWS) + // Symlink traversal is checked even after the directory has already been opened. + std::error_code link_error; + Path escape = child_path(a, "Escape"); + std::filesystem::create_directory_symlink(std::filesystem::path((const char8_t*)b.encode().c_str()), + std::filesystem::path((const char8_t*)escape.encode().c_str()), link_error); + luassert_always(!link_error); + auto escaped = VFS::open_file(child_path(asset_path(central, "Escape"), "Menu.json"), + FileOpenFlag::read, FileCreationMode::open_existing); + luassert_always(!escaped.valid()); + std::filesystem::remove(std::filesystem::path((const char8_t*)escape.encode().c_str()), link_error); + luassert_always(!link_error); +#endif + + // Reopening preserves the GUID and reads the successful save, rather than a cached object. + plan = close_plan(*service, central, false); + call(*service, CLOSE_DIRECTORY_URL, plan); + central = open_directory(*service, a); + params = Variant(VariantType::object); + params["asset_guid"] = first["asset_guid"]; + first = call(*service, OPEN_DOCUMENT_URL, params); + auto snapshot = call(*service, GET_SNAPSHOT_URL, document_params(first)); + auto decoded = decode_authoring_document(snapshot["document"]); + lupanic_if_failed(decoded); + luassert_always(decoded.get()->nodes[0].name == Name("After failed flush")); + luassert_always(!first["dirty"].boolean() && preview_ready(*service, first)); + + // Cycles across roots remain diagnostics and do not recurse indefinitely. + second = add_reference(*service, second, first); + luassert_always(!preview_ready(*service, first)); + plan = close_plan(*service, sidecar, true); + call(*service, CLOSE_DIRECTORY_URL, plan); + plan = close_plan(*service, central, false); + call(*service, CLOSE_DIRECTORY_URL, plan); + luassert_always(call(*service, LIST_DIRECTORIES_URL).empty()); + luassert_always(call(*service, LIST_DOCUMENTS_URL).empty()); + } + // Only this test's newly created, uniquely named fixture is removed. + luassert_always(fixture.back() == Name(fixture_name)); + std::error_code cleanup; + std::filesystem::remove_all(std::filesystem::path((const char8_t*)fixture.encode().c_str()), cleanup); + luassert_always(!cleanup); +} diff --git a/Tests/GameGUIEditorTest/GameGUIEditorTest.Target.cs b/Tests/GameGUIEditorTest/GameGUIEditorTest.Target.cs new file mode 100644 index 00000000..6b8cfe71 --- /dev/null +++ b/Tests/GameGUIEditorTest/GameGUIEditorTest.Target.cs @@ -0,0 +1,20 @@ +namespace LunaBuild.Core.Targets; + +public sealed class GameGUIEditorTestTargetRules : TargetRules +{ + public GameGUIEditorTestTargetRules() + : base( + name: "GameGUIEditorTest", + targetDirectory: "Tests/GameGUIEditorTest", + rulesPath: "Tests/GameGUIEditorTest/GameGUIEditorTest.Target.cs") + { + SupportedPlatforms(BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Linux); + Category = BuildTargetCategory.Tests; + Kind = BuildTargetKind.Executable; + Sources("Source/**.cpp", "../../Programs/GameGUIEditor/Source/**.cpp"); + ExcludeSources("../../Programs/GameGUIEditor/Source/main.cpp"); + DependsOn("Runtime", "Window", "RHI", "RHIUtility", "Font", "VG", "GUI", + "EditorGUI", "GUIWindow", "GameGUI", "GameGUIEditorService", "Frontend", + "Asset", "VariantUtils", "VFS"); + } +} diff --git a/Tests/GameGUIEditorTest/Source/ExplorerTest.cpp b/Tests/GameGUIEditorTest/Source/ExplorerTest.cpp new file mode 100644 index 00000000..4bd2ac15 --- /dev/null +++ b/Tests/GameGUIEditorTest/Source/ExplorerTest.cpp @@ -0,0 +1,179 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file ExplorerTest.cpp +* @author JXMaster +* @date 2026/9/11 +*/ +#include "../../../Programs/GameGUIEditor/Source/EditorApp.hpp" +#include +#include +#include + +using namespace Luna; +using namespace Luna::GameGUIEditor; +using namespace Luna::GameGUIEditor::Internal; + +void explorer_test() +{ + EditorApp app; + app.gui = GUI::new_context(); + lupanic_if_failed(app.gui->register_font("default", Font::get_default_font())); + EditorGUI::register_style_schemas(app.gui); + EditorGUI::set_default_style(app.gui, EditorGUI::DefaultStyleDesc()); + auto created = new_service(); + lupanic_if_failed(created); + app.service = move(created.get()); + const c8* current = get_current_dir(); + Path fixture(current); + release_current_dir(current); + c8 unique[GUID_STRING_LENGTH + 1] = {}; + lupanic_if_failed(encode_guid(random_guid(), unique, GUID_STRING_LENGTH)); + fixture.push_back(unique); + lupanic_if_failed(create_dir(fixture.encode().c_str())); + luassert_always(app.open_working_directory(fixture)); + Path folder = fixture; + folder.push_back("Folder"); + lupanic_if_failed(create_dir(folder.encode().c_str())); + luassert_always(app.create_document()); + Path menu(app.working_directories[0]["vfs_path"].c_str()); + menu.push_back("Menu"); + Variant params = editing_params(*app.active_document()), metadata; + params["path"] = menu.encode().c_str(); + luassert_always(app.invoke(SAVE_AS_URL, params, metadata)); + app.update_metadata(*app.active_document(), metadata); + Guid menu_guid = app.active_document()->asset_guid; + u64 document_id = app.active_document()->id; + luassert_always(app.invoke(CLOSE_DOCUMENT_URL, editing_params(*app.active_document()), metadata)); + app.remove_document_view(document_id); + Path foreign(app.working_directories[0]["vfs_path"].c_str()); + foreign.push_back("NotGameGUI"); + auto foreign_asset = Asset::new_asset(foreign, "Test.Explorer.Foreign", true); + lupanic_if_failed(foreign_asset); + Path cooked(app.working_directories[0]["vfs_path"].c_str()); + cooked.push_back("CookedOnly"); + auto cooked_asset = Asset::new_asset(cooked, GameGUI::get_asset_type(), true); + lupanic_if_failed(cooked_asset); + params = Variant(VariantType::object); + params["working_directory_id"] = app.selected_directory; + luassert_always(app.invoke(REFRESH_DIRECTORY_URL, params, metadata)); + luassert_always(app.refresh_working_directories()); + + EditorGUI::DockSpaceLayoutDesc layout; + layout.root_node = 0; + layout.nodes.resize(1); + layout.nodes[0].tabs.push_back(app.gui->make_id("panel.explorer")); + EditorGUI::set_dockspace_layout(app.gui, app.gui->make_id("editor.dock_space"), layout); + UIHandles handles; + auto frame = [&](Span input = {}) + { + if(app.active_document()) app.rebuild_inspector(*app.active_document()); + GUI::FrameDesc desc; + desc.logical_size = Float2U(500.0f, 600.0f); + desc.render_size = UInt2U(1000, 1200); + desc.delta_time = 1.0f / 60.0f; + app.gui->begin_frame(desc); + app.gui->add_input_events(input); + app.gui->push_layer(app.gui->make_id("explorer.test.layer")); + handles = UIHandles(); + GUI::ElementHandle root = EditorGUI::begin_v_layout(app.gui, + app.gui->make_id("explorer.test.root"), "Explorer Test", fill_layout()); + EditorGUI::begin_dock_space(app.gui, app.gui->make_id("editor.dock_space"), "Explorer", fill_layout()); + app.build_explorer_panel(handles); + EditorGUI::end_dock_space(app.gui); + app.build_asset_picker(handles); + EditorGUI::end_v_layout(app.gui, root); + app.gui->pop_layer(); + lupanic_if_failed(EditorGUI::layout_tree(app.gui, root, RectF(0, 0, 500, 600))); + app.gui->route_input(); + if(EditorGUI::resolve_interactions(app.gui).relayout_requested) + lupanic_if_failed(EditorGUI::layout_tree(app.gui, root, RectF(0, 0, 500, 600))); + lupanic_if_failed(app.gui->generate_draw_commands()); + app.process_interactions(handles); + luassert_always(app.error_message.empty()); + }; + frame(); + // Root, empty folder and two GameGUI assets; no foreign asset or physical data-unit files. + luassert_always(handles.explorer.size() == 4); + Float2U menu_position, root_position; + for(const auto& hit : handles.explorer) + { + RectF rect = item_screen_rect(app.gui, hit.element); + Float2U position(rect.offset_x + rect.width * 0.6f, rect.offset_y + rect.height * 0.5f); + if(hit.asset == menu_guid) menu_position = position; + if(hit.asset == Guid() && hit.path.empty()) root_position = position; + luassert_always(hit.path != Path("NotGameGUI")); + } + auto event = [](GUI::InputEventType type, const Float2U& at, GUI::PointerButton button = GUI::PointerButton::left) + { + GUI::InputEvent result; + result.type = type; + result.position = at; + result.button = button; + return result; + }; + frame({event(GUI::InputEventType::pointer_down, menu_position)}); + frame({event(GUI::InputEventType::pointer_up, menu_position)}); + luassert_always(app.documents.empty()); + frame({event(GUI::InputEventType::pointer_down, menu_position)}); + frame({event(GUI::InputEventType::pointer_up, menu_position)}); + luassert_always(app.documents.size() == 1 && app.active_document()->asset_guid == menu_guid); + // The Inspector's asset picker uses the same catalog and filters by the property's type. + auto authoring_type = get_authoring_node_type(GameGUI::get_asset_instance_node_type()); + lupanic_if_failed(authoring_type); + NodeTypeView type; + type.type = authoring_type.get().type; + type.property_schema = authoring_type.get().property_schema; + app.node_types.push_back(move(type)); + app.add_node(*app.active_document(), GameGUI::get_asset_instance_node_type(), app.active_document()->snapshot->root); + app.rebuild_inspector(*app.active_document()); + app.asset_picker_document = app.active_document()->id; + app.asset_picker_revision = app.active_document()->revision; + for(usize i = 0; i < app.active_document()->property_editors.size(); ++i) + if(app.active_document()->property_editors[i].desc.id == Name("asset")) app.asset_picker_property = i; + app.asset_picker_position = Float2U(50.0f, 200.0f); + EditorGUI::open_popup(app.gui, app.gui->make_id("inspector.asset_picker")); + frame(); + luassert_always(handles.asset_choices.size() == 2); + Float2U choice_position; + for(const auto& choice : handles.asset_choices) + { + if(choice.asset != menu_guid) continue; + RectF rect = item_screen_rect(app.gui, choice.element); + choice_position = Float2U(rect.offset_x + rect.width * 0.5f, rect.offset_y + rect.height * 0.5f); + } + frame({event(GUI::InputEventType::pointer_down, choice_position)}); + frame({event(GUI::InputEventType::pointer_up, choice_position)}); + auto selected = find_authoring_node(*app.active_document()->snapshot, app.active_document()->selected_node); + luassert_always(selected && selected->properties["asset"].str() == Name(guid_string(menu_guid))); + app.undo_document(*app.active_document()); + app.undo_document(*app.active_document()); + luassert_always(!app.active_document()->dirty); + frame(); + frame({event(GUI::InputEventType::pointer_down, root_position, GUI::PointerButton::right)}); + frame({event(GUI::InputEventType::pointer_up, root_position, GUI::PointerButton::right)}); + frame(); + luassert_always(EditorGUI::is_popup_open(app.gui, app.gui->make_id("explorer.context"))); + luassert_always(handles.unload_directory.id && handles.refresh_directory.id); + // Close a clean directory through the actual context menu. Removal is deferred past the frame. + RectF unload_rect = item_screen_rect(app.gui, handles.unload_directory); + Float2U unload_position(unload_rect.offset_x + unload_rect.width * 0.5f, + unload_rect.offset_y + unload_rect.height * 0.5f); + frame({event(GUI::InputEventType::pointer_down, unload_position)}); + frame({event(GUI::InputEventType::pointer_up, unload_position)}); + luassert_always(app.deferred_directory_closes.size() == 1 && app.documents.size() == 1); + app.process_deferred_directory_closes(); + luassert_always(app.working_directories.empty() && app.documents.empty()); + frame(); + luassert_always(handles.open_directory_button.id); + app.service.reset(); + for(const c8* name : {"Menu.json", "Menu.meta", "NotGameGUI.meta", "CookedOnly.meta", "Folder"}) + { + Path path = fixture; + path.push_back(name); + lupanic_if_failed(delete_file(path.encode().c_str())); + } + lupanic_if_failed(delete_file(fixture.encode().c_str())); +} diff --git a/Tests/GameGUIEditorTest/Source/Main.cpp b/Tests/GameGUIEditorTest/Source/Main.cpp new file mode 100644 index 00000000..d40370c3 --- /dev/null +++ b/Tests/GameGUIEditorTest/Source/Main.cpp @@ -0,0 +1,272 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Main.cpp +* @author JXMaster +* @date 2026/9/10 +*/ +#include "../../../Programs/GameGUIEditor/Source/EditorApp.hpp" +#include +#include +#include +#include +#include + +using namespace Luna; +using namespace Luna::GameGUIEditor; +using namespace Luna::GameGUIEditor::Internal; + +namespace +{ + GUI::InputEvent pointer(GUI::InputEventType type, const Float2U& position) + { + GUI::InputEvent event; + event.type = type; + event.position = position; + event.button = GUI::PointerButton::left; + return event; + } + + struct HierarchyTest + { + EditorApp app; + UIHandles handles; + Path fixture; + + HierarchyTest() + { + app.gui = GUI::new_context(); + lupanic_if_failed(app.gui->register_font("default", Font::get_default_font())); + EditorGUI::register_style_schemas(app.gui); + EditorGUI::set_default_style(app.gui, EditorGUI::DefaultStyleDesc()); + auto service = new_service(); + lupanic_if_failed(service); + app.service = move(service.get()); + const c8* current = get_current_dir(); + fixture = current; + release_current_dir(current); + c8 name[GUID_STRING_LENGTH + 1] = {}; + lupanic_if_failed(encode_guid(random_guid(), name, GUID_STRING_LENGTH)); + fixture.push_back(name); + lupanic_if_failed(create_dir(fixture.encode().c_str())); + luassert_always(app.open_working_directory(fixture)); + Variant types; + luassert_always(app.invoke(GET_NODE_TYPES_URL, Variant(VariantType::object), types)); + for(const Variant& value : types.values()) + { + NodeTypeView type; + luassert_always(decode_guid_string(value["type"], type.type)); + type.name = value["name"].c_str(); + type.display_name = value["display_name"].c_str(); + type.category = value["category"].c_str(); + app.node_types.push_back(move(type)); + } + luassert_always(app.create_document()); + EditorGUI::DockSpaceLayoutDesc layout; + layout.root_node = 0; + layout.nodes.resize(3); + layout.nodes[0].split = true; + layout.nodes[0].split_axis = EditorGUI::DockSplitAxis::y; + layout.nodes[0].split_ratio = 0.25f; + layout.nodes[0].child0 = 1; + layout.nodes[0].child1 = 2; + layout.nodes[1].tabs.push_back(app.gui->make_id("panel.palette")); + layout.nodes[2].tabs.push_back(app.gui->make_id("panel.hierarchy")); + EditorGUI::set_dockspace_layout(app.gui, app.gui->make_id("editor.dock_space"), layout); + frame(); + } + + DocumentView& document() { return *app.active_document(); } + + ~HierarchyTest() + { + app.documents.clear(); + app.service.reset(); + lupanic_if_failed(delete_file(fixture.encode().c_str())); + } + + void frame(Span events = {}) + { + app.rebuild_inspector(document()); + GUI::FrameDesc desc; + desc.logical_size = Float2U(640.0f, 640.0f); + desc.render_size = UInt2U(1280, 1280); + app.gui->begin_frame(desc); + app.gui->add_input_events(events); + handles = UIHandles(); + handles.document_id = document().id; + app.gui->push_layer(app.gui->make_id("test.layer")); + GUI::ElementHandle root = EditorGUI::begin_v_layout(app.gui, + app.gui->make_id("test.root"), "Test", fill_layout()); + EditorGUI::begin_dock_space(app.gui, app.gui->make_id("editor.dock_space"), + "Test DockSpace", fill_layout()); + app.build_palette_panel(handles); + app.build_hierarchy_panel(handles); + EditorGUI::end_dock_space(app.gui); + EditorGUI::end_v_layout(app.gui, root); + app.gui->pop_layer(); + lupanic_if_failed(EditorGUI::layout_tree(app.gui, root, RectF(0, 0, 640, 640))); + app.gui->route_input(); + if(EditorGUI::resolve_interactions(app.gui).relayout_requested) + lupanic_if_failed(EditorGUI::layout_tree(app.gui, root, RectF(0, 0, 640, 640))); + lupanic_if_failed(app.gui->generate_draw_commands()); + app.process_interactions(handles); + luassert_always(app.error_message.empty()); + } + + Float2U palette(const Guid& type) + { + for(const TypeHit& hit : handles.types) + { + if(hit.type != type) continue; + RectF rect = item_screen_rect(app.gui, hit.element); + return Float2U(rect.offset_x + rect.width * 0.5f, rect.offset_y + rect.height * 0.5f); + } + lupanic(); + return Float2U(0.0f); + } + + Float2U node(const Guid& id, f32 vertical_position = 0.5f) + { + for(const NodeHit& hit : handles.nodes) + { + if(hit.node != id) continue; + RectF rect = item_screen_rect(app.gui, hit.element); + return Float2U(rect.offset_x + rect.width * 0.5f, + rect.offset_y + rect.height * vertical_position); + } + lupanic(); + return Float2U(0.0f); + } + + void click(const Float2U& at) + { + frame({pointer(GUI::InputEventType::pointer_down, at)}); + frame({pointer(GUI::InputEventType::pointer_up, at)}); + frame(); + } + + void drag(const Float2U& from, const Float2U& to, HierarchyDropMode expected) + { + frame({pointer(GUI::InputEventType::pointer_down, from)}); + frame({pointer(GUI::InputEventType::pointer_move, to)}); + luassert_always(document().hierarchy_drag.dragging); + luassert_always(document().hierarchy_drag.drop_mode == expected); + frame({pointer(GUI::InputEventType::pointer_up, to)}); + frame(); + } + + const Vector& children(const Guid& parent) + { + const AuthoringNodeRecord* record = find_authoring_node(*document().snapshot, parent); + luassert_always(record); + return record->children; + } + }; + + void palette_drag_test() + { + HierarchyTest test; + Guid root = test.document().snapshot->root; + test.click(test.palette(GameGUI::get_flex_node_type())); + Guid container = test.document().selected_node; + luassert_always(container != root && test.children(root).size() == 1); + test.drag(test.palette(GameGUI::get_text_node_type()), test.node(container), HierarchyDropMode::child); + Guid text = test.document().selected_node; + luassert_always(test.children(container).size() == 1 && test.children(container)[0].child == text); + // The formerly empty parent is expanded, so the created child is visible. + test.node(text); + test.drag(test.palette(GameGUI::get_button_node_type()), test.node(text, 0.05f), HierarchyDropMode::before); + Guid button = test.document().selected_node; + luassert_always(test.children(container).size() == 2); + luassert_always(test.children(container)[0].child == button); + luassert_always(test.children(container)[1].child == text); + test.drag(test.palette(GameGUI::get_canvas_node_type()), test.node(text, 0.95f), HierarchyDropMode::after); + Guid canvas = test.document().selected_node; + luassert_always(test.children(container).size() == 3 && test.children(container)[2].child == canvas); + test.app.undo_document(test.document()); + test.frame(); + luassert_always(test.children(container).size() == 2); + test.app.redo_document(test.document()); + test.frame(); + luassert_always(test.children(container).size() == 3 && test.children(container)[2].child == canvas); + + // Existing-node movement shares the same hit zones, with move-only index adjustment. + test.drag(test.node(button), test.node(canvas, 0.95f), HierarchyDropMode::after); + luassert_always(test.children(container)[2].child == button); + test.drag(test.node(button), test.node(root), HierarchyDropMode::child); + luassert_always(test.children(root).size() == 2 && test.children(root)[1].child == button); + test.drag(test.node(container), test.node(canvas), HierarchyDropMode::none); + luassert_always(test.children(root)[0].child == container); + + usize count = test.document().snapshot->nodes.size(); + Float2U source = test.palette(GameGUI::get_text_node_type()); + test.drag(source, Float2U(630.0f, 620.0f), HierarchyDropMode::none); + luassert_always(test.document().snapshot->nodes.size() == count); + test.frame({pointer(GUI::InputEventType::pointer_down, source)}); + test.frame({pointer(GUI::InputEventType::pointer_move, test.node(root))}); + test.frame({pointer(GUI::InputEventType::pointer_up, source)}); + test.frame(); + luassert_always(test.document().snapshot->nodes.size() == count); + + // Fast drags can cross several positions in a single frame, including back to the button. + test.frame({pointer(GUI::InputEventType::pointer_down, source), + pointer(GUI::InputEventType::pointer_move, test.node(root)), + pointer(GUI::InputEventType::pointer_up, source)}); + test.frame(); + luassert_always(test.document().snapshot->nodes.size() == count); + test.frame({pointer(GUI::InputEventType::pointer_down, source), + pointer(GUI::InputEventType::pointer_up, test.node(root)), + pointer(GUI::InputEventType::pointer_move, source)}); + test.frame(); + ++count; + luassert_always(test.document().snapshot->nodes.size() == count); + luassert_always(test.children(root).back().child == test.document().selected_node); + + GUI::InputEvent blur; + blur.type = GUI::InputEventType::blur; + test.frame({pointer(GUI::InputEventType::pointer_down, source)}); + test.frame({pointer(GUI::InputEventType::pointer_move, test.node(root)), blur}); + test.frame(); + luassert_always(!test.document().hierarchy_drag.pressed); + luassert_always(test.document().snapshot->nodes.size() == count); + GUI::InputEvent focus; + focus.type = GUI::InputEventType::focus; + test.frame({focus}); + + GUI::InputEvent escape; + escape.type = GUI::InputEventType::key_down; + escape.key = KeyCode::esc; + test.frame({pointer(GUI::InputEventType::pointer_down, source)}); + test.frame({pointer(GUI::InputEventType::pointer_move, test.node(root)), escape}); + test.frame({pointer(GUI::InputEventType::pointer_up, test.node(root))}); + test.frame(); + luassert_always(test.document().snapshot->nodes.size() == count); + + // Palette drags are scoped to their document; switching documents cancels the old drag. + test.frame({pointer(GUI::InputEventType::pointer_down, source)}); + test.frame({pointer(GUI::InputEventType::pointer_move, test.node(root))}); + luassert_always(test.app.create_document()); + test.frame(); + test.frame({pointer(GUI::InputEventType::pointer_up, test.node(test.document().snapshot->root))}); + luassert_always(test.document().snapshot->nodes.size() == 1); + luassert_always(!test.app.documents[0].hierarchy_drag.pressed); + luassert_always(test.app.documents[0].snapshot->nodes.size() == count); + } +} + +void explorer_test(); + +int main() +{ + lupanic_if_failed(Luna::init()); + lupanic_if_failed(add_modules({GameGUI::module_game_gui(), EditorGUI::module_editor_gui(), + Frontend::module_frontend()})); + lupanic_if_failed(init_modules()); + palette_drag_test(); + explorer_test(); + Luna::close(); + return 0; +} diff --git a/Tests/GameGUITest/GameGUITest.Target.cs b/Tests/GameGUITest/GameGUITest.Target.cs new file mode 100644 index 00000000..fa745bd8 --- /dev/null +++ b/Tests/GameGUITest/GameGUITest.Target.cs @@ -0,0 +1,17 @@ +namespace LunaBuild.Core.Targets; + +public sealed class GameGUITestTargetRules : TargetRules +{ + public GameGUITestTargetRules() + : base( + name: "GameGUITest", + targetDirectory: "Tests/GameGUITest", + rulesPath: "Tests/GameGUITest/GameGUITest.Target.cs") + { + SupportedPlatforms(BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Linux); + Category = BuildTargetCategory.Tests; + Kind = BuildTargetKind.Executable; + Sources("Source/**.cpp"); + DependsOn("Runtime", "GameGUI"); + } +} diff --git a/Tests/GameGUITest/Source/Main.cpp b/Tests/GameGUITest/Source/Main.cpp new file mode 100644 index 00000000..7be61919 --- /dev/null +++ b/Tests/GameGUITest/Source/Main.cpp @@ -0,0 +1,828 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Main.cpp +* @author JXMaster +* @date 2026/8/25 +*/ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Luna; +using namespace Luna::GameGUI; + +#define lutest luassert_always + +namespace +{ + usize g_state_build_count = 0; + constexpr const c8* g_document_asset_path = "/GameGUITest/DocumentAsset"; + + NodeRecord make_node(const Guid& type, const c8* name = "") + { + NodeRecord node; + node.id = random_guid(); + node.type = type; + node.name = name; + node.properties = Variant(VariantType::object); + return node; + } + + Ref make_single_node_document(const NodeRecord& node) + { + Ref document = new_object(); + document->root = node.id; + document->nodes.push_back(node); + return document; + } + + void delete_asset_source(const Path& asset_path) + { + Path document_path = asset_path; + document_path.append_extension("cooked"); + lupanic_if_failed(VFS::delete_file(document_path)); + } + + void document_asset_test() + { + NodeRecord root = make_node(get_flex_node_type(), "Root"); + root.properties["gap"] = 12.0; + root.properties["nested"] = Variant(VariantType::object); + root.properties["nested"]["value"] = (u64)42; + Ref source = make_single_node_document(root); + + auto asset_result = Asset::new_asset(g_document_asset_path, get_asset_type(), false); + lutest(asset_result.valid()); + Asset::asset_t asset = asset_result.get(); + lupanic_if_failed(Asset::set_asset_data_unit_object(asset, Name(), source.object())); + lupanic_if_failed(Asset::save_asset_data_unit(asset, Name())); + lupanic_if_failed(Asset::set_asset_data_unit_object(asset, Name(), nullptr)); + lupanic_if_failed(Asset::load_asset_data_unit(asset, Name())); + auto loaded_result = Asset::get_asset_data_unit_object(asset, Name()); + lutest(loaded_result.valid()); + Ref loaded = loaded_result.get(); + lutest(loaded); + lutest(loaded->nodes.size() == 1); + lutest(loaded->nodes[0].type == root.type); + lutest(loaded->nodes[0].properties == root.properties); + + loaded->nodes[0].properties["unsaved"] = true; + lupanic_if_failed(Asset::load_asset_data_unit(asset, Name(), true)); + loaded_result = Asset::get_asset_data_unit_object(asset, Name()); + lutest(loaded_result.valid()); + loaded = loaded_result.get(); + lutest(loaded && !loaded->nodes[0].properties.contains("unsaved")); + + delete_asset_source(g_document_asset_path); + } + + void topology_validation_test() + { + NodeRecord root = make_node(get_flex_node_type()); + NodeRecord orphan = make_node(get_text_node_type()); + Ref document = new_object(); + document->root = root.id; + document->nodes.push_back(root); + document->nodes.push_back(orphan); + Vector diagnostics; + lutest(failed(validate_document(*document, &diagnostics))); + lutest(!diagnostics.empty()); + + ChildLink link; + link.child = orphan.id; + document->nodes[0].children.push_back(link); + lutest(succeeded(validate_document(*document))); + document->nodes[1].children.push_back(ChildLink{root.id}); + lutest(failed(validate_document(*document))); + } + + R prepare_test_node(const NodeRecord& node, object_t userdata) + { + Any data; + data.emplace(123); + return data; + } + + Variant create_test_state(object_t userdata) + { + Variant state(VariantType::object); + state["build_count"] = (u64)40; + return state; + } + + R build_test_node(BuildContext& context, const NodeRecord& node, object_t userdata) + { + lutest(context.prepared_data().as() && *context.prepared_data().as() == 123); + Variant& state = context.state(); + state["build_count"] = state["build_count"].unum() + 1; + g_state_build_count = (usize)state["build_count"].unum(); + GUI::ElementHandle element = context.gui()->begin_element(context.make_id("element")); + context.gui()->end_element(); + return element; + } + + RV resolve_test_node(ResolveContext& context, const NodeRecord& node, object_t userdata) + { + context.request_relayout(); + return ok; + } + + void registry_and_state_test() + { + Guid type = random_guid(); + NodeTypeDesc desc; + desc.type = type; + desc.name = "GameGUITest.StatefulNode"; + desc.prepare = prepare_test_node; + desc.create_state = create_test_state; + desc.build = build_test_node; + desc.resolve = resolve_test_node; + lupanic_if_failed(register_node_type(desc)); + lutest(failed(register_node_type(desc))); + + NodeRecord node = make_node(type); + Ref document = make_single_node_document(node); + InstanceDesc instance_desc; + instance_desc.document = document; + instance_desc.instance_scope = 0x12345678; + Ref instance = new_instance(instance_desc); + lupanic_if_failed(instance->prepare()); + + Ref gui = GUI::new_context(); + GUI::FrameDesc frame; + frame.logical_size = Float2U(100.0f, 100.0f); + gui->begin_frame(frame); + gui->push_layer(1); + auto first = instance->build(gui); + gui->pop_layer(); + lutest(first.valid()); + lutest(g_state_build_count == 41); + GUI::id_t stable_id = first.get().id; + lutest(stable_id == instance->make_stable_id(node.id, "element")); + + gui->begin_frame(frame); + gui->push_layer(1); + auto second = instance->build(gui); + gui->pop_layer(); + lutest(second.valid()); + lutest(second.get().id == stable_id); + lutest(second.get().generation != first.get().generation); + lutest(g_state_build_count == 42); + lutest(failed(instance->build(gui))); + gui->route_input(); + lupanic_if_failed(instance->resolve_interactions(gui)); + lutest(instance->relayout_requested()); + lupanic_if_failed(unregister_node_type(type)); + } + + void button_action_test() + { + NodeRecord button = make_node(get_button_node_type(), "ActionButton"); + button.properties["width"] = 100.0; + button.properties["height"] = 50.0; + button.properties["text"] = "Run"; + button.properties["action"] = "run"; + button.properties["action_payload"] = (u64)99; + Ref document = make_single_node_document(button); + InstanceDesc desc; + desc.document = document; + Ref instance = new_instance(desc); + lupanic_if_failed(instance->prepare()); + + Ref gui = GUI::new_context(); + GUI::FrameDesc frame; + frame.logical_size = Float2U(200.0f, 100.0f); + gui->begin_frame(frame); + GUI::InputEvent enter; + enter.type = GUI::InputEventType::pointer_enter; + enter.position = Float2U(10.0f, 10.0f); + gui->add_input_event(enter); + GUI::InputEvent down = enter; + down.type = GUI::InputEventType::pointer_down; + gui->add_input_event(down); + GUI::InputEvent up = enter; + up.type = GUI::InputEventType::pointer_up; + gui->add_input_event(up); + gui->push_layer(1); + auto root = instance->build(gui); + gui->pop_layer(); + lutest(root.valid()); + lupanic_if_failed(gui->apply_layout(root.get(), RectF(0.0f, 0.0f, 100.0f, 50.0f))); + gui->route_input(); + lupanic_if_failed(instance->resolve_interactions(gui)); + Span actions = instance->get_actions(); + lutest(actions.size() == 1); + lutest(actions[0].name == Name("run")); + lutest(actions[0].node == button.id); + lutest(actions[0].source_id != 0); + lutest(actions[0].payload.unum() == 99); + GUI::ElementVisualConfig visual_config = gui->get_element_visual_config(root.get()); + lutest(visual_config.before_children.size() == 2); + lutest(!gui->get_draw_config(root.get()).callback); + lupanic_if_failed(gui->generate_draw_commands()); + Span commands = gui->get_draw_commands(); + lutest(commands.size() == 2); + lutest(commands[0].type == GUI::DrawCommandType::rounded_rect); + lutest(commands[1].type == GUI::DrawCommandType::text); + lutest(commands[0].paint_order_id + 1 == commands[1].paint_order_id); + lutest(gui->get_performance_counters().draw_callback_count == 0); + } + + void element_visual_effect_test() + { + Ref gui = GUI::new_context(); + GUI::FrameDesc frame; + frame.logical_size = Float2U(100.0f, 100.0f); + gui->begin_frame(frame); + gui->push_layer(1); + GUI::ElementHandle root = gui->begin_element(1); + GUI::ElementVisualEffect root_before[3]; + root_before[0].command.type = GUI::DrawCommandType::rect; + root_before[0].command.rect_reference = GUI::DrawCommandRectReference::element; + root_before[0].command.color = Float4U(1.0f, 0.0f, 0.0f, 1.0f); + root_before[1].command.type = GUI::DrawCommandType::rounded_rect_stroke; + root_before[1].command.rect_reference = GUI::DrawCommandRectReference::element; + root_before[1].command.color = Float4U(0.0f, 1.0f, 0.0f, 1.0f); + root_before[1].command.radius = 4.0f; + root_before[1].command.line_width = 2.0f; + root_before[2].command.type = GUI::DrawCommandType::rounded_rect_stroke; + root_before[2].command.rect_reference = GUI::DrawCommandRectReference::element; + root_before[2].command.rect = RectF(2.0f, 2.0f, -4.0f, -4.0f); + root_before[2].command.color = Float4U(0.0f, 0.0f, 1.0f, 1.0f); + root_before[2].command.radius = 2.0f; + root_before[2].command.line_width = 1.0f; + GUI::ElementVisualEffect root_after; + root_after.command.type = GUI::DrawCommandType::text; + root_after.command.rect_reference = GUI::DrawCommandRectReference::element; + root_after.command.text = "after"; + GUI::ElementVisualConfig root_config; + root_config.before_children = Span(root_before, 3); + root_config.after_children = Span(&root_after, 1); + lupanic_if_failed(gui->set_element_visual_config(root, root_config)); + + GUI::ElementHandle child = gui->begin_element(2); + GUI::ElementVisualEffect child_visual; + child_visual.command.type = GUI::DrawCommandType::image; + child_visual.command.rect_reference = GUI::DrawCommandRectReference::element; + GUI::ElementVisualConfig child_config; + child_config.before_children = Span(&child_visual, 1); + lupanic_if_failed(gui->set_element_visual_config(child, child_config)); + gui->end_element(); + gui->end_element(); + gui->pop_layer(); + + root_before[0].command.color = Float4U(0.0f); + root_after.command.text = "mutated"; + GUI::ElementVisualConfig stored = gui->get_element_visual_config(root); + lutest(stored.before_children.size() == 3); + lutest(stored.after_children.size() == 1); + lutest(stored.before_children[0].command.color.x == 1.0f); + lutest(!strcmp(stored.after_children[0].command.text.c_str(), "after")); + lupanic_if_failed(gui->set_element_visual_config(root, stored)); + stored = gui->get_element_visual_config(root); + lutest(stored.before_children.size() == 3 && stored.after_children.size() == 1); + + GUI::ElementVisualEffect structural; + structural.command.type = GUI::DrawCommandType::push_clip; + GUI::ElementVisualConfig invalid_config; + invalid_config.before_children = Span(&structural, 1); + lutest(failed(gui->set_element_visual_config(root, invalid_config))); + lutest(gui->get_element_visual_config(root).before_children.size() == 3); + + lupanic_if_failed(gui->generate_draw_commands()); + Span commands = gui->get_draw_commands(); + lutest(commands.size() == 5); + lutest(commands[0].type == GUI::DrawCommandType::rect && commands[0].element == root.index); + lutest(commands[1].type == GUI::DrawCommandType::rounded_rect_stroke && + commands[1].element == root.index); + lutest(commands[2].type == GUI::DrawCommandType::rounded_rect_stroke && + commands[2].element == root.index && commands[2].line_width == 1.0f); + lutest(commands[3].type == GUI::DrawCommandType::image && commands[3].element == child.index); + lutest(commands[4].type == GUI::DrawCommandType::text && commands[4].element == root.index); + for(usize i = 0; i < commands.size(); ++i) + { + lutest(commands[i].paint_order_id == i); + lutest(commands[i].layer == 0); + } + lutest(gui->get_performance_counters().draw_callback_count == 0); + } + + Variant make_float2(f64 x, f64 y) + { + Variant value(VariantType::array); + value.push_back(x); + value.push_back(y); + return value; + } + + Variant make_float4(f64 x, f64 y, f64 z, f64 w) + { + Variant value(VariantType::array); + value.push_back(x); + value.push_back(y); + value.push_back(z); + value.push_back(w); + return value; + } + + void authored_visual_effect_test() + { + NodeRecord panel = make_node(get_panel_node_type(), "EffectPanel"); + panel.properties["width"] = 100.0; + panel.properties["height"] = 50.0; + Variant effects(VariantType::array); + Variant shadow(VariantType::object); + shadow["phase"] = "before_children"; + shadow["type"] = "shadow"; + shadow["inset"] = make_float4(2.0, 3.0, 4.0, 5.0); + shadow["color"] = make_float4(0.0, 0.0, 0.0, 0.5); + shadow["radius"] = 6.0; + shadow["shadow_offset"] = make_float2(7.0, 8.0); + shadow["shadow_softness"] = 9.0; + shadow["shadow_spread"] = 10.0; + effects.push_back(move(shadow)); + Variant gradient(VariantType::object); + gradient["phase"] = "before_children"; + gradient["type"] = "gradient_rect"; + gradient["color"] = make_float4(1.0, 0.0, 0.0, 1.0); + gradient["color_top_right"] = make_float4(0.0, 1.0, 0.0, 1.0); + gradient["color_bottom_right"] = make_float4(0.0, 0.0, 1.0, 1.0); + gradient["color_bottom_left"] = make_float4(1.0, 1.0, 1.0, 1.0); + effects.push_back(move(gradient)); + Variant rectangle(VariantType::object); + rectangle["phase"] = "after_children"; + rectangle["type"] = "rect"; + effects.push_back(move(rectangle)); + Variant rounded(VariantType::object); + rounded["phase"] = "after_children"; + rounded["type"] = "rounded_rect"; + rounded["radius"] = 3.0; + effects.push_back(move(rounded)); + Variant stroke(VariantType::object); + stroke["phase"] = "after_children"; + stroke["type"] = "rounded_rect_stroke"; + stroke["color"] = make_float4(1.0, 0.0, 0.0, 1.0); + stroke["radius"] = 4.0; + stroke["line_width"] = 2.0; + effects.push_back(move(stroke)); + panel.properties["visual_effects"] = move(effects); + + Ref document = make_single_node_document(panel); + InstanceDesc desc; + desc.document = document; + Ref instance = new_instance(desc); + lupanic_if_failed(instance->prepare()); + Ref gui = GUI::new_context(); + GUI::FrameDesc frame; + frame.logical_size = Float2U(100.0f, 50.0f); + gui->begin_frame(frame); + gui->push_layer(1); + auto root = instance->build(gui); + gui->pop_layer(); + lutest(root.valid()); + lupanic_if_failed(gui->apply_layout(root.get(), RectF(0.0f, 0.0f, 100.0f, 50.0f))); + GUI::ElementVisualConfig config = gui->get_element_visual_config(root.get()); + lutest(config.before_children.size() == 3); + lutest(config.after_children.size() == 3); + lutest(config.before_children[0].command.type == GUI::DrawCommandType::rounded_rect); + const GUI::DrawCommand& decoded_shadow = config.before_children[1].command; + lutest(decoded_shadow.type == GUI::DrawCommandType::shadow); + lutest(decoded_shadow.rect.offset_x == 2.0f && + decoded_shadow.rect.offset_y == 3.0f && decoded_shadow.rect.width == -6.0f && + decoded_shadow.rect.height == -8.0f); + lutest(decoded_shadow.shadow.offset.x == 7.0f && + decoded_shadow.shadow.offset.y == 8.0f && + decoded_shadow.shadow.softness == 9.0f && + decoded_shadow.shadow.spread == 10.0f); + lutest(config.before_children[2].command.type == GUI::DrawCommandType::gradient_rect); + lutest(config.before_children[2].command.color_top_right.y == 1.0f); + lutest(config.after_children[0].command.type == GUI::DrawCommandType::rect); + lutest(config.after_children[1].command.type == GUI::DrawCommandType::rounded_rect); + lutest(config.after_children[1].command.radius == 3.0f); + lutest(config.after_children[2].command.type == + GUI::DrawCommandType::rounded_rect_stroke); + lupanic_if_failed(gui->generate_draw_commands()); + Span commands = gui->get_draw_commands(); + lutest(commands.size() == 6); + lutest(commands[0].type == GUI::DrawCommandType::rounded_rect); + lutest(commands[1].type == GUI::DrawCommandType::shadow); + lutest(commands[2].type == GUI::DrawCommandType::gradient_rect); + lutest(commands[3].type == GUI::DrawCommandType::rect); + lutest(commands[4].type == GUI::DrawCommandType::rounded_rect); + lutest(commands[5].type == GUI::DrawCommandType::rounded_rect_stroke); + + NodeRecord malformed = make_node(get_flex_node_type(), "MalformedEffect"); + malformed.properties["visual_effects"] = Variant(VariantType::array); + Variant invalid_effect(VariantType::object); + invalid_effect["type"] = "rect"; + malformed.properties["visual_effects"].push_back(move(invalid_effect)); + InstanceDesc malformed_desc; + malformed_desc.document = make_single_node_document(malformed); + Ref malformed_instance = new_instance(malformed_desc); + lutest(failed(malformed_instance->prepare())); + } + + void canvas_layout_test() + { + NodeRecord canvas = make_node(get_canvas_node_type(), "CanvasRoot"); + NodeRecord panel = make_node(get_panel_node_type(), "PlacedPanel"); + panel.properties["width"] = 40.0; + panel.properties["height"] = 30.0; + ChildLink link; + link.child = panel.id; + link.attachment = Variant(VariantType::object); + link.attachment["anchor_min"] = make_float2(0.0, 0.0); + link.attachment["anchor_max"] = make_float2(0.0, 0.0); + link.attachment["offset"] = make_float4(10.0, 20.0, 0.0, 0.0); + canvas.children.push_back(link); + Ref document = new_object(); + document->root = canvas.id; + document->nodes.push_back(canvas); + document->nodes.push_back(panel); + + InstanceDesc desc; + desc.document = document; + Ref instance = new_instance(desc); + lupanic_if_failed(instance->prepare()); + Ref gui = GUI::new_context(); + GUI::FrameDesc frame; + frame.logical_size = Float2U(200.0f, 100.0f); + gui->begin_frame(frame); + gui->push_layer(1); + auto root = instance->build(gui); + gui->pop_layer(); + lutest(root.valid()); + lupanic_if_failed(gui->apply_layout(root.get(), RectF(0.0f, 0.0f, 200.0f, 100.0f))); + const GUI::Element* placed = gui->find_element(instance->make_stable_id(panel.id, "element")); + lutest(placed); + lutest(placed->layout_result.rect.offset_x == 10.0f); + lutest(placed->layout_result.rect.offset_y == 20.0f); + lutest(placed->layout_result.rect.width == 40.0f); + lutest(placed->layout_result.rect.height == 30.0f); + } + + void flex_stretch_overrides_explicit_cross_size_test() + { + NodeRecord root = make_node(get_flex_node_type(), "FlexRoot"); + NodeRecord fixed = make_node(get_text_node_type(), "FixedText"); + fixed.properties["width"] = 40.0; + fixed.properties["height"] = 30.0; + NodeRecord percent = make_node(get_text_node_type(), "PercentText"); + percent.properties["width_percent"] = 0.5; + percent.properties["height"] = 30.0; + root.children.push_back(ChildLink{fixed.id}); + root.children.push_back(ChildLink{percent.id}); + + Ref document = new_object(); + document->root = root.id; + document->nodes.push_back(root); + document->nodes.push_back(fixed); + document->nodes.push_back(percent); + InstanceDesc desc; + desc.document = document; + Ref instance = new_instance(desc); + lupanic_if_failed(instance->prepare()); + + Ref gui = GUI::new_context(); + GUI::FrameDesc frame; + frame.logical_size = Float2U(200.0f, 100.0f); + gui->begin_frame(frame); + gui->push_layer(1); + auto generated_root = instance->build(gui); + gui->pop_layer(); + lutest(generated_root.valid()); + lupanic_if_failed(gui->apply_layout(generated_root.get(), + RectF(0.0f, 0.0f, 200.0f, 100.0f))); + + const GUI::Element* fixed_element = gui->find_element( + instance->make_stable_id(fixed.id, "element")); + const GUI::Element* percent_element = gui->find_element( + instance->make_stable_id(percent.id, "element")); + lutest(fixed_element && fixed_element->layout_result.rect.width == 200.0f); + lutest(percent_element && percent_element->layout_result.rect.width == 200.0f); + } + + RectF layout_text_in_flex(const NodeRecord& text, const c8* axis, + const c8* alignment, const Float2U& available = Float2U(400.0f, 300.0f)) + { + NodeRecord root = make_node(get_flex_node_type(), "TextMeasureRoot"); + root.properties["axis"] = axis; + if(alignment) root.properties["cross_alignment"] = alignment; + root.children.push_back(ChildLink{text.id}); + Ref document = new_object(); + document->root = root.id; + document->nodes.push_back(root); + document->nodes.push_back(text); + InstanceDesc desc; + desc.document = document; + Ref instance = new_instance(desc); + lupanic_if_failed(instance->prepare()); + Ref gui = GUI::new_context(); + lupanic_if_failed(gui->register_font("registered-font", Font::get_default_font())); + GUI::FrameDesc frame; + frame.logical_size = available; + gui->begin_frame(frame); + gui->push_layer(1); + auto generated_root = instance->build(gui); + gui->pop_layer(); + lutest(generated_root.valid()); + lupanic_if_failed(gui->apply_layout(generated_root.get(), + RectF(0.0f, 0.0f, available.x, available.y))); + const GUI::Element* element = gui->find_element(instance->make_stable_id(text.id, "element")); + lutest(element); + RectF result = element->layout_result.rect; + lupanic_if_failed(gui->generate_draw_commands()); + lutest(gui->get_draw_commands().size() == 1 && + gui->get_draw_commands()[0].type == GUI::DrawCommandType::text); + return result; + } + + void text_content_measure_test() + { + NodeRecord text = make_node(get_text_node_type(), "AutoText"); + text.properties["text"] = "WWWW"; + text.properties["font_size"] = 24.0; + VG::TextArrangeSection section; + section.font_file = Font::get_default_font(); + section.font_size = 24.0f; + section.num_chars = 4; + VG::TextArrangeResult arranged = VG::arrange_text("WWWW", 4, + Span(§ion, 1), + RectF(0.0f, 0.0f, 400.0f, 300.0f), + VG::TextAlignment::end, VG::TextAlignment::begin); + f32 natural_width = arranged.bounding_rect.width; + f32 natural_height = arranged.bounding_rect.height; + lutest(natural_width > 0.0f && natural_height > 0.0f); + auto check_near = [](f32 actual, f32 expected) + { + lutest(actual > expected - 0.001f && actual < expected + 0.001f); + }; + + for(const c8* axis : {"x", "y"}) + { + bool horizontal = !strcmp(axis, "x"); + for(const c8* alignment : {"start", "center", "end", "stretch", ""}) + { + bool stretch = !strcmp(alignment, "stretch") || !alignment[0]; + const c8* configured_alignment = alignment[0] ? alignment : nullptr; + RectF rect = layout_text_in_flex(text, axis, configured_alignment); + check_near(rect.width, stretch && !horizontal ? 400.0f : natural_width); + check_near(rect.height, stretch && horizontal ? 300.0f : natural_height); + f32 extra = horizontal ? 300.0f - rect.height : 400.0f - rect.width; + f32 offset = !strcmp(alignment, "center") ? extra * 0.5f : + (!strcmp(alignment, "end") ? extra : 0.0f); + check_near(horizontal ? rect.offset_y : rect.offset_x, offset); + + NodeRecord fixed = text; + fixed.properties["width"] = 80.0; + fixed.properties["height"] = 40.0; + rect = layout_text_in_flex(fixed, axis, configured_alignment); + check_near(rect.width, stretch && !horizontal ? 400.0f : 80.0f); + check_near(rect.height, stretch && horizontal ? 300.0f : 40.0f); + } + } + + // The measured box can be used by the renderer without losing any glyphs. + RectF auto_rect = layout_text_in_flex(text, "y", "start"); + arranged = VG::arrange_text("WWWW", 4, Span(§ion, 1), + auto_rect, VG::TextAlignment::center, VG::TextAlignment::begin); + lutest(!arranged.overflow && arranged.lines.size() == 1 && + arranged.lines[0].glyphs.size() == 4); + + text.properties["font"] = "registered-font"; + check_near(layout_text_in_flex(text, "y", "start").width, natural_width); + text.properties["font"] = "missing-font"; + check_near(layout_text_in_flex(text, "y", "start").width, natural_width); + text.properties["font_size"] = 48.0; + check_near(layout_text_in_flex(text, "y", "start").width, natural_width * 2.0f); + text.properties["font_size"] = 24.0; + text.properties["text"] = "WW"; + check_near(layout_text_in_flex(text, "y", "start").width, natural_width * 0.5f); + text.properties["text"] = "WWWW"; + text.properties["padding"] = make_float4(3.0, 4.0, 5.0, 6.0); + RectF padded = layout_text_in_flex(text, "y", "start"); + check_near(padded.width, natural_width + 8.0f); + check_near(padded.height, natural_height + 10.0f); + text.properties.erase("padding"); + + // Auto height must account for wrapping at an explicit width, without clipping to + // the parent's available height while measuring. + text.properties["text"] = "WWWWWWWWWWWW"; + text.properties["width"] = (f64)(natural_width * 0.6f); + text.properties["flex_shrink"] = 0.0; + RectF wrapped = layout_text_in_flex(text, "y", "start", Float2U(400.0f, 30.0f)); + lutest(wrapped.height > natural_height * 2.0f); + check_near(wrapped.width, natural_width * 0.6f); + text.properties.erase("width"); + text.properties["width_percent"] = (f64)(natural_width * 0.6f / 400.0f); + RectF percent = layout_text_in_flex(text, "y", "start", Float2U(400.0f, 30.0f)); + check_near(percent.width, wrapped.width); + check_near(percent.height, wrapped.height); + text.properties.erase("width_percent"); + text.properties["text"] = ""; + RectF empty = layout_text_in_flex(text, "y", "start"); + lutest(empty.width == 0.0f && empty.height == 0.0f); + } + + void flex_container_properties_test() + { + NodeRecord root = make_node(get_flex_node_type(), "ConfiguredFlex"); + root.properties["axis"] = "x"; + root.properties["reverse"] = true; + root.properties["wrap"] = "wrap_reverse"; + root.properties["main_alignment"] = "space_evenly"; + root.properties["cross_alignment"] = "center"; + root.properties["line_alignment"] = "space_around"; + root.properties["gap"] = 7.0; + root.properties["line_gap"] = 11.0; + root.properties["clip_children"] = true; + + InstanceDesc desc; + desc.document = make_single_node_document(root); + Ref instance = new_instance(desc); + lupanic_if_failed(instance->prepare()); + Ref gui = GUI::new_context(); + GUI::FrameDesc frame; + frame.logical_size = Float2U(200.0f, 100.0f); + gui->begin_frame(frame); + gui->push_layer(1); + auto generated_root = instance->build(gui); + gui->pop_layer(); + lutest(generated_root.valid()); + GUI::LayoutCallbackConfig callbacks = + gui->get_layout_callback_config(generated_root.get()); + lutest(callbacks.algorithm == Name("GameGUI.Flex") && callbacks.userdata); + const GUI::FlexLayoutDesc& layout = + *reinterpret_cast(callbacks.userdata); + lutest(layout.axis == GUI::LayoutAxis::x); + lutest(layout.reverse); + lutest(layout.wrap == GUI::FlexWrap::wrap_reverse); + lutest(layout.main_alignment == GUI::FlexAlignment::space_evenly); + lutest(layout.cross_alignment == GUI::FlexAlignment::center); + lutest(layout.line_alignment == GUI::FlexAlignment::space_around); + lutest(layout.main_axis_gap == 7.0f); + lutest(layout.cross_axis_gap == 11.0f); + lutest(layout.clip_children); + } + + Ref make_nested_text_document() + { + NodeRecord text = make_node(get_text_node_type(), "NestedText"); + text.properties["height"] = 20.0; + text.properties["text"] = "Nested"; + return make_single_node_document(text); + } + + Asset::asset_t make_dynamic_document_asset(const Ref& document) + { + auto asset = Asset::new_asset(Path(), get_asset_type(), false); + lutest(asset.valid()); + lupanic_if_failed(Asset::set_asset_data_unit_object(asset.get(), Name(), document.object())); + return asset.get(); + } + + NodeRecord make_asset_instance(Asset::asset_t asset) + { + NodeRecord node = make_node(get_asset_instance_node_type(), "NestedAsset"); + c8 buffer[GUID_STRING_LENGTH]; + lupanic_if_failed(encode_guid(Asset::get_asset_guid(asset), buffer, sizeof(buffer))); + String asset_guid(buffer, sizeof(buffer)); + node.properties["asset"] = asset_guid.c_str(); + node.properties["height"] = 30.0; + return node; + } + + void nested_asset_test() + { + Asset::asset_t unloaded_asset = Asset::get_asset(random_guid()); + Ref unloaded_parent = make_single_node_document(make_asset_instance(unloaded_asset)); + InstanceDesc unloaded_desc; + unloaded_desc.document = unloaded_parent; + Ref unloaded_instance = new_instance(unloaded_desc); + lutest(failed(unloaded_instance->prepare())); + + Ref nested_document = make_nested_text_document(); + Asset::asset_t nested_asset = make_dynamic_document_asset(nested_document); + + NodeRecord root = make_node(get_flex_node_type(), "RepeatedMounts"); + NodeRecord first = make_asset_instance(nested_asset); + NodeRecord second = make_asset_instance(nested_asset); + root.children.push_back(ChildLink{first.id}); + root.children.push_back(ChildLink{second.id}); + Ref document = new_object(); + document->root = root.id; + document->nodes.push_back(root); + document->nodes.push_back(first); + document->nodes.push_back(second); + lupanic_if_failed(validate_document(*document)); + + Asset::asset_t parent_asset = make_dynamic_document_asset(document); + Vector references; + Asset::get_asset_data_unit_referred_assets(parent_asset, Name(), references); + lutest(references.size() == 1 && references[0] == nested_asset); + + InstanceDesc desc; + desc.document = document; + desc.source_asset = parent_asset; + desc.instance_scope = 77; + Ref instance = new_instance(desc); + lupanic_if_failed(instance->prepare()); + Ref gui = GUI::new_context(); + GUI::FrameDesc frame; + frame.logical_size = Float2U(200.0f, 100.0f); + gui->begin_frame(frame); + gui->push_layer(1); + auto generated_root = instance->build(gui); + gui->pop_layer(); + lutest(generated_root.valid()); + lutest(gui->get_elements().size() == 5); + Span generated = instance->get_generated_nodes(); + lutest(generated.size() == 5); + usize nested_text_count = 0; + GUI::id_t first_nested_source = 0; + for (const GeneratedNodeInfo& info : generated) + { + if (info.node == nested_document->root) + { + ++nested_text_count; + if (!first_nested_source) + first_nested_source = info.source_id; + else + lutest(first_nested_source != info.source_id); + } + lutest(info.source_id != 0 && info.root_element_id != 0); + } + lutest(nested_text_count == 2); + Span elements = gui->get_elements(); + for (usize i = 0; i < elements.size(); ++i) + { + for (usize j = i + 1; j < elements.size(); ++j) + lutest(elements[i].id != elements[j].id); + } + lupanic_if_failed(gui->apply_layout(generated_root.get(), RectF(0.0f, 0.0f, 200.0f, 100.0f))); + } + + void nested_cycle_test() + { + Ref document_a = new_object(); + Ref document_b = new_object(); + Asset::asset_t asset_a = make_dynamic_document_asset(document_a); + Asset::asset_t asset_b = make_dynamic_document_asset(document_b); + NodeRecord node_a = make_asset_instance(asset_b); + NodeRecord node_b = make_asset_instance(asset_a); + document_a->root = node_a.id; + document_a->nodes.push_back(node_a); + document_b->root = node_b.id; + document_b->nodes.push_back(node_b); + + InstanceDesc desc; + desc.document = document_a; + desc.source_asset = asset_a; + Ref instance = new_instance(desc); + RV result = instance->prepare(); + lutest(failed(result)); + lutest(!instance->get_diagnostics().empty()); + Span diagnostics = instance->get_diagnostics(); + lutest(diagnostics[diagnostics.size() - 1].asset_mount_chain.size() == 3); + } +} + +int main() +{ + lupanic_if_failed(Luna::init()); + lupanic_if_failed(add_modules({module_game_gui()})); + lupanic_if_failed(init_modules()); + const c8* current_dir = get_current_dir(); + auto file_system = VFS::new_native_file_system(current_dir); + release_current_dir(current_dir); + lupanic_if_failed(file_system); + RV mount_result = VFS::mount(file_system.get(), "/GameGUITest"); + file_system.get() = nullptr; + lupanic_if_failed(mount_result); + document_asset_test(); + topology_validation_test(); + registry_and_state_test(); + element_visual_effect_test(); + authored_visual_effect_test(); + button_action_test(); + canvas_layout_test(); + flex_stretch_overrides_explicit_cross_size_test(); + text_content_measure_test(); + flex_container_properties_test(); + nested_asset_test(); + nested_cycle_test(); + lupanic_if_failed(VFS::unmount("/GameGUITest")); + Luna::close(); + return 0; +} diff --git a/Tests/PakTest/Interop.py b/Tests/PakTest/Interop.py new file mode 100644 index 00000000..4c360a5e --- /dev/null +++ b/Tests/PakTest/Interop.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Check Pak interoperability with Python's independent ZIP implementation.""" +from pathlib import Path +import subprocess +import tempfile +import zipfile + + +def main(): + root = Path(__file__).resolve().parents[2] + with tempfile.TemporaryDirectory(prefix="luna-pak-interop-") as temporary: + directory = Path(temporary) + source = directory / "python.zip" + output = directory / "luna.pak" + with zipfile.ZipFile(source, "w", allowZip64=True) as archive: + archive.writestr("python/stored.txt", b"stored by Python", compress_type=zipfile.ZIP_STORED) + archive.writestr("python/文件.txt", b"compressed by Python", compress_type=zipfile.ZIP_DEFLATED) + archive.writestr("empty/", b"") + with archive.open("small-zip64", "w", force_zip64=True) as entry: + entry.write(b"ZIP64 local entry") + original = source.read_bytes() + subprocess.run( + ["dotnet", "run", "--project", "LunaBuild.csproj", "--", "run", "--target", "PakTest", + "--", str(output), str(source)], + cwd=root, check=True, + ) + assert source.read_bytes() == original + with zipfile.ZipFile(output) as archive: + assert archive.testzip() is None + assert archive.read("other") == b"other PAK!" + assert archive.read("copy") == b"changed" + assert archive.read("assets/文件.bin") == b"abXYe" + assert archive.getinfo("other").compress_type == zipfile.ZIP_DEFLATED + assert archive.getinfo("copy").compress_type == zipfile.ZIP_STORED + assert archive.getinfo("empty-again/").is_dir() + assert not any(name.startswith("__luna_pak_move_") for name in archive.namelist()) + with zipfile.ZipFile(str(source) + ".edited.pak") as archive: + assert archive.testzip() is None + assert archive.read("imported/stored.txt") == b"stored by Python" + assert archive.read("imported/文件.txt") == b"compressed by Python" + assert archive.read("imported/added.txt") == b"written by Pak" + assert archive.read("small-zip64") == b"ZIP64 local entry" + assert "python/stored.txt" not in archive.namelist() + print("Python/Pak ZIP interoperability passed.") + + +if __name__ == "__main__": + main() diff --git a/Tests/PakTest/PakTest.Target.cs b/Tests/PakTest/PakTest.Target.cs new file mode 100644 index 00000000..0d3d9a82 --- /dev/null +++ b/Tests/PakTest/PakTest.Target.cs @@ -0,0 +1,15 @@ +namespace LunaBuild.Core.Targets; + +public sealed class PakTestTargetRules : TargetRules +{ + public PakTestTargetRules() : base("PakTest", "Tests/PakTest", "Tests/PakTest/PakTest.Target.cs") + { + SupportedPlatforms(BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Linux); + Category = BuildTargetCategory.Tests; + Kind = BuildTargetKind.Executable; + Headers("Source/*.hpp"); + MetaHeaders("Source/TestStream.hpp"); + Sources("Source/*.cpp"); + DependsOn("Runtime", "Zip", "Pak"); + } +} diff --git a/Tests/PakTest/Source/Main.cpp b/Tests/PakTest/Source/Main.cpp new file mode 100644 index 00000000..000ee035 --- /dev/null +++ b/Tests/PakTest/Source/Main.cpp @@ -0,0 +1,533 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Main.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include "TestStream.hpp" +#include "PakTest.meta.generated.hpp" +#include +#include +#include +#include +#include +#include + +using namespace Luna; +using namespace Luna::Pak; + +template +static T must(R result) +{ + lupanic_if_failed(result); + return move(result.get()); +} + +template +static void expect_error(R result, ResultCode code) +{ + luassert_always(failed(result)); + if(unwrap_errcode(result.errcode()) != code) + { + printf("Unexpected error: %s\n", explain(result.errcode())); + luassert_always(false); + } +} + +static Ref data_stream(const c8* text = "") +{ + auto stream = new_object(); + stream->bytes.resize(strlen(text)); + if(!stream->bytes.empty()) memcpy(stream->bytes.data(), text, stream->bytes.size()); + return stream; +} + +static bool equal_bytes(const Vector& left, const Vector& right) +{ + return left.size() == right.size() && (left.empty() || !memcmp(left.data(), right.data(), left.size())); +} + +static Vector read_all(IStream* file) +{ + Vector bytes; + byte_t buffer[4096]; + while(true) + { + usize count = 0; + lupanic_if_failed(file->read(buffer, sizeof(buffer), &count)); + if(!count) break; + bytes.insert(bytes.end(), buffer, buffer + count); + } + return bytes; +} + +static Vector read_file(IPak* package, const c8* path) +{ + auto file = must(package->open_file(path, FileOpenFlag::read, FileCreationMode::open_existing)); + return read_all(file); +} + +static void expect_text(IPak* package, const c8* path, const c8* text) +{ + auto bytes = read_file(package, path); + luassert_always(bytes.size() == strlen(text)); + luassert_always(bytes.empty() || !memcmp(bytes.data(), text, bytes.size())); +} + +static void put_file(IPak* package, const c8* path, const c8* text) +{ + auto file = must(package->open_file(path, FileOpenFlag::write, FileCreationMode::create_always)); + lupanic_if_failed(file->write(text, strlen(text))); +} + +static Ref create_test() +{ + auto package = must(new_pak()); + luassert_always(package->is_open() && package->is_dirty() && !package->is_read_only()); + luassert_always(test_flags(must(package->get_file_attribute("/")).attributes, FileAttributeFlag::directory)); + expect_error(package->create_dir("/"), E_ALREADY_EXISTS); + expect_error(package->delete_file("/"), E_BAD_ARGUMENTS); + expect_error(package->get_file_attribute(nullptr), E_BAD_ARGUMENTS); + const c8* invalid_paths[] = {"//", "../escape", "a/../b", "a/./b", "./a", "a//b", "a//", "a\\b", "C:/a", "\xc0\xaf"}; + for(const auto path : invalid_paths) expect_error(package->create_dir(path), E_BAD_ARGUMENTS); + expect_error(package->create_dir("missing/child"), E_NOT_FOUND); + expect_error(package->open_file("missing/child", FileOpenFlag::write, FileCreationMode::create_new), E_NOT_FOUND); + expect_error(package->open_file("none", FileOpenFlag::read, FileCreationMode::open_always), E_ACCESS_DENIED); + expect_error(package->open_file("none", FileOpenFlag::none, FileCreationMode::open_existing), E_BAD_ARGUMENTS); + expect_error(package->open_file("none", (FileOpenFlag)128, FileCreationMode::open_existing), E_BAD_ARGUMENTS); + expect_error(package->open_file("none", FileOpenFlag::write, (FileCreationMode)99), E_BAD_ARGUMENTS); + lupanic_if_failed(package->create_dir("/data/")); + lupanic_if_failed(package->create_dir("empty")); + auto snapshot = must(package->open_dir("")); + luassert_always(!strcmp(snapshot->get_filename(), "data")); + expect_error(package->open_file("data", FileOpenFlag::read, FileCreationMode::open_existing), E_IS_DIRECTORY); + { + auto file = must(package->open_file("data/\xe6\x96\x87\xe4\xbb\xb6.bin", FileOpenFlag::read | FileOpenFlag::write, + FileCreationMode::create_new)); + lupanic_if_failed(file->write("abcdefgh", 8)); + lupanic_if_failed(file->seek(2, SeekMode::begin)); + lupanic_if_failed(file->write("XY", 2)); + lupanic_if_failed(file->seek(10, SeekMode::begin)); + lupanic_if_failed(file->write("Z", 1)); + lupanic_if_failed(file->set_size(14)); + lupanic_if_failed(file->seek(0, SeekMode::begin)); + auto bytes = read_all(file); + const c8 expected[] = {'a', 'b', 'X', 'Y', 'e', 'f', 'g', 'h', 0, 0, 'Z', 0, 0, 0}; + luassert_always(bytes.size() == sizeof(expected) && !memcmp(bytes.data(), expected, sizeof(expected))); + lupanic_if_failed(file->set_size(5)); + luassert_always(must(file->tell()) == 14 && file->get_size() == 5); + expect_error(file->seek(I64_MIN, SeekMode::current), E_OUT_OF_RANGE); + expect_error(file->seek(0, (SeekMode)99), E_BAD_ARGUMENTS); + lupanic_if_failed(file->seek(I64_MAX, SeekMode::begin)); + expect_error(file->write("x", 1), E_OUT_OF_RANGE); + expect_error(file->set_size(U64_MAX), E_OUT_OF_RANGE); + expect_error(package->open_file("data/\xe6\x96\x87\xe4\xbb\xb6.bin", FileOpenFlag::read, FileCreationMode::open_existing), E_BUSY); + expect_error(package->copy_file("data/\xe6\x96\x87\xe4\xbb\xb6.bin", "copy"), E_BUSY); + expect_error(package->move_file("data", "moved"), E_BUSY); + expect_error(package->flush(data_stream()), E_BUSY); + expect_error(package->discard(), E_BUSY); + put_file(package, "other", "other file"); + file->flush(); + } + expect_text(package, "data/\xe6\x96\x87\xe4\xbb\xb6.bin", "abXYe"); + expect_error(package->create_dir("other/child"), E_NOT_DIRECTORY); + expect_error(package->get_file_attribute("other/child"), E_NOT_DIRECTORY); + expect_error(package->get_file_attribute("other/"), E_NOT_DIRECTORY); + expect_error(package->open_dir("other"), E_NOT_DIRECTORY); + expect_error(package->delete_file("data"), E_DIRECTORY_NOT_EMPTY); + expect_error(package->move_file("data", "data/sub"), E_BAD_ARGUMENTS); + expect_error(package->move_file("data", "empty"), E_ALREADY_EXISTS); + lupanic_if_failed(package->copy_file("other", "copy")); + put_file(package, "other", "changed"); + expect_text(package, "copy", "other file"); + expect_error(package->open_file("copy", FileOpenFlag::read, FileCreationMode::create_always), E_ACCESS_DENIED); + expect_error(package->open_file("copy", FileOpenFlag::write, FileCreationMode::create_new), E_ALREADY_EXISTS); + expect_error(package->open_file("absent", FileOpenFlag::write, FileCreationMode::open_existing_as_new), E_NOT_FOUND); + { + auto first = must(package->open_file("copy", FileOpenFlag::read, FileCreationMode::open_existing)); + auto second = must(package->open_file("copy", FileOpenFlag::read, FileCreationMode::open_existing)); + expect_error(package->open_file("copy", FileOpenFlag::write, FileCreationMode::open_existing), E_BUSY); + expect_error(package->delete_file("copy"), E_BUSY); + expect_error(package->set_file_compression("copy", CompressionMethod::deflate), E_BUSY); + expect_error(first->write("x", 1), E_ACCESS_DENIED); + expect_error(first->set_size(0), E_ACCESS_DENIED); + lupanic_if_failed(first->seek(6, SeekMode::begin)); + luassert_always(read_all(first).size() == 4); + luassert_always(read_all(second).size() == 10); + } + lupanic_if_failed(package->set_file_compression("copy", CompressionMethod::deflate, 9)); + expect_error(package->set_file_compression("other", CompressionMethod::store, 1), E_BAD_ARGUMENTS); + expect_error(package->set_file_compression("other", (CompressionMethod)99), E_NOT_SUPPORTED); + // Iteration remains a snapshot after unrelated mutations. + luassert_always(snapshot->move_next() && !strcmp(snapshot->get_filename(), "empty")); + luassert_always(!snapshot->move_next()); + luassert_always(!snapshot->get_filename() && snapshot->get_attributes() == FileAttributeFlag::none); + auto output = data_stream("obsolete output contents"); + output->chunk_size = 7; + lupanic_if_failed(package->flush(output)); + luassert_always(package->is_open() && !package->is_dirty()); + luassert_always(must(package->get_file_compression("copy")) == CompressionMethod::deflate); + expect_error(package->flush(output), E_BAD_ARGUMENTS); + auto archive = must(Zip::open_archive(output)); + auto stored = must(archive->get_entry(must(archive->find_entry("other")))); + auto compressed = must(archive->get_entry(must(archive->find_entry("copy")))); + luassert_always(stored.compression == Zip::CompressionMethod::store); + luassert_always(compressed.compression == Zip::CompressionMethod::deflate && compressed.has_crc32); + return output; +} + +static Ref edit_test(PakTestStream* source) +{ + auto original = source->bytes; + source->bytes_written = 0; + source->read_only = true; + auto package = must(open_pak(source, OpenMode::read_write)); + luassert_always(!package->is_dirty()); + { + auto file = must(package->open_file("copy", FileOpenFlag::write, FileCreationMode::open_existing)); + luassert_always(!package->is_dirty()); + lupanic_if_failed(file->seek(6, SeekMode::begin)); + lupanic_if_failed(file->write("PAK!", 4)); + expect_error(file->read(nullptr, 0), E_ACCESS_DENIED); + } + expect_text(package, "copy", "other PAK!"); + lupanic_if_failed(package->move_file("data", "assets")); + lupanic_if_failed(package->move_file("other", "temporary")); + lupanic_if_failed(package->move_file("copy", "other")); + lupanic_if_failed(package->move_file("temporary", "copy")); + lupanic_if_failed(package->delete_file("empty")); + lupanic_if_failed(package->create_dir("empty-again")); + luassert_always(source->bytes_written == 0 && equal_bytes(source->bytes, original)); + // All failure stages preserve the source and the pending edits for retry. + for(u32 phase = 0; phase < 4; ++phase) + { + auto output = data_stream(); + output->fail_resize = phase == 0; + output->fail_write_at = phase == 1 ? 40 : U64_MAX; + output->fail_commit = phase == 2; + output->fail_read_after_write = phase == 3; + auto result = package->flush(output); + luassert_always(failed(result)); + luassert_always(package->is_open() && package->is_dirty()); + expect_text(package, "other", "other PAK!"); + expect_text(package, "assets/\xe6\x96\x87\xe4\xbb\xb6.bin", "abXYe"); + luassert_always(equal_bytes(source->bytes, original)); + } + auto output = data_stream(); + lupanic_if_failed(package->flush(output)); + luassert_always(!package->is_dirty()); + expect_text(package, "copy", "changed"); + luassert_always(must(package->get_file_compression("other")) == CompressionMethod::deflate); + expect_error(package->get_file_attribute("data"), E_NOT_FOUND); + // Edit/flush again on the same Pak object. Empty files and directories survive. + { + auto file = must(package->open_file("other", FileOpenFlag::write, FileCreationMode::open_existing_as_new)); + luassert_always(file->get_size() == 0); + } + auto second = data_stream(); + lupanic_if_failed(package->flush(second)); + luassert_always(read_file(package, "other").empty()); + auto third = data_stream(); + lupanic_if_failed(package->flush(third)); + luassert_always(equal_bytes(second->bytes, third->bytes)); + lupanic_if_failed(package->discard()); + luassert_always(!package->is_open() && !package->is_dirty()); + expect_error(package->get_file_attribute("/"), E_BAD_CALLING_TIME); + expect_error(package->flush(data_stream()), E_BAD_CALLING_TIME); + lupanic_if_failed(package->discard()); + return output; +} + +static void reading_test() +{ + auto archive = must(Zip::new_archive()); + auto data = data_stream(); + data->bytes.resize(2 * 1024 * 1024); + for(usize i = 0; i < data->bytes.size(); ++i) data->bytes[i] = (byte_t)(i % 251); + must(archive->add_file("implicit/nested/data", data, Zip::CompressionMethod::store)); + auto text = data_stream("0123456789abcdefghijklmnopqrstuvwxyz"); + must(archive->add_file("compressed", text, Zip::CompressionMethod::deflate)); + auto source = data_stream(); + lupanic_if_failed(archive->save(source)); + source->bytes_read = 0; + source->bytes_written = 0; + source->read_only = true; + auto package = must(open_pak(source)); + luassert_always(package->is_read_only()); + auto children = must(package->open_dir("implicit/nested")); + luassert_always(children->is_valid() && !strcmp(children->get_filename(), "data")); + luassert_always(source->bytes_read < 128 * 1024 && source->bytes_written == 0); + expect_error(package->create_dir("no"), E_ACCESS_DENIED); + expect_error(package->flush(data_stream()), E_ACCESS_DENIED); + expect_error(package->open_file("compressed", FileOpenFlag::write, FileCreationMode::open_existing), E_ACCESS_DENIED); + auto flags = must(package->get_file_attribute("compressed")).attributes; + luassert_always(test_flags(flags, FileAttributeFlag::read_only)); + { + auto file = must(package->open_file("compressed", FileOpenFlag::read, FileCreationMode::open_existing)); + lupanic_if_failed(file->seek(10, SeekMode::begin)); + c8 bytes[5]; + usize count = 0; + lupanic_if_failed(file->read(bytes, 5, &count)); + luassert_always(count == 5 && !memcmp(bytes, "abcde", 5)); + lupanic_if_failed(file->seek(-13, SeekMode::current)); + lupanic_if_failed(file->read(bytes, 5, &count)); + luassert_always(count == 5 && !memcmp(bytes, "23456", 5)); + lupanic_if_failed(file->seek(-3, SeekMode::end)); + luassert_always(read_all(file).size() == 3); + lupanic_if_failed(file->seek(7, SeekMode::end)); + luassert_always(read_all(file).empty()); + } + // Two real threads exercise independent cursors sharing one retained source. + auto worker = [package]() + { + auto file = must(package->open_file("implicit/nested/data", FileOpenFlag::read, FileCreationMode::open_existing)); + for(u32 i = 0; i < 12; ++i) + { + u32 position = (i * 973) % 8000; + lupanic_if_failed(file->seek(position, SeekMode::begin)); + byte_t bytes[127]; + usize count = 0; + lupanic_if_failed(file->read(bytes, sizeof(bytes), &count)); + luassert_always(count == sizeof(bytes)); + for(usize j = 0; j < count; ++j) luassert_always(bytes[j] == (byte_t)((position + j) % 251)); + } + }; + std::thread first(worker); + std::thread second(worker); + first.join(); + second.join(); + auto retained = must(package->open_file("compressed", FileOpenFlag::read, FileCreationMode::open_existing)); + package = nullptr; + luassert_always(equal_bytes(read_all(retained), text->bytes)); +} + +static Ref g_staging; +static bool g_fail_factory = false; +static Ref g_alias; +static R> create_staging(object_t userdata) +{ + luassert_always(userdata); + if(g_fail_factory) return E_IO_ERROR; + if(g_alias) return g_alias; + g_staging = data_stream("factory data must be reset"); + g_staging->chunk_size = 3; + return Ref(g_staging); +} + +static void staging_test(PakTestStream* source) +{ + Options options; + options.max_memory_file_size = 3; + auto limited = must(open_pak(source, OpenMode::read_write, options)); + expect_text(limited, "other", "other PAK!"); + expect_error(limited->open_file("other", FileOpenFlag::write, FileCreationMode::open_existing), E_OUT_OF_RANGE); + luassert_always(!limited->is_dirty()); + { + auto file = must(limited->open_file("other", FileOpenFlag::read | FileOpenFlag::write, FileCreationMode::create_always)); + lupanic_if_failed(file->write("abc", 3)); + expect_error(file->write("x", 1), E_OUT_OF_RANGE); + expect_error(file->set_size(4), E_OUT_OF_RANGE); + luassert_always(file->get_size() == 3 && must(file->tell()) == 3); + } + options.create_staging_stream = create_staging; + options.staging_userdata = data_stream(); + auto package = must(open_pak(source, OpenMode::read_write, options)); + g_fail_factory = true; + expect_error(package->open_file("other", FileOpenFlag::write, FileCreationMode::open_existing), E_IO_ERROR); + luassert_always(!package->is_dirty()); + g_fail_factory = false; + g_alias = source; + expect_error(package->open_file("other", FileOpenFlag::write, FileCreationMode::open_existing), E_BAD_ARGUMENTS); + g_alias = nullptr; + expect_text(package, "other", "other PAK!"); + { + auto file = must(package->open_file("other", FileOpenFlag::read | FileOpenFlag::write, FileCreationMode::open_existing)); + luassert_always(file->get_size() == 10 && !package->is_dirty()); + lupanic_if_failed(file->seek(14, SeekMode::begin)); + lupanic_if_failed(file->write("custom", 6)); + lupanic_if_failed(file->set_size(23)); + lupanic_if_failed(file->seek(10, SeekMode::begin)); + auto bytes = read_all(file); + luassert_always(bytes.size() == 13); + for(usize i = 0; i < 4; ++i) luassert_always(bytes[i] == 0); + luassert_always(!memcmp(bytes.data() + 4, "custom", 6)); + for(usize i = 10; i < 13; ++i) luassert_always(bytes[i] == 0); + g_staging->fail_write_at = 24; + lupanic_if_failed(file->seek(23, SeekMode::begin)); + usize count = 0; + expect_error(file->write("failure", 7, &count), E_IO_ERROR); + luassert_always(count == 1 && file->get_size() == 24 && must(file->tell()) == 24); + g_staging->fail_write_at = U64_MAX; + } + expect_error(package->flush(g_staging), E_BAD_ARGUMENTS); + g_alias = g_staging; + expect_error(package->copy_file("other", "alias-copy"), E_BAD_ARGUMENTS); + g_alias = nullptr; + auto saved = data_stream(); + lupanic_if_failed(package->flush(saved)); + luassert_always(read_file(package, "other").size() == 24); + g_staging = nullptr; +} + +static Ref make_zip(const c8* first, const c8* second = nullptr) +{ + auto archive = must(Zip::new_archive()); + auto data = data_stream("fixture bytes"); + must(archive->add_file(first, data, Zip::CompressionMethod::store)); + if(second) must(archive->add_file(second, data, Zip::CompressionMethod::store)); + auto result = data_stream(); + lupanic_if_failed(archive->save(result)); + return result; +} + +static void rewrite_name(PakTestStream* source, const c8* from, const c8* to) +{ + usize size = strlen(from); + luassert_always(size == strlen(to)); + usize replacements = 0; + for(usize i = 0; i + size <= source->bytes.size(); ++i) + { + if(!memcmp(source->bytes.data() + i, from, size)) + { + memcpy(source->bytes.data() + i, to, size); + ++replacements; + } + } + luassert_always(replacements == 2); +} + +static void validation_test() +{ + expect_error(open_pak(nullptr), E_BAD_ARGUMENTS); + Options invalid; + invalid.compression = (CompressionMethod)99; + expect_error(new_pak(invalid), E_NOT_SUPPORTED); + invalid.compression = CompressionMethod::store; + invalid.compression_level = 1; + expect_error(new_pak(invalid), E_BAD_ARGUMENTS); + auto valid = make_zip("data"); + expect_error(open_pak(valid, (OpenMode)99), E_BAD_ARGUMENTS); + luassert_always(failed(open_pak(data_stream("not a zip")))); + const c8* paths[] = {"../escape", "/absolute", "a//b", "a/./b", "a\\b", "C:/drive"}; + for(const auto path : paths) expect_error(open_pak(make_zip(path)), E_BAD_DATA); + expect_error(open_pak(make_zip("file", "file/child")), E_BAD_DATA); + expect_error(open_pak(make_zip("file/child", "file")), E_BAD_DATA); + auto duplicate = make_zip("dupA", "dupB"); + rewrite_name(duplicate, "dupA", "dupB"); + expect_error(open_pak(duplicate), E_BAD_DATA); + auto directory_data = make_zip("dirX"); + rewrite_name(directory_data, "dirX", "dir/"); + expect_error(open_pak(directory_data), E_BAD_DATA); + // Mutate structural flags only, so unsupported formats can be rejected without decoding. + for(bool encrypted : {false, true}) + { + auto unsupported = make_zip("data"); + for(usize i = 0; i + 46 <= unsupported->bytes.size(); ++i) + { + auto p = unsupported->bytes.data() + i; + if(!memcmp(p, "PK\x03\x04", 4)) + { + if(encrypted) p[6] |= 1; + else p[8] = 12; + } + if(!memcmp(p, "PK\x01\x02", 4)) + { + if(encrypted) p[8] |= 1; + else p[10] = 12; + } + } + expect_error(open_pak(unsupported), E_NOT_SUPPORTED); + } + auto corrupt = make_zip("crc"); + const c8 payload[] = "fixture bytes"; + bool corrupted = false; + for(usize i = 0; i + sizeof(payload) - 1 <= corrupt->bytes.size(); ++i) + { + if(!memcmp(corrupt->bytes.data() + i, payload, sizeof(payload) - 1)) + { + corrupt->bytes[i] ^= 1; + corrupted = true; + break; + } + } + luassert_always(corrupted); + auto package = must(open_pak(corrupt, OpenMode::read_write)); + { + auto file = must(package->open_file("crc", FileOpenFlag::read, FileCreationMode::open_existing)); + byte_t bytes[128]; + expect_error(file->read(bytes, sizeof(bytes)), E_BAD_DATA); + } + expect_error(package->open_file("crc", FileOpenFlag::write, FileCreationMode::open_existing), E_BAD_DATA); + luassert_always(!package->is_dirty()); +} + +static void empty_and_compression_test() +{ + Options options; + options.compression = CompressionMethod::deflate; + options.compression_level = 6; + auto package = must(new_pak(options)); + auto empty = data_stream(); + lupanic_if_failed(package->flush(empty)); + luassert_always(empty->bytes.size() == 22); + put_file(package, "compressed", "repeated repeated repeated repeated repeated"); + put_file(package, "zero", ""); + luassert_always(must(package->get_file_compression("compressed")) == CompressionMethod::deflate); + auto saved = data_stream(); + lupanic_if_failed(package->flush(saved)); + auto zip = must(Zip::open_archive(saved)); + auto info = must(zip->get_entry(must(zip->find_entry("compressed")))); + luassert_always(info.compression == Zip::CompressionMethod::deflate && info.compressed_size < info.size); + luassert_always(read_file(package, "zero").empty()); + lupanic_if_failed(package->delete_file("compressed")); + lupanic_if_failed(package->delete_file("zero")); + auto cleared = data_stream(); + lupanic_if_failed(package->flush(cleared)); + luassert_always(cleared->bytes.size() == 22); +} + +static void import_test(const c8* path) +{ + auto input = must(Luna::open_file(path, FileOpenFlag::read, FileCreationMode::open_existing)); + auto package = must(open_pak(input, OpenMode::read_write)); + expect_text(package, "python/stored.txt", "stored by Python"); + expect_text(package, "python/\xe6\x96\x87\xe4\xbb\xb6.txt", "compressed by Python"); + expect_text(package, "small-zip64", "ZIP64 local entry"); + lupanic_if_failed(package->move_file("python", "imported")); + put_file(package, "imported/added.txt", "written by Pak"); + String output_path(path); + output_path.append(".edited.pak"); + auto output = must(Luna::open_file(output_path.c_str(), FileOpenFlag::read | FileOpenFlag::write | FileOpenFlag::user_buffering, + FileCreationMode::create_always)); + lupanic_if_failed(package->flush(output)); + expect_text(package, "imported/added.txt", "written by Pak"); + expect_text(package, "imported/\xe6\x96\x87\xe4\xbb\xb6.txt", "compressed by Python"); +} + +int main(int argc, char** argv) +{ + lupanic_if_failed(Luna::init()); + lupanic_if_failed(add_modules({module_pak()})); + lupanic_if_failed(init_modules()); + Meta::register_PakTest_types(); + { + auto first = create_test(); + auto second = edit_test(first); + reading_test(); + staging_test(second); + validation_test(); + empty_and_compression_test(); + if(argc > 2) import_test(argv[2]); + if(argc > 1) + { + auto output = must(Luna::open_file(argv[1], FileOpenFlag::write, FileCreationMode::create_always)); + lupanic_if_failed(output->write(second->bytes.data(), second->bytes.size())); + } + } + Luna::close(); + printf("PakTest passed.\n"); + return 0; +} diff --git a/Tests/PakTest/Source/TestStream.hpp b/Tests/PakTest/Source/TestStream.hpp new file mode 100644 index 00000000..9fc3a691 --- /dev/null +++ b/Tests/PakTest/Source/TestStream.hpp @@ -0,0 +1,84 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file TestStream.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include +#include +#include "TestStream.generated.hpp" + +namespace Luna +{ + struct [[Luna::struct("{7C3B57F5-8307-417F-B200-5826A99F2EFE}")]] PakTestStream : ISeekableStream + { + luiimpl(); + Vector bytes; + u64 position = 0; + usize chunk_size = USIZE_MAX; + u64 fail_write_at = U64_MAX; + bool fail_resize = false; + bool fail_commit = false; + bool fail_read_after_write = false; + bool read_only = false; + u64 bytes_read = 0; + u64 bytes_written = 0; + + RV read(void* buffer, usize size, usize* count) override + { + if(count) *count = 0; + if(fail_read_after_write && bytes_written) return E_IO_ERROR; + usize amount = position >= bytes.size() ? 0 : (usize)min(size, bytes.size() - position); + amount = min(amount, chunk_size); + if(amount) memcpy(buffer, bytes.data() + (usize)position, amount); + position += amount; + bytes_read += amount; + if(count) *count = amount; + return ok; + } + RV write(const void* buffer, usize size, usize* count) override + { + if(count) *count = 0; + if(read_only) return E_ACCESS_DENIED; + if(position >= fail_write_at) return E_IO_ERROR; + usize amount = (usize)min(min(size, fail_write_at - position), chunk_size); + if(position > USIZE_MAX - amount) return E_FILE_TOO_BIG; + if(position + amount > bytes.size()) + { + usize old_size = bytes.size(); + if(position + amount > bytes.capacity()) bytes.reserve(max((usize)position + amount, bytes.capacity() * 2)); + bytes.resize((usize)position + amount); + memset(bytes.data() + old_size, 0xcc, bytes.size() - old_size); + } + if(amount) memcpy(bytes.data() + (usize)position, buffer, amount); + position += amount; + bytes_written += amount; + if(count) *count = amount; + return ok; + } + R tell() override { return position; } + RV seek(i64 offset, SeekMode mode) override + { + if(mode != SeekMode::begin && mode != SeekMode::current && mode != SeekMode::end) return E_BAD_ARGUMENTS; + i64 base = mode == SeekMode::begin ? 0 : (mode == SeekMode::current ? (i64)position : (i64)bytes.size()); + if(offset < -base || (offset > 0 && base > I64_MAX - offset)) return E_OUT_OF_RANGE; + position = (u64)(base + offset); + return ok; + } + u64 get_size() override { return bytes.size(); } + RV set_size(u64 size) override + { + if(read_only) return E_ACCESS_DENIED; + if(fail_resize || (fail_commit && size)) return E_IO_ERROR; + if(size > USIZE_MAX) return E_FILE_TOO_BIG; + usize old_size = bytes.size(); + bytes.resize((usize)size); + if(size > old_size) memset(bytes.data() + old_size, 0xcc, (usize)size - old_size); + return ok; + } + }; +} diff --git a/Tests/RuntimeTest/Source/FileTest.cpp b/Tests/RuntimeTest/Source/FileTest.cpp index 35e0c976..22b1c9fa 100644 --- a/Tests/RuntimeTest/Source/FileTest.cpp +++ b/Tests/RuntimeTest/Source/FileTest.cpp @@ -9,13 +9,147 @@ */ #include "TestCommon.hpp" #include +#include +#include +#include +#ifndef LUNA_PLATFORM_WINDOWS +#include +#endif namespace Luna { + static void write_test_file(const c8* path, const c8* text) + { + auto file = open_file(path, FileOpenFlag::write, FileCreationMode::create_new); + lupanic_if_failed(file); + lupanic_if_failed(file.get()->write(text, strlen(text))); + } + + static void expect_file_text(const c8* path, const c8* text) + { + auto file = open_file(path, FileOpenFlag::read, FileCreationMode::open_existing); + lupanic_if_failed(file); + auto data = load_file_data(file.get()); + lupanic_if_failed(data); + lutest(data.get().size() == strlen(text)); + lutest(!memcmp(data.get().data(), text, data.get().size())); + } + + static void expect_file_error(RV result, ResultCode code) + { + lutest(failed(result)); + lutest(unwrap_errcode(result.errcode()) == code); + } + + static void expect_missing_file(const c8* path) + { + auto result = get_file_attribute(path); + lutest(failed(result)); + lutest(unwrap_errcode(result.errcode()) == E_NOT_FOUND); + } + + static void file_move_test() + { + c8 guid[GUID_STRING_LENGTH + 1] = {}; + lupanic_if_failed(encode_guid(random_guid(), guid, GUID_STRING_LENGTH)); + String root("RuntimeFileMoveTest-"); + root.append(guid); + lupanic_if_failed(create_dir(root.c_str())); + String source(root); + source.append("/source"); + String destination(root); + destination.append("/destination"); + const FileMoveFlag options[] = { + FileMoveFlag::none, + FileMoveFlag::no_copy, + FileMoveFlag::allow_overwrite, + FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy + }; + for(auto flags : options) + { + write_test_file(source.c_str(), "new contents"); + write_test_file(destination.c_str(), "original contents"); + if(test_flags(flags, FileMoveFlag::allow_overwrite)) + { + lupanic_if_failed(move_file(source.c_str(), destination.c_str(), flags)); + expect_file_text(destination.c_str(), "new contents"); + expect_missing_file(source.c_str()); + expect_file_error(move_file(source.c_str(), destination.c_str(), flags), E_NOT_FOUND); + expect_file_text(destination.c_str(), "new contents"); + // Also allow replacement mode when the destination does not exist yet. + lupanic_if_failed(move_file(destination.c_str(), source.c_str(), flags)); + } + else + { + auto result = flags == FileMoveFlag::none ? move_file(source.c_str(), destination.c_str()) : + move_file(source.c_str(), destination.c_str(), flags); + expect_file_error(result, E_ALREADY_EXISTS); + expect_file_text(source.c_str(), "new contents"); + expect_file_text(destination.c_str(), "original contents"); + lupanic_if_failed(delete_file(destination.c_str())); + } + // Moving into an absent destination works with every option combination. + lupanic_if_failed(move_file(source.c_str(), destination.c_str(), flags)); + expect_missing_file(source.c_str()); + expect_file_text(destination.c_str(), "new contents"); + lupanic_if_failed(delete_file(destination.c_str())); + + // Overwrite permission never allows replacing directories, but does allow renaming them. + lupanic_if_failed(create_dir(source.c_str())); + lupanic_if_failed(create_dir(destination.c_str())); + String child(source); + child.append("/child"); + write_test_file(child.c_str(), "child contents"); + ResultCode directory_error = test_flags(flags, FileMoveFlag::allow_overwrite) ? E_IS_DIRECTORY : E_ALREADY_EXISTS; + expect_file_error(move_file(source.c_str(), destination.c_str(), flags), directory_error); + expect_file_error(move_file(child.c_str(), destination.c_str(), flags), directory_error); + expect_file_text(child.c_str(), "child contents"); + lupanic_if_failed(delete_file(destination.c_str())); + write_test_file(destination.c_str(), "original contents"); + expect_file_error(move_file(source.c_str(), destination.c_str(), flags), directory_error); + expect_file_text(destination.c_str(), "original contents"); + lupanic_if_failed(delete_file(destination.c_str())); + lupanic_if_failed(move_file(source.c_str(), destination.c_str(), flags)); + expect_missing_file(source.c_str()); + child = destination; + child.append("/child"); + expect_file_text(child.c_str(), "child contents"); + lupanic_if_failed(delete_file(child.c_str())); + lupanic_if_failed(delete_file(destination.c_str())); + } + + write_test_file(source.c_str(), "preserved contents"); + expect_file_error(move_file(nullptr, destination.c_str()), E_BAD_ARGUMENTS); + expect_file_error(move_file(source.c_str(), ""), E_BAD_ARGUMENTS); + expect_file_error(move_file(source.c_str(), destination.c_str(), (FileMoveFlag)0x04), E_BAD_ARGUMENTS); + expect_file_error(move_file(source.c_str(), source.c_str(), FileMoveFlag::allow_overwrite), E_BAD_ARGUMENTS); + expect_file_text(source.c_str(), "preserved contents"); +#ifndef LUNA_PLATFORM_WINDOWS + // A dangling symbolic link is still an existing destination. + lutest(::symlink("missing", destination.c_str()) == 0); + expect_file_error(move_file(source.c_str(), destination.c_str()), E_ALREADY_EXISTS); + expect_file_text(source.c_str(), "preserved contents"); + lupanic_if_failed(move_file(source.c_str(), destination.c_str(), FileMoveFlag::allow_overwrite | FileMoveFlag::no_copy)); + expect_missing_file(source.c_str()); + expect_file_text(destination.c_str(), "preserved contents"); + lupanic_if_failed(delete_file(destination.c_str())); +#else + lupanic_if_failed(delete_file(source.c_str())); +#endif + lupanic_if_failed(delete_file(root.c_str())); + } + void file_test() { + file_move_test(); const char s[] = "Sample String"; + { + auto attribute = get_file_attribute("MissingFileAttributeTest.file"); + lutest(failed(attribute)); + lutest(unwrap_errcode(attribute.errcode()) == E_NOT_FOUND); + } + { // Try to open one file from vfs and writes to it. auto file = open_file("SampleFile.txt", @@ -38,4 +172,4 @@ namespace Luna lutest(succeeded(delete_file("SampleFile.txt"))); } } -} \ No newline at end of file +} diff --git a/Tests/RuntimeTest/Source/GuidTest.cpp b/Tests/RuntimeTest/Source/GuidTest.cpp new file mode 100644 index 00000000..bb2e0154 --- /dev/null +++ b/Tests/RuntimeTest/Source/GuidTest.cpp @@ -0,0 +1,67 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file GuidTest.cpp +* @author JXMaster +* @date 2026/8/25 +*/ +#include "TestCommon.hpp" +#include + +namespace Luna +{ + namespace + { + constexpr const c8* TEST_GUID_STRING = "123e4567-e89b-12d3-a456-426614174000"; + constexpr Guid TEST_GUID(TEST_GUID_STRING); + + constexpr bool guid_constexpr_test() + { + c8 buffer[GUID_STRING_LENGTH]{}; + if(!encode_guid(TEST_GUID, buffer, sizeof(buffer)).valid()) return false; + for(usize i = 0; i < GUID_STRING_LENGTH; ++i) + { + if(buffer[i] != TEST_GUID_STRING[i]) return false; + } + Guid decoded; + if(!decode_guid(buffer, sizeof(buffer), decoded).valid()) return false; + return decoded == TEST_GUID; + } + + static_assert(guid_constexpr_test()); + } + + void guid_test() + { + c8 buffer[GUID_STRING_LENGTH + 1]; + for(usize i = 0; i < sizeof(buffer); ++i) buffer[i] = '?'; + luassert_always(succeeded(encode_guid(TEST_GUID, buffer, sizeof(buffer)))); + luassert_always(buffer[GUID_STRING_LENGTH] == '?'); + for(usize i = 0; i < GUID_STRING_LENGTH; ++i) + { + luassert_always(buffer[i] == TEST_GUID_STRING[i]); + } + + luassert_always(encode_guid(TEST_GUID, nullptr, GUID_STRING_LENGTH).errcode() == E_BAD_ARGUMENTS); + luassert_always(encode_guid(TEST_GUID, buffer, GUID_STRING_LENGTH - 1).errcode() == + E_INSUFFICIENT_USER_BUFFER); + + Guid decoded; + luassert_always(succeeded(decode_guid(buffer, GUID_STRING_LENGTH, decoded))); + luassert_always(decoded == TEST_GUID); + constexpr const c8* braced = "{123E4567-E89B-12D3-A456-426614174000}"; + luassert_always(succeeded(decode_guid(braced, GUID_STRING_LENGTH + 2, decoded))); + luassert_always(decoded == TEST_GUID); + + Guid unchanged(1, 2); + c8 invalid[GUID_STRING_LENGTH]; + for(usize i = 0; i < GUID_STRING_LENGTH; ++i) invalid[i] = TEST_GUID_STRING[i]; + invalid[0] = 'x'; + luassert_always(decode_guid(invalid, sizeof(invalid), unchanged).errcode() == E_BAD_DATA); + luassert_always(unchanged == Guid(1, 2)); + luassert_always(decode_guid(TEST_GUID_STRING, GUID_STRING_LENGTH - 1, unchanged).errcode() == E_BAD_DATA); + luassert_always(decode_guid(nullptr, GUID_STRING_LENGTH, unchanged).errcode() == E_BAD_ARGUMENTS); + } +} diff --git a/Tests/RuntimeTest/Source/TestCommon.hpp b/Tests/RuntimeTest/Source/TestCommon.hpp index 44898898..d1e5ed65 100644 --- a/Tests/RuntimeTest/Source/TestCommon.hpp +++ b/Tests/RuntimeTest/Source/TestCommon.hpp @@ -19,6 +19,7 @@ namespace Luna void open_hash_test(); void robin_hood_hash_test(); void hash_test(); + void guid_test(); void name_test(); void ring_deque_test(); void string_test(); diff --git a/Tests/RuntimeTest/Source/TestMain.cpp b/Tests/RuntimeTest/Source/TestMain.cpp index c5e70ab2..2963f6e7 100644 --- a/Tests/RuntimeTest/Source/TestMain.cpp +++ b/Tests/RuntimeTest/Source/TestMain.cpp @@ -24,6 +24,7 @@ void run() list_test(); robin_hood_hash_test(); hash_test(); + guid_test(); tuple_test(); name_test(); path_test(); diff --git a/Tests/VFSPakTest/Source/Main.cpp b/Tests/VFSPakTest/Source/Main.cpp new file mode 100644 index 00000000..4dedf8ef --- /dev/null +++ b/Tests/VFSPakTest/Source/Main.cpp @@ -0,0 +1,634 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Main.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include "TestStorage.hpp" +#include "VFSPakTest.meta.generated.hpp" +#include +#include +#include +#include +#include +#include +#include + +using namespace Luna; + +template +static _Ty must(R<_Ty> result) +{ + lupanic_if_failed(result); + return move(result.get()); +} + +template +static void expect_error(R<_Ty> result, ResultCode code) +{ + luassert_always(failed(result)); + if(unwrap_errcode(result.errcode()) != code) + { + printf("Unexpected error: %s\n", explain(result.errcode())); + luassert_always(false); + } +} + +static bool equal_bytes(const Vector& left, const Vector& right) +{ + return left.size() == right.size() && (left.empty() || !memcmp(left.data(), right.data(), left.size())); +} + +static Vector read_all(IStream* stream) +{ + Vector result; + byte_t buffer[4096]; + for(;;) + { + usize count = 0; + lupanic_if_failed(stream->read(buffer, sizeof(buffer), &count)); + if(!count) break; + result.insert(result.end(), buffer, buffer + count); + } + return result; +} + +static void expect_text(IStream* stream, const c8* text) +{ + auto bytes = read_all(stream); + luassert_always(bytes.size() == strlen(text) && (bytes.empty() || !memcmp(bytes.data(), text, bytes.size()))); +} + +static void expect_vfs_text(const c8* path, const c8* text) +{ + auto file = must(VFS::open_file(path, FileOpenFlag::read, FileCreationMode::open_existing)); + expect_text(file, text); +} + +static void put_vfs(const c8* path, const c8* text) +{ + auto file = must(VFS::open_file(path, FileOpenFlag::write, FileCreationMode::create_always)); + lupanic_if_failed(file->write(text, strlen(text))); +} + +static Ref make_storage(const c8* text = "original contents") +{ + auto storage = new_object(); + storage->published = new_object(); + auto package = must(Pak::new_pak()); + lupanic_if_failed(package->create_dir("data")); + { + auto file = must(package->open_file("data/seed", FileOpenFlag::write, FileCreationMode::create_new)); + lupanic_if_failed(file->write(text, strlen(text))); + } + lupanic_if_failed(package->flush(storage->published)); + return storage; +} + +static Ref mount_storage(VfsTestStorage* storage, const c8* path, + Pak::OpenMode mode = Pak::OpenMode::read_write, const Pak::Options& options = Pak::Options()) +{ + auto instance = must(VFS::new_pak_file_system(storage, mode, options)); + lupanic_if_failed(VFS::mount(instance, path)); + return instance; +} + +static void expect_published_text(VfsTestStorage* storage, const c8* path, const c8* text) +{ + auto package = must(Pak::open_pak(storage->published)); + auto file = must(package->open_file(path, FileOpenFlag::read, FileCreationMode::open_existing)); + expect_text(file, text); +} + +static String child_path(const c8* parent, const c8* child) +{ + Path path(parent); + path.append(Path(child)); + return path.encode(); +} + +static void create_native_package(const c8* path) +{ + auto storage = make_storage(); + auto file = must(Luna::open_file(path, FileOpenFlag::write, FileCreationMode::create_new)); + lupanic_if_failed(file->write(storage->published->bytes.data(), storage->published->bytes.size())); +} + +static Vector read_native(const c8* path) +{ + auto file = must(Luna::open_file(path, FileOpenFlag::read, FileCreationMode::open_existing)); + return read_all(file); +} + +static Vector temporary_outputs(const c8* directory) +{ + Vector result; + auto iter = must(Luna::open_dir(directory)); + for(; iter->is_valid(); iter->move_next()) + { + if(!strncmp(iter->get_filename(), ".luna-pak-", 10)) result.push_back(child_path(directory, iter->get_filename())); + } + return result; +} + +static void native_mount_test(const c8* directory) +{ + auto path = child_path(directory, "native.pak"); + create_native_package(path.c_str()); + auto original = read_native(path.c_str()); + { + auto native = must(VFS::new_native_file_system(directory)); + luassert_always(test_flags(must(native->get_file_attribute(Path())).attributes, FileAttributeFlag::directory)); + expect_error(native->get_file_attribute("/native.pak"), E_BAD_ARGUMENTS); + expect_error(native->get_file_attribute("../escape"), E_BAD_ARGUMENTS); + auto direct = must(native->open_file("native.pak", FileOpenFlag::read, FileCreationMode::open_existing)); + native = nullptr; // A Runtime native file independently owns its open handle. + luassert_always(equal_bytes(original, read_all(direct))); + } + auto read_only = must(VFS::new_pak_file_system(path.c_str())); + lupanic_if_failed(VFS::mount(read_only, "/Read")); + expect_vfs_text("/Read/data/seed", "original contents"); + luassert_always(test_flags(must(VFS::get_file_attribute("/Read")).attributes, FileAttributeFlag::directory)); + expect_error(VFS::create_dir("/Read/no"), E_ACCESS_DENIED); + expect_error(VFS::delete_file("/Read/data/seed"), E_ACCESS_DENIED); + expect_error(VFS::copy_file("/Read/data/seed", "/Read/copy"), E_ACCESS_DENIED); + expect_error(VFS::move_file("/Read/data", "/Read/moved"), E_ACCESS_DENIED); + expect_error(VFS::open_file("/Read/new", FileOpenFlag::write, FileCreationMode::create_new), E_ACCESS_DENIED); + expect_error(VFS::get_native_path("/Read/data/seed"), E_NOT_SUPPORTED); + lupanic_if_failed(read_only->flush()); + lupanic_if_failed(VFS::unmount("/Read")); + read_only = nullptr; + luassert_always(equal_bytes(original, read_native(path.c_str()))); + + Pak::Options options; + options.compression = Pak::CompressionMethod::deflate; + options.compression_level = 6; + auto file_system = must(VFS::new_pak_file_system(path.c_str(), Pak::OpenMode::read_write, options)); + lupanic_if_failed(VFS::mount(file_system, "/Edit")); + lupanic_if_failed(VFS::create_dir("/Edit/empty")); + { + auto file = must(VFS::open_file("/Edit/data/new", FileOpenFlag::read | FileOpenFlag::write, FileCreationMode::create_new)); + lupanic_if_failed(file->write("abcdefgh", 8)); + lupanic_if_failed(file->seek(2, SeekMode::begin)); + lupanic_if_failed(file->write("XY", 2)); + lupanic_if_failed(file->set_size(6)); + luassert_always(must(file->tell()) == 4 && file->get_size() == 6); + lupanic_if_failed(file->seek(0, SeekMode::begin)); + expect_text(file, "abXYef"); + file->flush(); + expect_error(file_system->flush(), E_BUSY); + expect_error(VFS::unmount("/Edit"), E_BUSY); + luassert_always(equal_bytes(original, read_native(path.c_str()))); + } + lupanic_if_failed(VFS::copy_file("/Edit/data/new", "/Edit/copy")); + lupanic_if_failed(VFS::move_file("/Edit/data", "/Edit/renamed")); + expect_error(VFS::delete_file("/Edit/renamed"), E_DIRECTORY_NOT_EMPTY); + lupanic_if_failed(VFS::delete_file("/Edit/empty")); + auto iter = must(VFS::open_dir("/Edit")); + luassert_always(iter->is_valid() && !strcmp(iter->get_filename(), "copy")); + expect_error(VFS::unmount("/Edit"), E_BUSY); + // Factory-time resolution must survive changes to the process current directory. + const c8* previous_directory = get_current_dir(); + lupanic_if_failed(set_current_dir(directory)); + lupanic_if_failed(file_system->flush()); + lupanic_if_failed(set_current_dir(previous_directory)); + release_current_dir(previous_directory); + luassert_always(!equal_bytes(original, read_native(path.c_str()))); + luassert_always(temporary_outputs(directory).empty()); + luassert_always(iter->move_next() && !strcmp(iter->get_filename(), "renamed")); + iter = nullptr; + expect_vfs_text("/Edit/renamed/new", "abXYef"); + put_vfs("/Edit/renamed/new", "second edit"); + lupanic_if_failed(VFS::unmount("/Edit")); + { + auto input = must(Luna::open_file(path.c_str(), FileOpenFlag::read, FileCreationMode::open_existing)); + auto package = must(Pak::open_pak(input)); + auto file = must(package->open_file("renamed/new", FileOpenFlag::read, FileCreationMode::open_existing)); + expect_text(file, "second edit"); + luassert_always(must(package->get_file_compression("renamed/new")) == Pak::CompressionMethod::deflate); + luassert_always(must(package->get_file_compression("copy")) == Pak::CompressionMethod::deflate); + luassert_always(must(package->get_file_compression("renamed/seed")) == Pak::CompressionMethod::store); + } + expect_error(VFS::mount(nullptr, "/Invalid"), E_BAD_ARGUMENTS); + expect_error(VFS::new_pak_file_system((const c8*)nullptr), E_BAD_ARGUMENTS); + expect_error(VFS::new_pak_file_system((VFS::IPakStorage*)nullptr), E_BAD_ARGUMENTS); + expect_error(VFS::new_pak_file_system(directory), E_IS_DIRECTORY); + expect_error(VFS::new_pak_file_system(child_path(directory, "missing").c_str()), E_NOT_FOUND); + expect_error(VFS::new_pak_file_system(path.c_str(), (Pak::OpenMode)99), E_BAD_ARGUMENTS); + expect_error(VFS::new_native_file_system(path.c_str()), E_NOT_DIRECTORY); + expect_error(VFS::new_native_file_system(nullptr), E_BAD_ARGUMENTS); + file_system = nullptr; + lupanic_if_failed(Luna::delete_file(path.c_str())); + printf("Native mounting, deferred saves and handle lifetimes passed.\n"); +} + +static void storage_failure_test() +{ + auto storage = make_storage(); + auto original = storage->published->bytes; + auto file_system = mount_storage(storage, "/Memory"); + put_vfs("/Memory/data/seed", "pending edit"); + const OutputFault faults[] = {OutputFault::create, OutputFault::null_stream, OutputFault::alias_source, + OutputFault::write, OutputFault::resize, OutputFault::reopen}; + const ResultCode errors[] = {E_ACCESS_DENIED, E_BAD_DATA, E_BAD_ARGUMENTS, E_IO_ERROR, E_IO_ERROR, E_IO_ERROR}; + for(usize i = 0; i < sizeof(faults) / sizeof(faults[0]); ++i) + { + storage->fault = faults[i]; + expect_error(file_system->flush(), errors[i]); + luassert_always(equal_bytes(original, storage->published->bytes) && !storage->publish_count); + expect_vfs_text("/Memory/data/seed", "pending edit"); + put_vfs("/Memory/still-editable", "staged"); + } + storage->fault = OutputFault::none; + storage->fail_publish = true; + expect_error(file_system->flush(), E_IO_ERROR); + luassert_always(strstr(get_error().message.c_str(), storage->failure_message)); + usize outputs = storage->output_count; + auto completed = storage->last_output; + auto completed_bytes = completed->bytes; + luassert_always(equal_bytes(original, storage->published->bytes)); + expect_vfs_text("/Memory/data/seed", "pending edit"); + expect_error(VFS::create_dir("/Memory/blocked"), E_BUSY); + expect_error(VFS::delete_file("/Memory/data/seed"), E_BUSY); + expect_error(VFS::copy_file("/Memory/data/seed", "/Memory/copy"), E_BUSY); + expect_error(VFS::move_file("/Memory/data", "/Memory/moved"), E_BUSY); + expect_error(VFS::open_file("/Memory/new", FileOpenFlag::write, FileCreationMode::create_new), E_BUSY); + { + auto reader = must(VFS::open_file("/Memory/data/seed", FileOpenFlag::read, FileCreationMode::open_existing)); + expect_error(file_system->flush(), E_BUSY); + } + expect_error(VFS::unmount("/Memory"), E_IO_ERROR); + luassert_always(storage->output_count == outputs && storage->last_output == completed); + luassert_always(equal_bytes(completed_bytes, completed->bytes)); + storage->fail_publish = false; + lupanic_if_failed(file_system->flush()); + luassert_always(storage->output_count == outputs && storage->published == completed); + expect_published_text(storage, "data/seed", "pending edit"); + lupanic_if_failed(file_system->flush()); + luassert_always(storage->output_count == outputs); + put_vfs("/Memory/data/seed", "after retry"); + lupanic_if_failed(VFS::unmount("/Memory")); + expect_published_text(storage, "data/seed", "after retry"); + file_system = nullptr; + file_system = mount_storage(storage, "/Memory", Pak::OpenMode::read); + outputs = storage->output_count; + lupanic_if_failed(VFS::unmount("/Memory")); + luassert_always(storage->output_count == outputs); + printf("Output failures and publication retry passed.\n"); +} + +static void routing_and_flush_all_test() +{ + auto first = make_storage("nested"); + auto second = make_storage("parent"); + auto third = make_storage("third"); + mount_storage(first, "/Root/Child"); + mount_storage(second, "/Root"); + mount_storage(third, "Root"); + expect_vfs_text("/Root/Child/data/seed", "nested"); + expect_vfs_text("/Root/data/seed", "parent"); + expect_vfs_text("Root/data/seed", "third"); + expect_error(VFS::get_file_attribute("OtherRoot:/Root/data/seed"), E_NOT_FOUND); + expect_error(VFS::remount("/Root", "/Root/Child"), E_ALREADY_EXISTS); + auto duplicate = must(VFS::new_pak_file_system(first)); + expect_error(VFS::mount(duplicate, "/Root"), E_ALREADY_EXISTS); + Path parents("../../escape"); + luassert_always(parents.size() == 3 && !strcmp(parents[0].c_str(), "..")); + expect_error(VFS::get_file_attribute(parents), E_BAD_ARGUMENTS); + Path malformed("/Root"); + malformed.push_back(Name("a/b")); + expect_error(VFS::get_file_attribute(malformed), E_BAD_ARGUMENTS); + lupanic_if_failed(VFS::remount("Root", "/Third")); + expect_error(VFS::get_file_attribute("Root/data/seed"), E_NOT_FOUND); + put_vfs("/Root/Child/new", "first edit"); + put_vfs("/Root/new", "second edit"); + put_vfs("/Third/new", "third edit"); + first->fail_publish = true; + second->fail_publish = true; + first->failure_message = "first mount failed"; + second->failure_message = "second mount failed"; + expect_error(VFS::flush_all(), E_IO_ERROR); + luassert_always(!strcmp(get_error().message.c_str(), "first mount failed")); + luassert_always(first->publish_count == 1 && second->publish_count == 1 && third->publish_count == 1); + expect_published_text(third, "new", "third edit"); + first->fail_publish = false; + second->fail_publish = false; + lupanic_if_failed(VFS::flush_all()); + luassert_always(first->output_count == 1 && second->output_count == 1 && third->output_count == 1); + lupanic_if_failed(VFS::unmount("/Root/Child")); + lupanic_if_failed(VFS::unmount("/Root")); + lupanic_if_failed(VFS::unmount("/Third")); + printf("Routing, remount and flush-all failure reporting passed.\n"); +} + +static void cross_mount_test(const c8* directory) +{ + auto source = make_storage("cross-mount payload"); + auto destination = make_storage(); + mount_storage(source, "/Source"); + Pak::Options options; + options.compression = Pak::CompressionMethod::deflate; + auto destination_fs = mount_storage(destination, "/Destination", Pak::OpenMode::read_write, options); + auto native_fs = must(VFS::new_native_file_system(directory)); + lupanic_if_failed(VFS::mount(native_fs, "/Native")); + lupanic_if_failed(native_fs->create_dir("nested")); + auto nested_fs = must(VFS::new_native_file_system(child_path(directory, "nested").c_str())); + lupanic_if_failed(VFS::mount(nested_fs, "/NativeNested")); + lupanic_if_failed(VFS::create_dir("/Native/moving-dir")); + put_vfs("/Native/moving-dir/child", "native directory move"); + lupanic_if_failed(VFS::move_file("/Native/moving-dir", "/NativeNested/moved-dir")); + expect_vfs_text("/NativeNested/moved-dir/child", "native directory move"); + lupanic_if_failed(VFS::copy_file("/NativeNested/moved-dir/child", "/Native/native-copy")); + expect_vfs_text("/Native/native-copy", "native directory move"); + lupanic_if_failed(VFS::delete_file("/Native/native-copy")); + lupanic_if_failed(VFS::delete_file("/NativeNested/moved-dir/child")); + lupanic_if_failed(VFS::delete_file("/NativeNested/moved-dir")); + lupanic_if_failed(VFS::unmount("/NativeNested")); + lupanic_if_failed(native_fs->delete_file("nested")); + lupanic_if_failed(VFS::copy_file("/Source/data/seed", "/Destination/copied")); + lupanic_if_failed(VFS::copy_file("/Source/data/seed", "/Native/copied")); + lupanic_if_failed(VFS::copy_file("/Native/copied", "/Destination/from-native")); + expect_vfs_text("/Destination/from-native", "cross-mount payload"); + expect_error(VFS::copy_file("/Source/data/seed", "/Native/copied"), E_ALREADY_EXISTS); + expect_error(VFS::copy_file("/Source/missing", "/Native/copied"), E_NOT_FOUND); + expect_vfs_text("/Native/copied", "cross-mount payload"); + expect_error(VFS::copy_file("/Native/copied", "/Destination/copied"), E_ALREADY_EXISTS); + expect_vfs_text("/Destination/copied", "cross-mount payload"); + expect_error(VFS::copy_file("/Source/data", "/Native/directory-copy"), E_IS_DIRECTORY); + lupanic_if_failed(VFS::move_file("/Native/copied", "/Destination/moved")); + expect_error(VFS::get_file_attribute("/Native/copied"), E_NOT_FOUND); + lupanic_if_failed(VFS::move_file("/Source/data/seed", "/Destination/moved-again")); + expect_error(VFS::get_file_attribute("/Source/data/seed"), E_NOT_FOUND); + lupanic_if_failed(destination_fs->flush()); + { + auto package = must(Pak::open_pak(destination->published)); + luassert_always(must(package->get_file_compression("copied")) == Pak::CompressionMethod::deflate); + } + // A staging size failure must remove only the newly created partial file. + auto small = make_storage(); + options.max_memory_file_size = 4; + mount_storage(small, "/Small", Pak::OpenMode::read_write, options); + auto copied = VFS::copy_file("/Destination/copied", "/Small/partial"); + luassert_always(failed(copied)); + expect_error(VFS::get_file_attribute("/Small/partial"), E_NOT_FOUND); + expect_vfs_text("/Small/data/seed", "original contents"); + lupanic_if_failed(VFS::unmount("/Small")); + lupanic_if_failed(VFS::unmount("/Source")); + lupanic_if_failed(VFS::unmount("/Destination")); + lupanic_if_failed(VFS::unmount("/Native")); + printf("Native/Pak and Pak/Pak copies and moves passed.\n"); +} + +static void instance_lifetime_test() +{ + FileSystemProbe probe, output_probe; + probe.identity = 11; + auto instance = new_object(); + instance->probe = &probe; + // Mount only attaches the object. It does not flush, recreate or replace it. + lupanic_if_failed(VFS::mount(instance, "/First")); + lupanic_if_failed(VFS::mount(instance, "/Alias")); + luassert_always(!probe.flush_count && !probe.destroyed); + luassert_always(must(VFS::get_file_attribute("/Alias/file")).size == 11); + lupanic_if_failed(VFS::flush_all()); + luassert_always(probe.flush_count == 1); + probe.fail_flush = true; + expect_error(VFS::flush_all(), E_IO_ERROR); + luassert_always(probe.flush_count == 2); // Aliases are attempted only once, even on error. + expect_error(VFS::unmount("/First"), E_IO_ERROR); + luassert_always(must(VFS::get_file_attribute("/First/file")).size == 11); + probe.fail_flush = false; + lupanic_if_failed(VFS::unmount("/First")); + luassert_always(!probe.destroyed); + instance->file = new_object(); + instance->file->bytes.resize(70001); + for(usize i = 0; i < instance->file->bytes.size(); ++i) instance->file->bytes[i] = (byte_t)(i % 251); + instance->file->chunk_size = 11; + auto output = new_object(); + output->probe = &output_probe; + lupanic_if_failed(VFS::mount(output, "/ShortOutput")); + lupanic_if_failed(VFS::copy_file("/Alias/file", "/ShortOutput/file")); + luassert_always(equal_bytes(output->file->bytes, instance->file->bytes)); + auto destination = make_storage(); + mount_storage(destination, "/ShortDestination"); + lupanic_if_failed(VFS::copy_file("/Alias/file", "/ShortDestination/file")); + { + auto copied = must(VFS::open_file("/ShortDestination/file", FileOpenFlag::read, FileCreationMode::open_existing)); + luassert_always(equal_bytes(read_all(copied), instance->file->bytes)); + } + lupanic_if_failed(VFS::unmount("/ShortOutput")); + luassert_always(!output_probe.destroyed); // The caller still owns the instance. + output = nullptr; + luassert_always(output_probe.destroyed == 1); + lupanic_if_failed(VFS::unmount("/ShortDestination")); + instance = nullptr; // The remaining mount is now the only owner. + luassert_always(!probe.destroyed); + lupanic_if_failed(VFS::unmount("/Alias")); + luassert_always(probe.destroyed == 1); + printf("Instance ownership, alias deduplication and short I/O passed.\n"); +} + +static void direct_pak_test() +{ + auto storage = make_storage(); + auto instance = must(VFS::new_pak_file_system(storage, Pak::OpenMode::read_write)); + auto original = storage->published->bytes; + luassert_always(test_flags(must(instance->get_file_attribute(Path())).attributes, FileAttributeFlag::directory)); + expect_error(instance->get_file_attribute("/data/seed"), E_BAD_ARGUMENTS); + expect_error(instance->get_file_attribute("../escape"), E_BAD_ARGUMENTS); + expect_error(instance->get_file_attribute("Root:data/seed"), E_BAD_ARGUMENTS); + { + auto file = must(instance->open_file("data/direct", FileOpenFlag::write, FileCreationMode::create_new)); + lupanic_if_failed(file->write("direct edit", 11)); + expect_error(instance->flush(), E_BUSY); + } + lupanic_if_failed(instance->flush()); // No mount is involved. + expect_published_text(storage, "data/direct", "direct edit"); + lupanic_if_failed(VFS::mount(instance, "/SharedA")); + lupanic_if_failed(VFS::mount(instance, "/SharedB")); + put_vfs("/SharedA/data/direct", "shared edit"); + expect_vfs_text("/SharedB/data/direct", "shared edit"); + { + auto direct = must(instance->open_file("data/direct", FileOpenFlag::read, FileCreationMode::open_existing)); + expect_error(VFS::unmount("/SharedA"), E_BUSY); + expect_error(instance->flush(), E_BUSY); + } + { + auto alias_file = must(VFS::open_file("/SharedA/data/direct", FileOpenFlag::read, FileCreationMode::open_existing)); + expect_error(instance->flush(), E_BUSY); + expect_error(VFS::unmount("/SharedB"), E_BUSY); + } + // Instance identity selects the in-filesystem operation even through another alias. + lupanic_if_failed(VFS::copy_file("/SharedA/data/direct", "/SharedB/copy")); + lupanic_if_failed(VFS::move_file("/SharedA/data", "/SharedB/renamed")); + expect_vfs_text("/SharedA/renamed/direct", "shared edit"); + lupanic_if_failed(VFS::unmount("/SharedA")); + expect_vfs_text("/SharedB/renamed/direct", "shared edit"); + lupanic_if_failed(VFS::unmount("/SharedB")); + { + auto file = must(instance->open_file("after-unmount", FileOpenFlag::write, FileCreationMode::create_new)); + lupanic_if_failed(file->write("still usable", 12)); + } + lupanic_if_failed(instance->flush()); + expect_published_text(storage, "after-unmount", "still usable"); + storage->fail_publish = true; + lupanic_if_failed(instance->create_dir("pending")); + expect_error(instance->flush(), E_IO_ERROR); + usize outputs = storage->output_count; + { + auto reader = must(instance->open_file("after-unmount", FileOpenFlag::read, FileCreationMode::open_existing)); + storage->fail_publish = false; + expect_error(instance->flush(), E_BUSY); + } + lupanic_if_failed(instance->flush()); + luassert_always(storage->output_count == outputs); + lupanic_if_failed(VFS::mount(instance, "/Remounted")); + expect_vfs_text("/Remounted/after-unmount", "still usable"); + lupanic_if_failed(VFS::unmount("/Remounted")); + auto published = storage->published->bytes; + lupanic_if_failed(instance->create_dir("discarded")); + instance = nullptr; + luassert_always(equal_bytes(published, storage->published->bytes)); + luassert_always(!equal_bytes(original, published)); + printf("Direct Pak access, alias state and instance flush passed.\n"); +} + +#if !defined(LUNA_PLATFORM_WINDOWS) +static String block_native_publication(const c8* directory, const c8* mount_path, Ref& instance) +{ + auto path = child_path(directory, "blocked.pak"); + create_native_package(path.c_str()); + instance = must(VFS::new_pak_file_system(path.c_str(), Pak::OpenMode::read_write)); + lupanic_if_failed(VFS::mount(instance, mount_path)); + put_vfs(child_path(mount_path, "data/seed").c_str(), "retained output"); + // POSIX permits renaming an open input. This intentionally breaks exclusive + // storage ownership solely to inject a deterministic replacement failure. + auto backup = child_path(directory, "backup.pak"); + lupanic_if_failed(Luna::move_file(path.c_str(), backup.c_str())); + lupanic_if_failed(Luna::create_dir(path.c_str())); + expect_error(instance->flush(), E_IS_DIRECTORY); + auto outputs = temporary_outputs(directory); + luassert_always(outputs.size() == 1 && strstr(get_error().message.c_str(), outputs[0].c_str())); + expect_vfs_text(child_path(mount_path, "data/seed").c_str(), "retained output"); + return outputs[0]; +} + +static void native_retry_test(const c8* directory) +{ + Ref instance; + auto completed = block_native_publication(directory, "/Retry", instance); + auto bytes = read_native(completed.c_str()); + expect_error(VFS::unmount("/Retry"), E_IS_DIRECTORY); + luassert_always(equal_bytes(bytes, read_native(completed.c_str()))); + auto path = child_path(directory, "blocked.pak"); + auto backup = child_path(directory, "backup.pak"); + lupanic_if_failed(Luna::delete_file(path.c_str())); + lupanic_if_failed(Luna::move_file(backup.c_str(), path.c_str())); + lupanic_if_failed(instance->flush()); + luassert_always(temporary_outputs(directory).empty()); + luassert_always(equal_bytes(bytes, read_native(path.c_str()))); + lupanic_if_failed(VFS::unmount("/Retry")); + instance = nullptr; + lupanic_if_failed(Luna::delete_file(path.c_str())); + printf("Native replacement failure and retry passed.\n"); +} +#endif + +static void shutdown_test(const c8* directory) +{ + auto path = child_path(directory, "shutdown.pak"); + create_native_package(path.c_str()); + auto instance = must(VFS::new_pak_file_system(path.c_str(), Pak::OpenMode::read_write)); + lupanic_if_failed(VFS::mount(instance, "/Shutdown")); + lupanic_if_failed(VFS::mount(instance, "/ShutdownAlias")); + auto file = must(VFS::open_file("/Shutdown/data/seed", FileOpenFlag::write, FileCreationMode::create_always)); + lupanic_if_failed(file->write("saved at shutdown", 17)); + auto iter = must(VFS::open_dir("/Shutdown")); + FileSystemProbe failed_probe; + failed_probe.fail_flush = true; + auto failed_instance = new_object(); + failed_instance->probe = &failed_probe; + lupanic_if_failed(VFS::mount(failed_instance, "/CleanupFailure")); + lupanic_if_failed(VFS::mount(failed_instance, "/CleanupFailureAlias")); + auto direct_storage = make_storage(); + auto direct_instance = must(VFS::new_pak_file_system(direct_storage, Pak::OpenMode::read_write)); + lupanic_if_failed(VFS::mount(direct_instance, "/DirectAtShutdown")); + auto direct_file = must(direct_instance->open_file("data/seed", FileOpenFlag::write, FileCreationMode::create_always)); + lupanic_if_failed(direct_file->write("direct handle retained", 22)); +#if !defined(LUNA_PLATFORM_WINDOWS) + Ref retained_instance; + auto retained = block_native_publication(directory, "/ShutdownFailure", retained_instance); +#endif + // Exercise the shutdown callback while Runtime/Pak are still alive so invalidated + // wrappers and saved bytes can be inspected. The later module close is a no-op. + module_vfs()->on_close(); + luassert_always(failed_probe.flush_count == 1 && !failed_probe.destroyed); + failed_probe.fail_flush = false; + lupanic_if_failed(failed_instance->flush()); + failed_instance = nullptr; + luassert_always(failed_probe.destroyed == 1); + luassert_always(!direct_storage->output_count && direct_file->get_size() == 22); + direct_file = nullptr; + lupanic_if_failed(direct_instance->flush()); + expect_published_text(direct_storage, "data/seed", "direct handle retained"); + expect_error(file->write("x", 1), E_BAD_CALLING_TIME); + expect_error(file->tell(), E_BAD_CALLING_TIME); + luassert_always(!iter->is_valid() && !iter->get_filename()); + expect_error(VFS::flush_all(), E_BAD_CALLING_TIME); + file = nullptr; + iter = nullptr; + { + auto input = must(Luna::open_file(path.c_str(), FileOpenFlag::read, FileCreationMode::open_existing)); + auto package = must(Pak::open_pak(input)); + auto saved = must(package->open_file("data/seed", FileOpenFlag::read, FileCreationMode::open_existing)); + expect_text(saved, "saved at shutdown"); + } + instance = nullptr; + lupanic_if_failed(Luna::delete_file(path.c_str())); +#if !defined(LUNA_PLATFORM_WINDOWS) + { + auto input = must(Luna::open_file(retained.c_str(), FileOpenFlag::read, FileCreationMode::open_existing)); + auto package = must(Pak::open_pak(input)); + auto saved = must(package->open_file("data/seed", FileOpenFlag::read, FileCreationMode::open_existing)); + expect_text(saved, "retained output"); + } + retained_instance = nullptr; + lupanic_if_failed(Luna::delete_file(retained.c_str())); + lupanic_if_failed(Luna::delete_file(child_path(directory, "blocked.pak").c_str())); + lupanic_if_failed(Luna::delete_file(child_path(directory, "backup.pak").c_str())); +#endif + printf("Shutdown persistence, alias deduplication and caller ownership passed.\n"); +} + +int main() +{ + lupanic_if_failed(Luna::init()); + lupanic_if_failed(add_modules({module_vfs()})); + lupanic_if_failed(init_modules()); + Meta::register_VFSPakTest_types(); + { + c8 guid[GUID_STRING_LENGTH + 1]{}; + lupanic_if_failed(encode_guid(random_guid(), guid, GUID_STRING_LENGTH)); + String directory("VFSPakTest-"); + directory.append(guid); + lupanic_if_failed(Luna::create_dir(directory.c_str())); + native_mount_test(directory.c_str()); + storage_failure_test(); + routing_and_flush_all_test(); + cross_mount_test(directory.c_str()); + instance_lifetime_test(); + direct_pak_test(); +#if !defined(LUNA_PLATFORM_WINDOWS) + native_retry_test(directory.c_str()); +#endif + shutdown_test(directory.c_str()); + luassert_always(temporary_outputs(directory.c_str()).empty()); + lupanic_if_failed(Luna::delete_file(directory.c_str())); + } + Luna::close(); + printf("VFSPakTest passed.\n"); + return 0; +} diff --git a/Tests/VFSPakTest/Source/TestStorage.hpp b/Tests/VFSPakTest/Source/TestStorage.hpp new file mode 100644 index 00000000..4733e966 --- /dev/null +++ b/Tests/VFSPakTest/Source/TestStorage.hpp @@ -0,0 +1,171 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file TestStorage.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include +#include +#include "TestStorage.generated.hpp" + +namespace Luna +{ + struct [[Luna::struct("{73A72F32-5B27-46C5-8142-A8C12DD34278}")]] VfsTestStream : IFile + { + luiimpl(); + Vector bytes; + u64 position = 0; + usize chunk_size = USIZE_MAX; + u64 fail_write_at = U64_MAX; + bool fail_resize = false; + bool fail_read_after_write = false; + u64 bytes_written = 0; + + RV read(void* buffer, usize size, usize* count) override + { + if(count) *count = 0; + if(fail_read_after_write && bytes_written) return E_IO_ERROR; + usize amount = position >= bytes.size() ? 0 : (usize)min(size, bytes.size() - position); + amount = min(amount, chunk_size); + if(amount) memcpy(buffer, bytes.data() + (usize)position, amount); + position += amount; + if(count) *count = amount; + return ok; + } + RV write(const void* buffer, usize size, usize* count) override + { + if(count) *count = 0; + if(position >= fail_write_at) return E_IO_ERROR; + usize amount = (usize)min(min(size, fail_write_at - position), chunk_size); + if(position > USIZE_MAX - amount) return E_FILE_TOO_BIG; + if(position + amount > bytes.size()) + { + usize old_size = bytes.size(); + bytes.resize((usize)position + amount); + memset(bytes.data() + old_size, 0, bytes.size() - old_size); + } + if(amount) memcpy(bytes.data() + (usize)position, buffer, amount); + position += amount; + bytes_written += amount; + if(count) *count = amount; + return ok; + } + R tell() override { return position; } + RV seek(i64 offset, SeekMode mode) override + { + if(mode != SeekMode::begin && mode != SeekMode::current && mode != SeekMode::end) return E_BAD_ARGUMENTS; + i64 base = mode == SeekMode::begin ? 0 : (mode == SeekMode::current ? (i64)position : (i64)bytes.size()); + if(offset < -base || (offset > 0 && base > I64_MAX - offset)) return E_OUT_OF_RANGE; + position = (u64)(base + offset); + return ok; + } + u64 get_size() override { return bytes.size(); } + RV set_size(u64 size) override + { + if(fail_resize) return E_IO_ERROR; + if(size > USIZE_MAX) return E_FILE_TOO_BIG; + usize old_size = bytes.size(); + bytes.resize((usize)size); + if(size > old_size) memset(bytes.data() + old_size, 0, (usize)size - old_size); + return ok; + } + void flush() override {} + }; + + enum class OutputFault + { + none, + create, + null_stream, + alias_source, + write, + resize, + reopen + }; + + struct [[Luna::struct("{B999BA5F-7864-467A-B9B6-713D03093578}")]] VfsTestStorage : VFS::IPakStorage + { + luiimpl(); + Ref published; + Ref last_output; + OutputFault fault = OutputFault::none; + bool fail_publish = false; + const c8* failure_message = "injected publication failure"; + usize chunk_size = 7; + usize output_count = 0; + usize publish_count = 0; + + R> open_source() override { return Ref(published); } + R> create_output() override + { + ++output_count; + if(fault == OutputFault::create) return E_ACCESS_DENIED; + if(fault == OutputFault::null_stream) return Ref(); + if(fault == OutputFault::alias_source) return Ref(published); + last_output = new_object(); + last_output->chunk_size = chunk_size; + last_output->fail_write_at = fault == OutputFault::write ? 40 : U64_MAX; + last_output->fail_resize = fault == OutputFault::resize; + last_output->fail_read_after_write = fault == OutputFault::reopen; + return Ref(last_output); + } + RV publish(ISeekableStream* output) override + { + ++publish_count; + luassert_always(output == static_cast(last_output.get())); + if(fail_publish) return set_error(E_IO_ERROR, "%s", failure_message); + published = last_output; + return ok; + } + }; + + struct FileSystemProbe + { + usize identity = 0; + usize destroyed = 0; + usize flush_count = 0; + bool fail_flush = false; + }; + + struct [[Luna::struct("{9C9BD772-C271-45AB-A464-914C6E73EF0F}")]] TestFileSystem : VFS::IFileSystem + { + luiimpl(); + FileSystemProbe* probe = nullptr; + Ref file; + ~TestFileSystem() { if(probe) ++probe->destroyed; } + R> open_file(const Path&, FileOpenFlag, FileCreationMode creation) override + { + if(creation == FileCreationMode::create_new) + { + if(file) return E_ALREADY_EXISTS; + file = new_object(); + file->chunk_size = 3; + } + if(!file) return E_NOT_FOUND; + file->position = 0; + return Ref(file); + } + R get_file_attribute(const Path&) override + { + FileAttribute attr{}; + attr.size = probe->identity; + return attr; + } + R> open_dir(const Path&) override { return E_NOT_SUPPORTED; } + RV delete_file(const Path&) override + { + if(!file) return E_NOT_FOUND; + file = nullptr; + return ok; + } + RV flush() override + { + ++probe->flush_count; + return probe->fail_flush ? RV(E_IO_ERROR) : RV(ok); + } + }; +} diff --git a/Tests/VFSPakTest/VFSPakTest.Target.cs b/Tests/VFSPakTest/VFSPakTest.Target.cs new file mode 100644 index 00000000..98917f78 --- /dev/null +++ b/Tests/VFSPakTest/VFSPakTest.Target.cs @@ -0,0 +1,15 @@ +namespace LunaBuild.Core.Targets; + +public sealed class VFSPakTestTargetRules : TargetRules +{ + public VFSPakTestTargetRules() : base("VFSPakTest", "Tests/VFSPakTest", "Tests/VFSPakTest/VFSPakTest.Target.cs") + { + SupportedPlatforms(BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Linux); + Category = BuildTargetCategory.Tests; + Kind = BuildTargetKind.Executable; + Headers("Source/*.hpp"); + MetaHeaders("Source/TestStorage.hpp"); + Sources("Source/*.cpp"); + DependsOn("Runtime", "Pak", "VFS"); + } +} diff --git a/Tests/VGCurveTest/Source/main.cpp b/Tests/VGCurveTest/Source/main.cpp index 61ce380f..c74c34f0 100644 --- a/Tests/VGCurveTest/Source/main.cpp +++ b/Tests/VGCurveTest/Source/main.cpp @@ -68,6 +68,7 @@ namespace Luna bool was_left_down = false; f32 display_size = 512.0f; RectF image_rect = RectF(0.0f, 0.0f, 0.0f, 0.0f); + Vector overlay_commands; Float2U control_points[NUM_CURVE_POINTS] = { Float2U(0.16f, 0.48f), @@ -87,6 +88,7 @@ namespace Luna constexpr GUI::id_t ROOT_ID = 2; constexpr GUI::id_t IMAGE_HIT_ID = 100; constexpr GUI::id_t RESOLUTION_GROUP_ID = 101; + constexpr GUI::id_t OVERLAY_ID = 102; constexpr GUI::id_t FIRST_TEXT_ID = 1000; inline GUI::LayoutConfig fixed_layout(f32 width, f32 height) @@ -123,7 +125,7 @@ namespace Luna return in_bounds(point, rect_min(rect), rect_max(rect)); } - void draw_line(GUI::IContext* context, const Float2U& begin, const Float2U& end, + void append_line(Vector& commands, const Float2U& begin, const Float2U& end, const Float4U& color, f32 width) { GUI::DrawCommand command; @@ -132,10 +134,10 @@ namespace Luna command.point1 = end; command.color = color; command.line_width = width; - context->draw(command); + commands.push_back(move(command)); } - void draw_circle(GUI::IContext* context, const Float2U& center, f32 radius, + void append_circle(Vector& commands, const Float2U& center, f32 radius, const Float4U& color) { GUI::DrawCommand command; @@ -143,7 +145,20 @@ namespace Luna command.rect = RectF(center.x - radius, center.y - radius, radius * 2.0f, radius * 2.0f); command.color = color; command.radius = radius; - context->draw(command); + commands.push_back(move(command)); + } + + R draw_curve_overlay(GUI::IContext* context, + const GUI::ElementHandle&, GUI::DrawPhase, GUI::paint_order_id_t paint_order_id, + void* userdata) + { + const Vector& commands = *(const Vector*)userdata; + if(commands.empty()) return paint_order_id; + for(usize i = 0; i < commands.size(); ++i) + { + context->draw(commands[i], paint_order_id + (GUI::paint_order_id_t)i); + } + return paint_order_id + (GUI::paint_order_id_t)commands.size() - 1; } inline Float2U curve_point_to_texture(const App& app, const Float2U& point) @@ -241,7 +256,7 @@ namespace Luna return ok; } - void draw_curve_overlay(App& app, const RectF& rect) + void build_curve_overlay(App& app, const RectF& rect) { Float2U p0 = curve_point_to_rect(rect, app.control_points[0]); Float2U q0 = curve_point_to_rect(rect, app.control_points[1]); @@ -255,22 +270,23 @@ namespace Luna Float2U c3 = curve_point_to_rect(rect, app.control_points[9]); Float4U quad_line_color(0.95f, 0.95f, 1.0f, 0.48f); Float4U cubic_line_color(0.95f, 0.80f, 0.30f, 0.48f); - draw_line(app.gui, p0, q0, quad_line_color, CONTROL_LINE_WIDTH); - draw_line(app.gui, q0, p1, quad_line_color, CONTROL_LINE_WIDTH); - draw_line(app.gui, p1, c0, cubic_line_color, CONTROL_LINE_WIDTH); - draw_line(app.gui, c0, c1, cubic_line_color, CONTROL_LINE_WIDTH); - draw_line(app.gui, c1, p2, cubic_line_color, CONTROL_LINE_WIDTH); - draw_line(app.gui, p2, q1, quad_line_color, CONTROL_LINE_WIDTH); - draw_line(app.gui, q1, p3, quad_line_color, CONTROL_LINE_WIDTH); - draw_line(app.gui, p3, c2, cubic_line_color, CONTROL_LINE_WIDTH); - draw_line(app.gui, c2, c3, cubic_line_color, CONTROL_LINE_WIDTH); - draw_line(app.gui, c3, p0, cubic_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, p0, q0, quad_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, q0, p1, quad_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, p1, c0, cubic_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, c0, c1, cubic_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, c1, p2, cubic_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, p2, q1, quad_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, q1, p3, quad_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, p3, c2, cubic_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, c2, c3, cubic_line_color, CONTROL_LINE_WIDTH); + append_line(app.overlay_commands, c3, p0, cubic_line_color, CONTROL_LINE_WIDTH); for(u32 i = 0; i < NUM_CURVE_POINTS; ++i) { Float4U color = (i == 0 || i == 2 || i == 5 || i == 7) ? Float4U(1.0f, 1.0f, 1.0f, 1.0f) : Float4U(1.0f, 0.78f, 0.18f, 1.0f); - draw_circle(app.gui, curve_point_to_rect(rect, app.control_points[i]), CONTROL_POINT_RADIUS, color); + append_circle(app.overlay_commands, curve_point_to_rect(rect, app.control_points[i]), + CONTROL_POINT_RADIUS, color); } } @@ -284,6 +300,7 @@ namespace Luna void build_gui(App& app, const Float2U& surface_size) { + app.overlay_commands.clear(); GUI::ElementHandle root = app.gui->begin_element(ROOT_ID); set_element_rect(app.gui, root, RectF(0.0f, 0.0f, surface_size.x, surface_size.y)); @@ -310,7 +327,15 @@ namespace Luna set_element_rect(app.gui, image_hit, app.image_rect); if(app.image_rect.width > 1.0f && app.image_rect.height > 1.0f) { - draw_curve_overlay(app, app.image_rect); + build_curve_overlay(app, app.image_rect); + GUI::ElementHandle overlay = app.gui->begin_element(OVERLAY_ID); + set_element_rect(app.gui, overlay, RectF(0.0f, 0.0f, surface_size.x, surface_size.y)); + GUI::DrawConfig overlay_draw; + overlay_draw.name = Name("vg_curve_test.overlay"); + overlay_draw.callback = draw_curve_overlay; + overlay_draw.userdata = &app.overlay_commands; + app.gui->set_draw_config(overlay, overlay_draw); + app.gui->end_element(); } f32 side_x = app.image_rect.offset_x + app.image_rect.width + 24.0f; diff --git a/Tests/ZipTest/Interop.py b/Tests/ZipTest/Interop.py new file mode 100644 index 00000000..7f7470f1 --- /dev/null +++ b/Tests/ZipTest/Interop.py @@ -0,0 +1,43 @@ +"""Check ZIP interoperability in both directions using Python's standard library. + +Run from any directory with `python3 Tests/ZipTest/Interop.py`. +The script launches ZipTest through LunaBuild; do not run another build alongside it. +""" +from pathlib import Path +import subprocess +import tempfile +import zipfile + + +def main(): + root = Path(__file__).resolve().parents[2] + with tempfile.TemporaryDirectory(prefix="luna-zip-interop-") as directory: + source = Path(directory) / "python.zip" + output = Path(directory) / "luna.zip" + contents = b"Created by Python zipfile." + with zipfile.ZipFile(source, "w", allowZip64=True) as archive: + archive.writestr("stored.txt", contents, compress_type=zipfile.ZIP_STORED) + archive.writestr("deflated.txt", contents, compress_type=zipfile.ZIP_DEFLATED) + archive.writestr("unsupported.txt", contents, compress_type=zipfile.ZIP_BZIP2) + with archive.open("zip64.txt", "w", force_zip64=True) as entry: + entry.write(contents) + original = source.read_bytes() + subprocess.run([ + "dotnet", "run", "--project", "LunaBuild.csproj", "--", "run", + "--target", "ZipTest", "--", str(output), str(source), + ], cwd=root, check=True) + assert source.read_bytes() == original + with zipfile.ZipFile(output) as archive: + assert archive.testzip() is None + expected = b"Luna ZIP stream test. Luna ZIP stream test." + assert archive.read("stored.txt") == expected + assert archive.read("nested/\u6587\u4ef6.txt") == expected + assert archive.getinfo("stored.txt").compress_type == zipfile.ZIP_STORED + assert archive.getinfo("nested/\u6587\u4ef6.txt").compress_type == zipfile.ZIP_DEFLATED + assert archive.getinfo("empty/").is_dir() + assert archive.read("zero") == b"" + print("Zip interoperability: Python to Luna and Luna to Python passed.") + + +if __name__ == "__main__": + main() diff --git a/Tests/ZipTest/Source/Main.cpp b/Tests/ZipTest/Source/Main.cpp new file mode 100644 index 00000000..946e6d60 --- /dev/null +++ b/Tests/ZipTest/Source/Main.cpp @@ -0,0 +1,336 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file Main.cpp +* @author JXMaster +* @date 2026/9/4 +*/ +#include "TestStream.hpp" +#include "ZipTest.meta.generated.hpp" +#include +#include +#include +#include +#include +using namespace Luna; +using namespace Luna::Zip; + +static bool same_bytes(const Vector& lhs, const Vector& rhs) +{ + return lhs.size() == rhs.size() && (lhs.empty() || !memcmp(lhs.data(), rhs.data(), lhs.size())); +} + +template +static T must(R result) +{ + lupanic_if_failed(result); + return move(result.get()); +} + +template +static void expect_error(R result, ResultCode code) +{ + luassert_always(failed(result)); + luassert_always(unwrap_errcode(result.errcode()) == code); +} + +static Ref data_stream(const c8* text) +{ + auto stream = new_object(); + stream->bytes.resize(strlen(text)); + memcpy(stream->bytes.data(), text, stream->bytes.size()); + return stream; +} + +static Vector read_all(IStream* stream) +{ + Vector result; + byte_t buffer[4096]; + while(true) + { + usize size = 0; + lupanic_if_failed(stream->read(buffer, sizeof(buffer), &size)); + if(!size) break; + result.insert(result.end(), buffer, buffer + size); + } + return result; +} + +static Vector read_entry(IArchive* archive, u64 index) +{ + auto stream = must(archive->open_entry(index)); + return read_all(stream); +} + +static Ref create_test() +{ + auto archive = must(new_archive()); + auto output = data_stream("old output to be replaced"); + auto text = data_stream("Luna ZIP stream test. Luna ZIP stream test."); + text->chunk_size = 3; + text->position = 7; + text->read_only = true; + u64 stored = must(archive->add_file("stored.txt", text, CompressionMethod::store)); + u64 deflated = must(archive->add_file("nested/\xe6\x96\x87\xe4\xbb\xb6.txt", text, CompressionMethod::deflate, 9)); + u64 directory = must(archive->add_directory("empty")); + auto empty = data_stream(""); + must(archive->add_file("zero", empty)); + luassert_always(text->bytes_read == 0 && output->bytes_written == 0); + expect_error(archive->add_file("stored.txt", text), E_ALREADY_EXISTS); + expect_error(archive->add_file("bad/", text), E_BAD_ARGUMENTS); + expect_error(archive->add_file("bad", text, CompressionMethod::store, 1), E_BAD_ARGUMENTS); + expect_error(archive->add_file("bad", text, (CompressionMethod)99), E_NOT_SUPPORTED); + expect_error(archive->open_entry(directory), E_IS_DIRECTORY); + expect_error(archive->get_entry(999), E_NOT_FOUND); + luassert_always(same_bytes(read_entry(archive, stored), text->bytes)); + luassert_always(same_bytes(read_entry(archive, deflated), text->bytes)); + // Two independent entry cursors share the same underlying source object. + { + auto first = must(archive->open_entry(stored)); + auto second = must(archive->open_entry(deflated)); + c8 prefix[5]; + usize count; + lupanic_if_failed(first->read(prefix, sizeof(prefix), &count)); + luassert_always(count == sizeof(prefix)); + luassert_always(same_bytes(read_all(second), text->bytes)); + auto tail = read_all(first); + luassert_always(tail.size() == text->bytes.size() - sizeof(prefix)); + luassert_always(!memcmp(tail.data(), text->bytes.data() + sizeof(prefix), tail.size())); + expect_error(first->write(prefix, sizeof(prefix)), E_ACCESS_DENIED); + expect_error(archive->delete_entry(stored), E_BUSY); + expect_error(archive->save(output), E_BUSY); + expect_error(archive->discard(), E_BUSY); + } + expect_error(archive->save(text), E_BAD_ARGUMENTS); + output->chunk_size = 7; + lupanic_if_failed(archive->save(output)); + luassert_always(!archive->is_open()); + expect_error(archive->get_entries(), E_BAD_CALLING_TIME); + lupanic_if_failed(archive->discard()); + auto reopened = must(open_archive(output)); + luassert_always(must(reopened->get_entries()).size() == 4); + luassert_always(same_bytes(read_entry(reopened, must(reopened->find_entry("stored.txt"))), text->bytes)); + auto info = must(reopened->get_entry(must(reopened->find_entry("nested/\xe6\x96\x87\xe4\xbb\xb6.txt")))); + luassert_always(info.compression == CompressionMethod::deflate && info.has_crc32 && info.has_compressed_size); + luassert_always(info.compressed_size < info.size); + expect_error(reopened->add_directory("no"), E_ACCESS_DENIED); + expect_error(reopened->save(text), E_ACCESS_DENIED); + auto retained = must(reopened->open_entry(stored)); + reopened = nullptr; + luassert_always(same_bytes(read_all(retained), text->bytes)); + return output; +} + +static void edit_test(ZipTestStream* original) +{ + auto unchanged_bytes = original->bytes; + auto archive = must(open_archive(original, OpenMode::read_write)); + auto text = data_stream("replacement bytes"); + auto output = new_object(); + auto stored = must(archive->find_entry("stored.txt")); + auto compressed = must(archive->find_entry("nested/\xe6\x96\x87\xe4\xbb\xb6.txt")); + lupanic_if_failed(archive->replace_file(stored, text)); + lupanic_if_failed(archive->replace_file(compressed, text)); + luassert_always(same_bytes(read_entry(archive, stored), text->bytes)); + lupanic_if_failed(archive->rename_entry(stored, "renamed.txt")); + expect_error(archive->rename_entry(stored, "bad/"), E_BAD_ARGUMENTS); + lupanic_if_failed(archive->delete_entry(must(archive->find_entry("empty/")))); + expect_error(archive->find_entry("empty/"), E_NOT_FOUND); + luassert_always(must(archive->get_entries()).size() == 3); + luassert_always(same_bytes(original->bytes, unchanged_bytes)); + expect_error(archive->save(original), E_BAD_ARGUMENTS); + // Fail before writing, during writing, and at commit, then retry the same session. + output->fail_resize = true; + expect_error(archive->save(output), E_IO_ERROR); + luassert_always(archive->is_open()); + output->fail_resize = false; + output->fail_write_at = 35; + expect_error(archive->save(output), E_IO_ERROR); + luassert_always(archive->is_open() && same_bytes(original->bytes, unchanged_bytes)); + output->fail_write_at = U64_MAX; + output->fail_commit = true; + expect_error(archive->save(output), E_IO_ERROR); + luassert_always(archive->is_open() && same_bytes(original->bytes, unchanged_bytes)); + output->fail_commit = false; + lupanic_if_failed(archive->save(output)); + auto reopened = must(open_archive(output, OpenMode::read_write)); + auto info = must(reopened->get_entry(must(reopened->find_entry("renamed.txt")))); + luassert_always(info.compression == CompressionMethod::store); + auto compressed_info = must(reopened->get_entry(must(reopened->find_entry("nested/\xe6\x96\x87\xe4\xbb\xb6.txt")))); + luassert_always(compressed_info.compression == CompressionMethod::deflate); + luassert_always(same_bytes(read_entry(reopened, info.index), text->bytes)); + lupanic_if_failed(reopened->set_compression(info.index, CompressionMethod::deflate, 1)); + auto recompressed = new_object(); + lupanic_if_failed(reopened->save(recompressed)); + reopened = must(open_archive(recompressed)); + luassert_always(must(reopened->get_entry(must(reopened->find_entry("renamed.txt")))).compression == CompressionMethod::deflate); + luassert_always(same_bytes(original->bytes, unchanged_bytes)); +} + +static void unchanged_test(ZipTestStream* original) +{ + auto archive = must(open_archive(original, OpenMode::read_write)); + auto index = must(archive->find_entry("stored.txt")); + // Renames that cancel must still produce a complete archive on save. + lupanic_if_failed(archive->rename_entry(index, "temporary.txt")); + lupanic_if_failed(archive->rename_entry(index, "stored.txt")); + auto output = new_object(); + output->fail_write_at = 9; + expect_error(archive->save(output), E_IO_ERROR); + luassert_always(archive->is_open()); + output->fail_write_at = U64_MAX; + lupanic_if_failed(archive->save(output)); + luassert_always(same_bytes(output->bytes, original->bytes)); + archive = must(open_archive(original, OpenMode::read_write)); + lupanic_if_failed(archive->set_compression(index, CompressionMethod::store)); + lupanic_if_failed(archive->save(output)); + archive = must(open_archive(output)); + luassert_always(must(archive->get_entries()).size() == 4); + archive = must(open_archive(original, OpenMode::read_write)); + lupanic_if_failed(archive->rename_entry(index, "discarded")); + lupanic_if_failed(archive->discard()); + archive = must(open_archive(original)); + must(archive->find_entry("stored.txt")); +} + +static void empty_and_zip64_test() +{ + auto output = new_object(); + auto archive = must(new_archive()); + lupanic_if_failed(archive->save(output)); + luassert_always(output->bytes.size() == 22); + archive = must(open_archive(output, OpenMode::read_write)); + luassert_always(must(archive->get_entries()).empty()); + // Crossing the classic ZIP entry-count limit exercises ZIP64 directory records. + for(u32 i = 0; i < 65536; ++i) + { + c8 name[32]; + snprintf(name, sizeof(name), "directory-%u/", i); + must(archive->add_directory(name)); + } + auto zip64 = new_object(); + lupanic_if_failed(archive->save(zip64)); + archive = must(open_archive(zip64, OpenMode::read_write)); + luassert_always(must(archive->get_entries()).size() == 65536); + luassert_always(must(archive->find_entry("directory-65535/")) == 65535); + for(u64 i = 0; i < 65536; ++i) lupanic_if_failed(archive->delete_entry(i)); + lupanic_if_failed(archive->save(output)); + archive = must(open_archive(output)); + luassert_always(must(archive->get_entries()).empty() && output->bytes.size() == 22); +} + +static void corruption_and_metadata_test() +{ + auto input = new_object(); + input->bytes.resize(2 * 1024 * 1024, (byte_t)0x61); + auto output = new_object(); + auto archive = must(new_archive()); + must(archive->add_file("large", input, CompressionMethod::store)); + lupanic_if_failed(archive->save(output)); + output->bytes_read = 0; + output->chunk_size = 13; + archive = must(open_archive(output)); + must(archive->get_entries()); + // Central-directory lookup may overread a bounded tail to locate EOCD, but + // it must not stream the 2 MiB payload or decompress it during enumeration. + luassert_always(output->bytes_read < 128 * 1024); + archive = nullptr; + auto invalid = data_stream("not a ZIP"); + expect_error(open_archive(invalid), E_BAD_DATA); + invalid->bytes = output->bytes; + invalid->bytes.resize(50); + expect_error(open_archive(invalid), E_BAD_DATA); + // Stored entry data starts after its 30-byte local header and 5-byte name. + output->bytes[35] = (byte_t)0x62; + archive = must(open_archive(output)); + auto reader = must(archive->open_entry(0)); + byte_t buffer[65536]; + bool crc_failed = false; + while(true) + { + usize count; + auto result = reader->read(buffer, sizeof(buffer), &count); + if(failed(result)) + { + luassert_always(unwrap_errcode(result.errcode()) == E_BAD_DATA); + crc_failed = true; + break; + } + if(!count) break; + } + luassert_always(crc_failed); +} + +static void imported_archive_test(const c8* path) +{ + auto file = must(open_file(path, FileOpenFlag::read, FileCreationMode::open_existing)); + auto archive = must(open_archive(file, OpenMode::read_write)); + auto expected = data_stream("Created by Python zipfile."); + for(auto name : {"stored.txt", "deflated.txt", "zip64.txt"}) + { + luassert_always(same_bytes(read_entry(archive, must(archive->find_entry(name))), expected->bytes)); + } + auto unsupported = must(archive->find_entry("unsupported.txt")); + expect_error(archive->open_entry(unsupported), E_NOT_SUPPORTED); + auto stored = must(archive->find_entry("stored.txt")); + lupanic_if_failed(archive->rename_entry(stored, "renamed.txt")); + auto output = new_object(); + lupanic_if_failed(archive->save(output)); + archive = must(open_archive(output)); + luassert_always(same_bytes(read_entry(archive, must(archive->find_entry("renamed.txt"))), expected->bytes)); + expect_error(archive->open_entry(must(archive->find_entry("unsupported.txt"))), E_NOT_SUPPORTED); +} + +static void unsupported_entry_test(ZipTestStream* original) +{ + auto input = new_object(); + input->bytes = original->bytes; + // Flag the first entry as encrypted in both headers. The wrapper must reject + // decoding before treating these unencrypted bytes as encrypted input. + input->bytes[6] |= 1; + for(usize i = 0; i + 46 < input->bytes.size(); ++i) + { + if(!memcmp(input->bytes.data() + i, "PK\x01\x02", 4)) + { + input->bytes[i + 8] |= 1; + break; + } + } + auto archive = must(open_archive(input, OpenMode::read_write)); + luassert_always(must(archive->get_entry(0)).encrypted); + expect_error(archive->open_entry(0), E_NOT_SUPPORTED); + expect_error(archive->replace_file(0, original), E_NOT_SUPPORTED); + expect_error(archive->set_compression(0, CompressionMethod::deflate), E_NOT_SUPPORTED); +} + +static void run_tests(const c8* output_path, const c8* import_path) +{ + auto ordinary = create_test(); + edit_test(ordinary); + unchanged_test(ordinary); + corruption_and_metadata_test(); + unsupported_entry_test(ordinary); + empty_and_zip64_test(); + if(import_path) imported_archive_test(import_path); + if(output_path) + { + auto file = must(open_file(output_path, FileOpenFlag::write, FileCreationMode::create_always)); + auto archive = must(open_archive(ordinary, OpenMode::read_write)); + lupanic_if_failed(archive->save(file)); + file->flush(); + } +} + +int main(int argc, char** argv) +{ + lupanic_if_failed(Luna::init()); + lupanic_if_failed(add_modules({module_zip()})); + lupanic_if_failed(init_modules()); + Meta::register_ZipTest_types(); + run_tests(argc > 1 ? argv[1] : nullptr, argc > 2 ? argv[2] : nullptr); + Luna::close(); + printf("ZipTest: all tests passed.\n"); + return 0; +} diff --git a/Tests/ZipTest/Source/TestStream.hpp b/Tests/ZipTest/Source/TestStream.hpp new file mode 100644 index 00000000..cd041e92 --- /dev/null +++ b/Tests/ZipTest/Source/TestStream.hpp @@ -0,0 +1,76 @@ +/*! +* This file is a portion of LunaSDK. +* For conditions of distribution and use, see the disclaimer +* and license in LICENSE.txt +* +* @file TestStream.hpp +* @author JXMaster +* @date 2026/9/4 +*/ +#pragma once +#include +#include +#include "TestStream.generated.hpp" + +namespace Luna +{ + struct [[Luna::struct("{AA5C00C0-5F63-4488-B1BE-38513D62A5E0}")]] ZipTestStream : ISeekableStream + { + luiimpl(); + Vector bytes; + u64 position = 0; + usize chunk_size = USIZE_MAX; + u64 fail_write_at = U64_MAX; + bool fail_resize = false; + bool fail_commit = false; + bool read_only = false; + u64 bytes_read = 0; + u64 bytes_written = 0; + + RV read(void* buffer, usize size, usize* count) override + { + usize amount = position >= bytes.size() ? 0 : min(size, bytes.size() - (usize)position); + amount = min(amount, chunk_size); + if(amount) memcpy(buffer, bytes.data() + (usize)position, amount); + position += amount; + bytes_read += amount; + if(count) *count = amount; + return ok; + } + RV write(const void* buffer, usize size, usize* count) override + { + if(count) *count = 0; + if(read_only) return E_ACCESS_DENIED; + if(position >= fail_write_at) return E_IO_ERROR; + usize amount = min(min(size, fail_write_at - position), chunk_size); + if(position > USIZE_MAX - amount) return E_FILE_TOO_BIG; + if(position + amount > bytes.size()) + { + if(position + amount > bytes.capacity()) bytes.reserve(max((usize)position + amount, bytes.capacity() * 2)); + bytes.resize((usize)position + amount); + } + if(amount) memcpy(bytes.data() + (usize)position, buffer, amount); + position += amount; + bytes_written += amount; + if(count) *count = amount; + return ok; + } + R tell() override { return position; } + RV seek(i64 offset, SeekMode mode) override + { + i64 base = mode == SeekMode::begin ? 0 : (mode == SeekMode::current ? (i64)position : (i64)bytes.size()); + if(offset < -base || (offset > 0 && base > I64_MAX - offset)) return E_BAD_ARGUMENTS; + position = (u64)(base + offset); + return ok; + } + u64 get_size() override { return bytes.size(); } + RV set_size(u64 size) override + { + if(read_only) return E_ACCESS_DENIED; + if(fail_resize || (fail_commit && size)) return E_IO_ERROR; + if(size > USIZE_MAX) return E_FILE_TOO_BIG; + bytes.resize((usize)size); + return ok; + } + }; +} diff --git a/Tests/ZipTest/ZipTest.Target.cs b/Tests/ZipTest/ZipTest.Target.cs new file mode 100644 index 00000000..735f6fbe --- /dev/null +++ b/Tests/ZipTest/ZipTest.Target.cs @@ -0,0 +1,15 @@ +namespace LunaBuild.Core.Targets; + +public sealed class ZipTestTargetRules : TargetRules +{ + public ZipTestTargetRules() : base("ZipTest", "Tests/ZipTest", "Tests/ZipTest/ZipTest.Target.cs") + { + SupportedPlatforms(BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Linux); + Category = BuildTargetCategory.Tests; + Kind = BuildTargetKind.Executable; + Headers("Source/*.hpp"); + MetaHeaders("Source/TestStream.hpp"); + Sources("Source/*.cpp"); + DependsOn("Runtime", "Zip"); + } +} diff --git a/ThirdParty.Target.cs b/ThirdParty.Target.cs index 84b496f6..984f7203 100644 --- a/ThirdParty.Target.cs +++ b/ThirdParty.Target.cs @@ -1,5 +1,78 @@ +using System.Text.Json; + namespace LunaBuild.Core.Targets; +internal static class SourceSdkPaths +{ + public static string DirectoryFor(BuildWorkspace workspace, string name) + { + using var recipes = JsonDocument.Parse(File.ReadAllText(workspace.ResolveRepositoryPath("Tools/LunaSetup/SourceSdks.json"))); + var version = recipes.RootElement.GetProperty(name).GetProperty("version").GetString(); + return "SDKs/" + name + "-" + version; + } + + public static string[] SourcesFor(BuildWorkspace workspace, string name) + { + return File.ReadAllLines(workspace.ResolveRepositoryPath("Tools/LunaSetup/SourceSdks/" + name + "/Sources.txt")); + } +} + +public sealed class LibZipTargetRules : TargetRules +{ + public LibZipTargetRules() : base("libzip", ".", "ThirdParty.Target.cs") + { + Kind = BuildTargetKind.StaticLibrary; + DependsOn("zlib"); + } + + protected override void Configure(BuildWorkspace workspace, BuildOptions options) + { + var directory = SourceSdkPaths.DirectoryFor(workspace, "libzip"); + Headers(directory + "/*.h", directory + "/lib/*.h"); + IncludeDirectories(directory, directory + "/lib"); + PublicIncludeDirectories(directory, directory + "/lib"); + foreach(var source in SourceSdkPaths.SourcesFor(workspace, "libzip")) + { + Sources(directory + "/lib/" + source); + } + if(Platform == BuildPlatform.Windows) + { + Defines("WIN32_LEAN_AND_MEAN", "_CRT_SECURE_NO_WARNINGS"); + foreach(var source in new[] { "zip_source_file_win32.c", "zip_source_file_win32_named.c", + "zip_source_file_win32_utf16.c", "zip_source_file_win32_utf8.c", + "zip_source_file_win32_ansi.c", "zip_random_win32.c" }) + { + Sources(directory + "/lib/" + source); + } + SystemLibraries("advapi32"); + } + else + { + Defines("_FILE_OFFSET_BITS=64"); + Sources(directory + "/lib/zip_source_file_stdio_named.c", directory + "/lib/zip_random_unix.c"); + } + } +} + +public sealed class ZlibTargetRules : TargetRules +{ + public ZlibTargetRules() : base("zlib", ".", "ThirdParty.Target.cs") + { + Kind = BuildTargetKind.StaticLibrary; + } + + protected override void Configure(BuildWorkspace workspace, BuildOptions options) + { + var directory = SourceSdkPaths.DirectoryFor(workspace, "zlib"); + Headers(directory + "/*.h"); + PublicIncludeDirectories(directory); + foreach(var source in SourceSdkPaths.SourcesFor(workspace, "zlib")) + { + Sources(directory + "/" + source); + } + } +} + public sealed class StbTargetRules : TargetRules { public StbTargetRules() diff --git a/Tools/CPPSL/src/CPPSL.Cli/CPPSL.Cli.csproj b/Tools/CPPSL/src/CPPSL.Cli/CPPSL.Cli.csproj index 78d5bebb..03547b50 100644 --- a/Tools/CPPSL/src/CPPSL.Cli/CPPSL.Cli.csproj +++ b/Tools/CPPSL/src/CPPSL.Cli/CPPSL.Cli.csproj @@ -2,6 +2,7 @@ Exe net9.0 + Major enable enable cppslc diff --git a/Tools/CPPSL/tests/CPPSL.SmokeTests/CPPSL.SmokeTests.csproj b/Tools/CPPSL/tests/CPPSL.SmokeTests/CPPSL.SmokeTests.csproj index 0d9a4971..339456e8 100644 --- a/Tools/CPPSL/tests/CPPSL.SmokeTests/CPPSL.SmokeTests.csproj +++ b/Tools/CPPSL/tests/CPPSL.SmokeTests/CPPSL.SmokeTests.csproj @@ -2,6 +2,7 @@ Exe net9.0 + Major enable enable false diff --git a/Tools/LunaBuild/docs/TargetRules.md b/Tools/LunaBuild/docs/TargetRules.md index 3d5b6b71..d7aa5c0a 100644 --- a/Tools/LunaBuild/docs/TargetRules.md +++ b/Tools/LunaBuild/docs/TargetRules.md @@ -116,6 +116,7 @@ Kind = BuildTargetKind.Executable; Current common kinds: - `SharedLibrary`: Luna SDK modules such as `Runtime`, `RHI`, `Image`. +- `StaticLibrary`: An archive of the target's own object files, including when the overall build uses shared libraries. Its library dependencies are forwarded to consumers. `SharedLibrary` targets still follow the global `--shared`/`--static` selection. - `Executable`: command-line tools and tests that produce a native executable such as `.exe` on Windows. - `Application`: app-style targets for Windows, macOS, iOS and Android. @@ -227,6 +228,19 @@ SupportedPlatforms(BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Lin `inspect` and graph generation filter unsupported targets before dependency resolution. +## Compiler Options + +MSVC receives `/utf-8` by default so both the source and execution character +sets use UTF-8. A target can explicitly opt out when it must preserve the MSVC +code-page behavior: + +```csharp +MsvcUtf8(false); +``` + +This option only affects MSVC compilation. Changing it is part of the compile +action identity and therefore invalidates previously compiled object files. + ## Project Build Properties Project-specific build switches should be declared in a `*.Project.cs` file, diff --git a/Tools/LunaBuild/src/LunaBuild.Cli/LunaBuild.Cli.csproj b/Tools/LunaBuild/src/LunaBuild.Cli/LunaBuild.Cli.csproj index 266860f3..438b9452 100644 --- a/Tools/LunaBuild/src/LunaBuild.Cli/LunaBuild.Cli.csproj +++ b/Tools/LunaBuild/src/LunaBuild.Cli/LunaBuild.Cli.csproj @@ -2,6 +2,7 @@ Exe net9.0 + Major enable enable lunabuild diff --git a/Tools/LunaBuild/src/LunaBuild.Core/BuildTargetDefinition.cs b/Tools/LunaBuild/src/LunaBuild.Core/BuildTargetDefinition.cs index 6e8cd6cd..9068435e 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/BuildTargetDefinition.cs +++ b/Tools/LunaBuild/src/LunaBuild.Core/BuildTargetDefinition.cs @@ -69,6 +69,7 @@ public sealed record BuildTargetDefinition( BuildTargetKind Kind, BuildTargetCategory Category, string? MsvcRuntimeLibrary, + bool EnableMsvcUtf8, bool EnableRtti, string? DotNetProjectFile, string? DotNetOutputFile, diff --git a/Tools/LunaBuild/src/LunaBuild.Core/CppTargetGraphGenerator.cs b/Tools/LunaBuild/src/LunaBuild.Core/CppTargetGraphGenerator.cs index 88c3add4..1007402d 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/CppTargetGraphGenerator.cs +++ b/Tools/LunaBuild/src/LunaBuild.Core/CppTargetGraphGenerator.cs @@ -245,7 +245,7 @@ public TargetBuildOutputs AddTarget(string targetName) var explicitLinkInputIds = target.LinkLibraryFiles .Select(path => AddFileReferenceNode(path)) .ToArray(); - var sideOutputIds = GetLinkSideOutputs(_workspace, _options, binaryPath) + var sideOutputIds = GetLinkSideOutputs(_workspace, _options, target, binaryPath) .Select(path => { var id = BuildGraphIds.File(_workspace.ToRepositoryRelativePath(path)); @@ -272,19 +272,27 @@ public TargetBuildOutputs AddTarget(string targetName) .Distinct(StringComparer.OrdinalIgnoreCase) .Order(StringComparer.OrdinalIgnoreCase) .ToArray(); - var linkDependencyIds = objectIds.Concat(dependencyTargetIds).Concat(dependencyLinkInputIds).Concat(explicitLinkInputIds).ToArray(); + var isStaticArchive = !target.Kind.ProducesNativeExecutable() && !IsSharedLibrary(_options, target); + // Static archive contents are a build-system invariant, not a target + // option: an archive contains only object files produced by that target. + var linkInputIds = isStaticArchive + ? objectIds.ToArray() + : objectIds.Concat(dependencyLinkInputIds).Concat(explicitLinkInputIds).ToArray(); + var linkDependencyIds = isStaticArchive + ? objectIds.ToArray() + : objectIds.Concat(dependencyTargetIds).Concat(dependencyLinkInputIds).Concat(explicitLinkInputIds).ToArray(); AddNode(new BuildGraphNode( Id: binaryId, Kind: BuildGraphNodeKind.File, Path: _workspace.ToRepositoryRelativePath(binaryPath), - Command: BuildLinkCommandDescription(_workspace, _options, target, dependencyFrameworks, binaryPath, objectIds.Concat(dependencyLinkInputIds).Concat(explicitLinkInputIds).ToArray()), + Command: BuildLinkCommandDescription(_workspace, _options, target, dependencyFrameworks, binaryPath, linkInputIds), Dependencies: linkDependencyIds, OrderOnlyDependencies: Array.Empty(), Outputs: sideOutputIds, Depfiles: Array.Empty())); var runtimeFileIds = AddRuntimeFileNodes(target, dependencyOutputs.SelectMany(output => output.RuntimeFiles), binaryPath); - var linkInputId = !target.Kind.ProducesNativeExecutable() && _options.Shared && _options.Platform == BuildPlatform.Windows + var linkInputId = IsSharedLibrary(_options, target) && _options.Platform == BuildPlatform.Windows ? BuildGraphIds.File(_workspace.ToRepositoryRelativePath(Path.ChangeExtension(binaryPath, ".lib"))) : binaryId; var targetId = BuildGraphIds.Target(target.QualifiedName); @@ -293,7 +301,10 @@ public TargetBuildOutputs AddTarget(string targetName) Kind: BuildGraphNodeKind.Virtual, Path: _workspace.ToRepositoryRelativePath(target.Directory), Command: BuildTargetCommandDescription(_workspace, _options, target, sourceFiles.Length), - Dependencies: new[] { binaryId }.Concat(runtimeFileIds).ToArray(), + Dependencies: new[] { binaryId } + .Concat(isStaticArchive ? dependencyTargetIds : Array.Empty()) + .Concat(runtimeFileIds) + .ToArray(), OrderOnlyDependencies: Array.Empty(), Outputs: Array.Empty(), Depfiles: Array.Empty())); @@ -303,6 +314,7 @@ public TargetBuildOutputs AddTarget(string targetName) binaryId, new[] { linkInputId } .Concat(dependencyLinkInputIds) + .Concat(isStaticArchive ? explicitLinkInputIds : Array.Empty()) .Distinct(StringComparer.Ordinal) .ToArray(), target.Options.GlobalIncludeDirectories @@ -327,7 +339,7 @@ public TargetBuildOutputs AddTarget(string targetName) .Concat(dependencyFrameworks) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(), - (!target.Kind.ProducesNativeExecutable() && _options.Shared + (IsSharedLibrary(_options, target) ? new[] { binaryPath } : Array.Empty()) .Concat(target.RuntimeFiles) @@ -1192,7 +1204,7 @@ private static string GetTargetBinaryPath(BuildWorkspace workspace, BuildOptions ? $"lib{target.Name}.so" : target.Kind.ProducesNativeExecutable() ? $"{target.Name}{ExecutableExtension(options.Platform)}" - : $"{options.LibraryPrefix}{target.Name}{(options.Shared ? SharedLibraryExtension(options.Platform) : StaticLibraryExtension(options.Platform))}"; + : $"{options.LibraryPrefix}{target.Name}{(IsSharedLibrary(options, target) ? SharedLibraryExtension(options.Platform) : StaticLibraryExtension(options.Platform))}"; return Path.Combine( GetTargetConfigurationDirectory(target, options), "bin", @@ -1262,9 +1274,14 @@ private static string GetTargetConfigurationDirectory(BuildTargetDefinition targ return target.IsHostProject ? directory : Path.Combine(directory, target.ConfigurationId); } - private static IReadOnlyList GetLinkSideOutputs(BuildWorkspace workspace, BuildOptions options, string binaryPath) + private static bool IsSharedLibrary(BuildOptions options, BuildTargetDefinition target) + { + return target.Kind == BuildTargetKind.SharedLibrary && options.Shared; + } + + private static IReadOnlyList GetLinkSideOutputs(BuildWorkspace workspace, BuildOptions options, BuildTargetDefinition target, string binaryPath) { - if(options.Shared && options.Platform == BuildPlatform.Windows && !binaryPath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + if(IsSharedLibrary(options, target) && options.Platform == BuildPlatform.Windows) { return new[] { Path.ChangeExtension(binaryPath, ".lib") }; } @@ -1331,6 +1348,10 @@ private static string BuildCompileCommandDescription( { lines.Add($"runtime={target.MsvcRuntimeLibrary}"); } + if(options.Platform == BuildPlatform.Windows) + { + lines.Add($"msvc_utf8={target.EnableMsvcUtf8}"); + } lines.AddRange(target.Defines.Select(define => $"define={define}")); lines.AddRange(dependencyOutputs .SelectMany(output => output.PublicDefines) @@ -1622,7 +1643,7 @@ private static string BuildLinkCommandDescription( { var actionKind = target.Kind.ProducesNativeExecutable() ? IsAndroidNativeActivityLibrary(options, target) ? "cpp.link.shared" : "cpp.link.executable" - : options.Shared ? "cpp.link.shared" : "cpp.link.static"; + : IsSharedLibrary(options, target) ? "cpp.link.shared" : "cpp.link.static"; var lines = new List { $"kind={actionKind}", diff --git a/Tools/LunaBuild/src/LunaBuild.Core/LunaBuild.Core.csproj b/Tools/LunaBuild/src/LunaBuild.Core/LunaBuild.Core.csproj index 58b285ae..df9fd9a3 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/LunaBuild.Core.csproj +++ b/Tools/LunaBuild/src/LunaBuild.Core/LunaBuild.Core.csproj @@ -5,4 +5,7 @@ enable false + + + diff --git a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/CppActionExecutor.cs b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/CppActionExecutor.cs index cdcf2640..194a2713 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/CppActionExecutor.cs +++ b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/CppActionExecutor.cs @@ -6,7 +6,8 @@ namespace LunaBuild.Core.MakeSystem; public sealed class CppActionExecutor : IMakeActionExecutor { - private readonly Lazy _msvcToolchain = new(MsvcToolchainLocator.Locate); + private readonly object _msvcToolchainLock = new(); + private Task? _msvcToolchainTask; private readonly ConcurrentDictionary> _appleToolchains = new(StringComparer.OrdinalIgnoreCase); private readonly Lazy _androidToolchain = new(() => AndroidNdkToolchainLocator.Locate()); private readonly TimeSpan _actionTimeout; @@ -32,6 +33,20 @@ public string GetDescription(MakeActionContext context) }; } + public async Task InitializeAsync(IReadOnlyList actions, CancellationToken cancellationToken) + { + var windowsAction = actions.FirstOrDefault(action => action.Options.Platform == BuildPlatform.Windows); + if(windowsAction is null) + { + return; + } + if(windowsAction.Workspace.OptionsHostPlatform() != BuildPlatform.Windows) + { + throw new MakeSystemException("Windows/MSVC C++ actions require a Windows host."); + } + await GetMsvcToolchainAsync(windowsAction.Workspace, cancellationToken); + } + public Task ExecuteAsync(MakeActionContext context, CancellationToken cancellationToken) { return context.ActionKind switch @@ -99,6 +114,7 @@ private async Task CompileMsvcAsync(MakeActionContext context, CancellationToken throw new MakeSystemException("Windows/MSVC C++ compilation requires a Windows host."); } + var toolchain = await GetMsvcToolchainAsync(context.Workspace, cancellationToken); var payload = ActionPayload.Parse(context.ActionPayload); var source = context.Workspace.ResolveRepositoryPath(payload.Required("source")); var output = context.Workspace.ResolveRepositoryPath(payload.Required("object")); @@ -109,13 +125,13 @@ private async Task CompileMsvcAsync(MakeActionContext context, CancellationToken var args = CppCommandLineBuilder.BuildMsvcCompileArguments(context.Workspace, context.Options, payload); var rsp = WriteResponseFile(context.Workspace, context.Node.Id, "cl", args); - var result = await RunVsToolAsync(context.Workspace, _msvcToolchain.Value.ClExe, rsp, cancellationToken); + var result = await RunVsToolAsync(context.Workspace, toolchain, toolchain.ClExe, rsp, cancellationToken); if(result.ExitCode != 0) { throw new MakeSystemException(FormatResponseFileFailure( $"C++ compile failed for {source}", context.Workspace.RootDirectory, - _msvcToolchain.Value.ClExe, + toolchain.ClExe, rsp, result.Output)); } @@ -185,6 +201,7 @@ private async Task CompileResourceAsync(MakeActionContext context, CancellationT throw new MakeSystemException("Windows resource compilation requires a Windows/MSVC host and target."); } + var toolchain = await GetMsvcToolchainAsync(context.Workspace, cancellationToken); var payload = ActionPayload.Parse(context.ActionPayload); var source = context.Workspace.ResolveRepositoryPath(payload.Required("source")); var output = context.Workspace.ResolveRepositoryPath(payload.Required("output")); @@ -202,13 +219,13 @@ private async Task CompileResourceAsync(MakeActionContext context, CancellationT args.Add(Quote(source)); var rsp = WriteResponseFile(context.Workspace, context.Node.Id, "rc", args); - var result = await RunVsToolAsync(context.Workspace, "rc.exe", rsp, cancellationToken); + var result = await RunVsToolAsync(context.Workspace, toolchain, toolchain.RcExe, rsp, cancellationToken); if(result.ExitCode != 0) { throw new MakeSystemException(FormatResponseFileFailure( $"Resource compile failed for {source}", context.Workspace.RootDirectory, - "rc.exe", + toolchain.RcExe, rsp, result.Output)); } @@ -216,6 +233,7 @@ private async Task CompileResourceAsync(MakeActionContext context, CancellationT private async Task LinkMsvcSharedAsync(MakeActionContext context, CancellationToken cancellationToken) { + var toolchain = await GetMsvcToolchainAsync(context.Workspace, cancellationToken); var payload = ActionPayload.Parse(context.ActionPayload); var output = context.Workspace.ResolveRepositoryPath(payload.Required("output")); Directory.CreateDirectory(Path.GetDirectoryName(output)!); @@ -239,13 +257,13 @@ private async Task LinkMsvcSharedAsync(MakeActionContext context, CancellationTo args.Add($"/implib:{Quote(importLib)}"); var rsp = WriteResponseFile(context.Workspace, context.Node.Id, "link", args); - var result = await RunVsToolAsync(context.Workspace, _msvcToolchain.Value.LinkExe, rsp, cancellationToken); + var result = await RunVsToolAsync(context.Workspace, toolchain, toolchain.LinkExe, rsp, cancellationToken); if(result.ExitCode != 0) { throw new MakeSystemException(FormatResponseFileFailure( $"C++ link failed for {output}", context.Workspace.RootDirectory, - _msvcToolchain.Value.LinkExe, + toolchain.LinkExe, rsp, result.Output)); } @@ -253,6 +271,7 @@ private async Task LinkMsvcSharedAsync(MakeActionContext context, CancellationTo private async Task LinkMsvcStaticAsync(MakeActionContext context, CancellationToken cancellationToken) { + var toolchain = await GetMsvcToolchainAsync(context.Workspace, cancellationToken); var payload = ActionPayload.Parse(context.ActionPayload); var output = context.Workspace.ResolveRepositoryPath(payload.Required("output")); Directory.CreateDirectory(Path.GetDirectoryName(output)!); @@ -265,13 +284,13 @@ private async Task LinkMsvcStaticAsync(MakeActionContext context, CancellationTo args.AddRange(LinkInputPaths(context).Select(Quote)); var rsp = WriteResponseFile(context.Workspace, context.Node.Id, "lib", args); - var result = await RunVsToolAsync(context.Workspace, _msvcToolchain.Value.LibExe, rsp, cancellationToken); + var result = await RunVsToolAsync(context.Workspace, toolchain, toolchain.LibExe, rsp, cancellationToken); if(result.ExitCode != 0) { throw new MakeSystemException(FormatResponseFileFailure( $"C++ static link failed for {output}", context.Workspace.RootDirectory, - _msvcToolchain.Value.LibExe, + toolchain.LibExe, rsp, result.Output)); } @@ -279,6 +298,7 @@ private async Task LinkMsvcStaticAsync(MakeActionContext context, CancellationTo private async Task LinkMsvcExecutableAsync(MakeActionContext context, CancellationToken cancellationToken) { + var toolchain = await GetMsvcToolchainAsync(context.Workspace, cancellationToken); var payload = ActionPayload.Parse(context.ActionPayload); var output = context.Workspace.ResolveRepositoryPath(payload.Required("output")); Directory.CreateDirectory(Path.GetDirectoryName(output)!); @@ -298,13 +318,13 @@ private async Task LinkMsvcExecutableAsync(MakeActionContext context, Cancellati args.AddRange(payload.All("library")); var rsp = WriteResponseFile(context.Workspace, context.Node.Id, "link", args); - var result = await RunVsToolAsync(context.Workspace, _msvcToolchain.Value.LinkExe, rsp, cancellationToken); + var result = await RunVsToolAsync(context.Workspace, toolchain, toolchain.LinkExe, rsp, cancellationToken); if(result.ExitCode != 0) { throw new MakeSystemException(FormatResponseFileFailure( $"C++ executable link failed for {output}", context.Workspace.RootDirectory, - _msvcToolchain.Value.LinkExe, + toolchain.LinkExe, rsp, result.Output)); } @@ -512,10 +532,31 @@ private IEnumerable LinkInputPaths(MakeActionContext context) .Select(node => context.Workspace.ResolveRepositoryPath(node.Path!)); } - private async Task RunVsToolAsync(BuildWorkspace workspace, string tool, string responseFile, CancellationToken cancellationToken) + private Task GetMsvcToolchainAsync(BuildWorkspace workspace, CancellationToken cancellationToken) + { + lock(_msvcToolchainLock) + { + return _msvcToolchainTask ??= MsvcToolchainLocator.LocateAsync( + workspace.RootDirectory, + TimeSpan.FromSeconds(30), + cancellationToken); + } + } + + private Task RunVsToolAsync( + BuildWorkspace workspace, + MsvcToolchain toolchain, + string tool, + string responseFile, + CancellationToken cancellationToken) { - var command = $"call {Quote(_msvcToolchain.Value.VcVarsBat)} >nul && {Quote(tool)} @{Quote(responseFile)}"; - return await ProcessRunner.RunAsync("cmd.exe", $"/d /s /c \"{command}\"", workspace.RootDirectory, _actionTimeout, cancellationToken); + return ProcessRunner.RunAsync( + tool, + new[] { "@" + responseFile }, + workspace.RootDirectory, + _actionTimeout, + cancellationToken, + toolchain.EnvironmentVariables); } private static string FormatResponseFileFailure(string title, string workingDirectory, string tool, string responseFile, string output) diff --git a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/CppCommandLineBuilder.cs b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/CppCommandLineBuilder.cs index 656d8ad3..44cf2924 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/CppCommandLineBuilder.cs +++ b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/CppCommandLineBuilder.cs @@ -66,6 +66,10 @@ public static IReadOnlyList BuildMsvcCompileArguments( "/D_CRT_SECURE_NO_WARNINGS", $"/sourceDependencies {Quote(sourceDependencies)}", }; + if(MsvcUtf8Enabled(payload)) + { + args.Add("/utf-8"); + } if(payload.Required("language").Equals("c", StringComparison.OrdinalIgnoreCase)) { args.Add("/TC"); @@ -97,6 +101,20 @@ public static IReadOnlyList BuildMsvcCompileArguments( return args; } + private static bool MsvcUtf8Enabled(ActionPayload payload) + { + if(!payload.Contains("msvc_utf8")) + { + return true; + } + var value = payload.Required("msvc_utf8"); + if(bool.TryParse(value, out var enabled)) + { + return enabled; + } + throw new MakeSystemException($"Unsupported MSVC UTF-8 option: {value}"); + } + public static IReadOnlyList BuildAppleCompileArguments( BuildWorkspace workspace, BuildOptions options, diff --git a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MakeActionContext.cs b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MakeActionContext.cs index 4155b29e..681d5e68 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MakeActionContext.cs +++ b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MakeActionContext.cs @@ -19,5 +19,7 @@ public interface IMakeActionExecutor string? GetDescription(MakeActionContext context) => context.ActionKind; + Task InitializeAsync(IReadOnlyList actions, CancellationToken cancellationToken) => Task.CompletedTask; + Task ExecuteAsync(MakeActionContext context, CancellationToken cancellationToken); } diff --git a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MakeSystemBackend.cs b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MakeSystemBackend.cs index dfe66e15..6f7cb60a 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MakeSystemBackend.cs +++ b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MakeSystemBackend.cs @@ -106,6 +106,7 @@ public async Task BuildAsync( info.Action = PrepareAction(workspace, graph, validated, info.Node); } var totalTasks = commandsToRun.Count(info => info.Action!.Description is not null); + await InitializeExecutorsAsync(commandsToRun.Select(info => info.Action!), cancellationToken); foreach(var info in buildInfos.Where(info => info.NeedsBuild)) { @@ -123,56 +124,79 @@ public async Task BuildAsync( var finishedNodes = buildInfos.Count(info => !info.NeedsBuild); var executedActions = 0; var nextTaskIndex = 0; - while(finishedNodes < buildInfos.Length) + var ready = new Queue(buildInfos.Where( + info => info.NeedsBuild && info.RemainingDependencies == 0)); + var running = new List<(BuildInfo Info, Task Task)>(_maxParallelism); + using var schedulerCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + try { - cancellationToken.ThrowIfCancellationRequested(); - var ready = buildInfos - .Where(info => info.NeedsBuild && !info.Scheduled && info.RemainingDependencies == 0) - .Take(_maxParallelism) - .ToArray(); - if(ready.Length == 0) - { - throw new MakeSystemException("Internal MakeSystem scheduler deadlock."); - } - - foreach(var info in ready) - { - info.Scheduled = true; - } - - var runningTasks = new List(ready.Length); - foreach(var info in ready) + while(finishedNodes < buildInfos.Length) { - int? taskIndex = null; - if(info.Action?.Description is not null) + schedulerCancellation.Token.ThrowIfCancellationRequested(); + while(ready.Count > 0 && running.Count < _maxParallelism) { - taskIndex = ++nextTaskIndex; + var info = ready.Dequeue(); + info.Scheduled = true; + + int? taskIndex = null; + if(info.Action?.Description is not null) + { + taskIndex = ++nextTaskIndex; + } + var task = ExecuteNodeAsync( + workspace, + validated, + cache, + info, + taskIndex, + totalTasks, + options.Progress, + schedulerCancellation.Token); + running.Add((info, task)); } - runningTasks.Add(ExecuteNodeAsync( - workspace, - validated, - cache, - info, - taskIndex, - totalTasks, - options.Progress, - cancellationToken)); - } - await Task.WhenAll(runningTasks); - foreach(var info in ready) - { - info.Finished = true; - ++finishedNodes; - if(!string.IsNullOrWhiteSpace(info.Node.Command)) + + if(running.Count == 0) { - ++executedActions; + throw new MakeSystemException("Internal MakeSystem scheduler deadlock."); } - foreach(var dependent in info.Dependents) + + await Task.WhenAny(running.Select(entry => entry.Task)); + var completed = running.Where(entry => entry.Task.IsCompleted).ToArray(); + foreach(var entry in completed) { - --dependent.RemainingDependencies; + await entry.Task; + running.Remove(entry); + + var info = entry.Info; + info.Finished = true; + ++finishedNodes; + if(!string.IsNullOrWhiteSpace(info.Node.Command)) + { + ++executedActions; + } + foreach(var dependent in info.Dependents) + { + if(--dependent.RemainingDependencies == 0) + { + ready.Enqueue(dependent); + } + } } } } + catch + { + schedulerCancellation.Cancel(); + try + { + await Task.WhenAll(running.Select(entry => entry.Task)); + } + catch + { + // Preserve the exception that stopped scheduling after all running actions exit. + } + throw; + } cache.Save(); return new MakeSystemResult(orderedNodes.Count, executedActions, UpToDate: false); @@ -320,6 +344,17 @@ private PreparedAction PrepareAction( } } + private static async Task InitializeExecutorsAsync( + IEnumerable actions, + CancellationToken cancellationToken) + { + foreach(var group in actions.GroupBy(action => action.Executor)) + { + cancellationToken.ThrowIfCancellationRequested(); + await group.Key.InitializeAsync(group.Select(action => action.Context).ToArray(), cancellationToken); + } + } + private static string FormatActionContext(MakeActionContext context) { var options = context.Options; @@ -681,5 +716,7 @@ protected KnownActionExecutor(string actionKind) public virtual string? GetDescription(MakeActionContext context) => context.ActionKind; + public virtual Task InitializeAsync(IReadOnlyList actions, CancellationToken cancellationToken) => Task.CompletedTask; + public abstract Task ExecuteAsync(MakeActionContext context, CancellationToken cancellationToken); } diff --git a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MsvcToolchain.cs b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MsvcToolchain.cs index c5912d77..9d952fef 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MsvcToolchain.cs +++ b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/MsvcToolchain.cs @@ -1,17 +1,95 @@ namespace LunaBuild.Core.MakeSystem; -internal sealed record MsvcToolchain(string VcVarsBat, string ClExe, string LinkExe, string LibExe); +using System.Collections.ObjectModel; + +internal sealed record MsvcToolchain( + string VcVarsBat, + string ClExe, + string LinkExe, + string LibExe, + string RcExe, + IReadOnlyDictionary EnvironmentVariables); internal static class MsvcToolchainLocator { - public static MsvcToolchain Locate() + private const string EnvironmentMarker = "__LUNABUILD_MSVC_ENVIRONMENT__"; + + public static async Task LocateAsync( + string workingDirectory, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var vcvars = LocateVcVarsBat(); + var command = $"call {QuoteForCmd(vcvars)} >nul && echo {EnvironmentMarker} && set"; + var result = await ProcessRunner.RunAsync( + "cmd.exe", + $"/d /s /c \"{command}\"", + workingDirectory, + timeout, + cancellationToken); + if(result.ExitCode != 0) + { + throw new MakeSystemException( + $"Failed to initialize the MSVC environment with `{vcvars}` (exit code {result.ExitCode})." + + Environment.NewLine + result.Output.TrimEnd()); + } + + var environmentVariables = ParseEnvironmentOutput(result.Output); + var vcToolsInstallDir = RequireEnvironmentVariable(environmentVariables, "VCToolsInstallDir", vcvars); + var windowsSdkBin = RequireEnvironmentVariable(environmentVariables, "WindowsSdkVerBinPath", vcvars); + var vcBin = Path.Combine(vcToolsInstallDir, "bin", "Hostx64", "x64"); + return new MsvcToolchain( + vcvars, + RequireTool(Path.Combine(vcBin, "cl.exe"), "C++ compiler"), + RequireTool(Path.Combine(vcBin, "link.exe"), "linker"), + RequireTool(Path.Combine(vcBin, "lib.exe"), "library manager"), + RequireTool(Path.Combine(windowsSdkBin, "x64", "rc.exe"), "resource compiler"), + environmentVariables); + } + + internal static IReadOnlyDictionary ParseEnvironmentOutput(string output) + { + var environmentVariables = new Dictionary(StringComparer.OrdinalIgnoreCase); + var foundMarker = false; + foreach(var rawLine in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.TrimEnd(); + if(!foundMarker) + { + foundMarker = line.Equals(EnvironmentMarker, StringComparison.Ordinal); + continue; + } + + var separator = line.IndexOf('='); + if(separator <= 0) + { + continue; + } + var name = line[..separator]; + var value = line[(separator + 1)..]; + if(!environmentVariables.TryAdd(name, value) && name.Equals("PATH", StringComparison.Ordinal)) + { + // Windows environments may contain both PATH and Path. vcvars updates + // the canonical uppercase entry, so it must win over a stale alias. + environmentVariables[name] = value; + } + } + + if(!foundMarker || environmentVariables.Count == 0) + { + throw new MakeSystemException("vcvars64.bat completed without returning an MSVC environment."); + } + return new ReadOnlyDictionary(environmentVariables); + } + + private static string LocateVcVarsBat() { foreach(var vsRoot in CandidateVisualStudioRoots().Distinct(StringComparer.OrdinalIgnoreCase)) { - var toolchain = TryCreateToolchain(vsRoot); - if(toolchain is not null) + var vcvars = Path.Combine(vsRoot, "VC", "Auxiliary", "Build", "vcvars64.bat"); + if(File.Exists(vcvars)) { - return toolchain; + return vcvars; } } @@ -19,30 +97,32 @@ public static MsvcToolchain Locate() "Cannot locate MSVC x64 toolchain. Install Visual Studio 2022 or later with the C++ x64/x86 build tools workload."); } - private static MsvcToolchain? TryCreateToolchain(string vsRoot) + private static string RequireEnvironmentVariable( + IReadOnlyDictionary environmentVariables, + string name, + string vcvars) { - var vcvars = Path.Combine(vsRoot, "VC", "Auxiliary", "Build", "vcvars64.bat"); - var toolsRoot = Path.Combine(vsRoot, "VC", "Tools", "MSVC"); - if(!File.Exists(vcvars) || !Directory.Exists(toolsRoot)) + if(environmentVariables.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)) { - return null; + return value; } + throw new MakeSystemException($"`{vcvars}` did not define the required environment variable `{name}`."); + } - var version = Directory.GetDirectories(toolsRoot) - .OrderByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); - if(version is null) + private static string RequireTool(string path, string description) + { + if(File.Exists(path)) { - return null; + return path; } + throw new MakeSystemException($"MSVC {description} was not found: {path}"); + } - var bin = Path.Combine(version, "bin", "Hostx64", "x64"); - var cl = Path.Combine(bin, "cl.exe"); - var link = Path.Combine(bin, "link.exe"); - var lib = Path.Combine(bin, "lib.exe"); - return File.Exists(cl) && File.Exists(link) && File.Exists(lib) - ? new MsvcToolchain(vcvars, cl, link, lib) - : null; + private static string QuoteForCmd(string value) + { + return value.Contains(' ') || value.Contains('\t') + ? $"\"{value}\"" + : value; } private static IEnumerable CandidateVisualStudioRoots() diff --git a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/ProcessRunner.cs b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/ProcessRunner.cs index 775eb8fc..e59470b9 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/ProcessRunner.cs +++ b/Tools/LunaBuild/src/LunaBuild.Core/MakeSystem/ProcessRunner.cs @@ -12,9 +12,10 @@ public static async Task RunAsync( string arguments, string workingDirectory, TimeSpan timeout, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + IReadOnlyDictionary? environmentVariables = null) { - return await RunAsync(fileName, arguments, null, workingDirectory, timeout, cancellationToken); + return await RunAsync(fileName, arguments, null, workingDirectory, timeout, cancellationToken, environmentVariables); } public static async Task RunAsync( @@ -22,9 +23,10 @@ public static async Task RunAsync( IReadOnlyList arguments, string workingDirectory, TimeSpan timeout, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + IReadOnlyDictionary? environmentVariables = null) { - return await RunAsync(fileName, null, arguments, workingDirectory, timeout, cancellationToken); + return await RunAsync(fileName, null, arguments, workingDirectory, timeout, cancellationToken, environmentVariables); } private static async Task RunAsync( @@ -33,7 +35,8 @@ private static async Task RunAsync( IReadOnlyList? argumentList, string workingDirectory, TimeSpan timeout, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + IReadOnlyDictionary? environmentVariables) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(timeout); @@ -61,21 +64,29 @@ private static async Task RunAsync( { startInfo.Arguments = argumentString ?? string.Empty; } + ApplyEnvironmentVariables(startInfo, environmentVariables); using var process = new Process(); process.StartInfo = startInfo; + var outputLock = new object(); process.OutputDataReceived += (_, e) => { if(e.Data is not null) { - output.AppendLine(e.Data); + lock(outputLock) + { + output.AppendLine(e.Data); + } } }; process.ErrorDataReceived += (_, e) => { if(e.Data is not null) { - output.AppendLine(e.Data); + lock(outputLock) + { + output.AppendLine(e.Data); + } } }; @@ -87,13 +98,41 @@ private static async Task RunAsync( { await process.WaitForExitAsync(timeoutCts.Token); } - catch(OperationCanceledException) when(!cancellationToken.IsCancellationRequested) + catch(OperationCanceledException) { TryKill(process); + if(cancellationToken.IsCancellationRequested) + { + throw; + } throw new TimeoutException($"Process timed out after {timeout.TotalSeconds:0}s: {fileName} {FormatArguments(argumentString, argumentList)}"); } - return new ProcessRunResult(process.ExitCode, output.ToString()); + lock(outputLock) + { + return new ProcessRunResult(process.ExitCode, output.ToString()); + } + } + + internal static void ApplyEnvironmentVariables( + ProcessStartInfo startInfo, + IReadOnlyDictionary? environmentVariables) + { + if(environmentVariables is null) + { + return; + } + + foreach(var variable in environmentVariables) + { + foreach(var existingKey in startInfo.Environment.Keys + .Where(key => key.Equals(variable.Key, StringComparison.OrdinalIgnoreCase)) + .ToArray()) + { + startInfo.Environment.Remove(existingKey); + } + startInfo.Environment[variable.Key] = variable.Value; + } } private static string FormatArguments(string? argumentString, IReadOnlyList? argumentList) @@ -112,7 +151,7 @@ private static void TryKill(Process process) } catch { - // Best effort cleanup after timeout. + // Best effort cleanup after timeout or build cancellation. } } } diff --git a/Tools/LunaBuild/src/LunaBuild.Core/TargetRules.cs b/Tools/LunaBuild/src/LunaBuild.Core/TargetRules.cs index b1685c82..a0c5db40 100644 --- a/Tools/LunaBuild/src/LunaBuild.Core/TargetRules.cs +++ b/Tools/LunaBuild/src/LunaBuild.Core/TargetRules.cs @@ -25,6 +25,7 @@ public abstract class TargetRules private BuildWorkspace? _currentWorkspace; private BuildOptions? _currentOptions; private string? _msvcRuntimeLibrary; + private bool _enableMsvcUtf8 = true; private bool _enableRtti = true; private string? _appleBundleIdentifier; private string? _appleBundleDisplayName; @@ -173,6 +174,11 @@ protected void MsvcRuntimeLibrary(string runtimeLibrary) _msvcRuntimeLibrary = runtimeLibrary; } + protected void MsvcUtf8(bool enabled) + { + _enableMsvcUtf8 = enabled; + } + protected void Rtti(bool enabled) { _enableRtti = enabled; @@ -335,6 +341,7 @@ public virtual BuildTargetDefinition ToDefinition( Kind: Kind, Category: Category, MsvcRuntimeLibrary: _msvcRuntimeLibrary, + EnableMsvcUtf8: _enableMsvcUtf8, EnableRtti: _enableRtti, DotNetProjectFile: _dotNetProject is null ? null @@ -390,6 +397,7 @@ private TargetRuleState CaptureState() SupportedPlatforms: _supportedPlatforms.ToArray(), Category: Category, MsvcRuntimeLibrary: _msvcRuntimeLibrary, + EnableMsvcUtf8: _enableMsvcUtf8, EnableRtti: _enableRtti, AppleBundleIdentifier: _appleBundleIdentifier, AppleBundleDisplayName: _appleBundleDisplayName, @@ -426,6 +434,7 @@ private void RestoreState(TargetRuleState state) } Category = state.Category; _msvcRuntimeLibrary = state.MsvcRuntimeLibrary; + _enableMsvcUtf8 = state.EnableMsvcUtf8; _enableRtti = state.EnableRtti; _appleBundleIdentifier = state.AppleBundleIdentifier; _appleBundleDisplayName = state.AppleBundleDisplayName; @@ -471,6 +480,7 @@ private sealed record TargetRuleState( BuildPlatform[] SupportedPlatforms, BuildTargetCategory Category, string? MsvcRuntimeLibrary, + bool EnableMsvcUtf8, bool EnableRtti, string? AppleBundleIdentifier, string? AppleBundleDisplayName, diff --git a/Tools/LunaBuild/tests/LunaBuild.Core.Tests/Program.cs b/Tools/LunaBuild/tests/LunaBuild.Core.Tests/Program.cs index 77a61e29..6f079172 100644 --- a/Tools/LunaBuild/tests/LunaBuild.Core.Tests/Program.cs +++ b/Tools/LunaBuild/tests/LunaBuild.Core.Tests/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using LunaBuild.Core; using LunaBuild.Core.MakeSystem; @@ -17,6 +18,8 @@ private static int Main() ("import cycles are rejected", ImportCycle), ("sibling project targets are not visible", SiblingVisibility), ("native link configurations must be compatible", LinkCompatibility), + ("static archives contain only their own objects", StaticArchivesContainOnlyOwnObjects), + ("explicit static targets remain static in shared builds", ExplicitStaticTargetsInSharedBuilds), ("shared dependencies already beside consumers are not copied onto themselves", SameDirectoryRuntimeStaging), ("symlink imports use canonical project identity", CanonicalSymlinkIdentity), ("rule edits invalidate the compiled rules cache", RulesCacheInvalidation), @@ -25,8 +28,12 @@ private static int Main() ("dotnet builds honor the requested configuration", DotNetBuildConfiguration), ("cpp actions describe their input and output files", CppActionDescriptions), ("make system reports indexed task progress", IndexedTaskProgress), + ("make system initializes each action executor once", ActionExecutorInitialization), + ("make system releases dependents without batch barriers", AsyncDagScheduling), ("aggregate actions execute without task progress", AggregateActionsDoNotReportProgress), ("application targets produce native executable graphs", ApplicationTargetGraph), + ("MSVC UTF-8 compilation is enabled by default and configurable", MsvcUtf8CompilationOption), + ("MSVC environment capture normalizes duplicate path keys", MsvcEnvironmentCapture), ("apple deployment settings affect layout and commands", AppleDeploymentSettings), }; @@ -407,7 +414,7 @@ private static void DotNetBuildConfiguration() { "sdk": { "version": "9.0.100", - "rollForward": "latestFeature" + "rollForward": "major" } } """); @@ -656,6 +663,255 @@ private static void ApplicationTargetGraph() True(windowsLink.Command!.Contains("application=true", StringComparison.Ordinal), "Windows application link marker"); } + private static void AsyncDagScheduling() + { + using var scope = new TestScope(); + var root = scope.Project("AsyncDagScheduling"); + var workspace = new BuildWorkspace(root); + var options = BuildOptions.HostDefault(); + var slow = TestFileActionNode("out/slow.txt", "slow", delayMilliseconds: 300); + var fast = TestFileActionNode("out/fast.txt", "fast", delayMilliseconds: 30); + var dependent = TestFileActionNode("out/dependent.txt", "dependent", delayMilliseconds: 10) with + { + Dependencies = new[] { fast.Id }, + }; + var graph = new BuildGraph(2, options, new[] { slow, fast, dependent }, new[] { slow.Id, dependent.Id }); + + var events = new List(); + var eventsLock = new object(); + void Record(string value) + { + lock(eventsLock) + { + events.Add(value); + } + } + + var executor = new TestFileActionExecutor( + onStarted: description => Record("start:" + description), + onFinished: description => Record("finish:" + description)); + var makeSystem = new MakeSystemBackend(new[] { executor }, maxParallelism: 2); + makeSystem.BuildAsync(workspace, graph).GetAwaiter().GetResult(); + + int EventIndex(string value) + { + lock(eventsLock) + { + return events.IndexOf(value); + } + } + + True(EventIndex("finish:fast") < EventIndex("start:dependent"), "dependent waits for its own prerequisite"); + True(EventIndex("start:dependent") < EventIndex("finish:slow"), "dependent starts before an unrelated slow task finishes"); + } + + private static void StaticArchivesContainOnlyOwnObjects() + { + using var scope = new TestScope(); + var root = scope.Project("StaticArchivesContainOnlyOwnObjects"); + Write(root, "leaf.cpp", "int leaf() { return 1; }"); + Write(root, "middle.cpp", "int middle() { return leaf(); }"); + Write(root, "app.cpp", "int main() { return middle(); }"); + + var workspace = new BuildWorkspace(root); + var options = BuildOptions.HostDefault() with { Shared = false }; + var leaf = new NativeGraphTargetRules( + "Leaf", + "leaf.cpp", + BuildTargetKind.StaticLibrary) + .ToDefinition(workspace, options, "Host", "static", isHostProject: true); + var middle = new NativeGraphTargetRules( + "Middle", + "middle.cpp", + BuildTargetKind.StaticLibrary, + dependencies: new[] { leaf.QualifiedName }) + .ToDefinition(workspace, options, "Host", "static", isHostProject: true); + var app = new NativeGraphTargetRules( + "App", + "app.cpp", + BuildTargetKind.Executable, + dependencies: new[] { middle.QualifiedName }) + .ToDefinition(workspace, options, "Host", "static", isHostProject: true); + + var graph = new CppTargetGraphGenerator().Generate( + workspace, + options, + new[] { leaf, middle, app }, + app.QualifiedName); + var leafArchive = LinkNode(graph, "cpp.link.static", leaf.QualifiedName); + var middleArchive = LinkNode(graph, "cpp.link.static", middle.QualifiedName); + var executable = LinkNode(graph, "cpp.link.executable", app.QualifiedName); + var leafObject = CompileNode(graph, leaf.QualifiedName); + var middleObject = CompileNode(graph, middle.QualifiedName); + var appObject = CompileNode(graph, app.QualifiedName); + + SequenceEqual(new[] { leafObject.Id }, LinkInputs(leafArchive), "leaf archive inputs"); + SequenceEqual(new[] { middleObject.Id }, LinkInputs(middleArchive), "middle archive inputs"); + SequenceEqual(new[] { leafObject.Id }, leafArchive.Dependencies, "leaf archive dependencies"); + SequenceEqual(new[] { middleObject.Id }, middleArchive.Dependencies, "middle archive dependencies"); + + var middleTarget = graph.Nodes.Single(node => node.Id == BuildGraphIds.Target(middle.QualifiedName)); + True(middleTarget.Dependencies.Contains(BuildGraphIds.Target(leaf.QualifiedName), StringComparer.Ordinal), + "middle target waits for its dependency target without adding it to the archive"); + + var expectedExecutableInputs = new[] { appObject.Id, middleArchive.Id, leafArchive.Id }; + SequenceEqual( + expectedExecutableInputs.Order(StringComparer.OrdinalIgnoreCase), + LinkInputs(executable).Order(StringComparer.OrdinalIgnoreCase), + "final executable receives dependent archives rather than dependent objects"); + SequenceEqual( + expectedExecutableInputs, + executable.Dependencies.Where(id => id != BuildGraphIds.Target(middle.QualifiedName)), + "final executable dependency order"); + } + + private static void ExplicitStaticTargetsInSharedBuilds() + { + using var scope = new TestScope(); + var root = scope.Project("ExplicitStaticTargetsInSharedBuilds"); + Write(root, "dependency.c", "int dependency(void) { return 1; }"); + Write(root, "module.cpp", "int module() { return 1; }"); + var workspace = new BuildWorkspace(root); + foreach(var platform in new[] { BuildPlatform.Windows, BuildPlatform.MacOS, BuildPlatform.Linux }) + { + var options = BuildOptions.HostDefault() with { Shared = true, Platform = platform }; + var dependency = new NativeGraphTargetRules("Dependency", "dependency.c", BuildTargetKind.StaticLibrary) + .ToDefinition(workspace, options, "Host", "shared", isHostProject: true); + var module = new NativeGraphTargetRules("Module", "module.cpp", BuildTargetKind.SharedLibrary, + dependencies: new[] { dependency.QualifiedName }) + .ToDefinition(workspace, options, "Host", "shared", isHostProject: true); + var graph = new CppTargetGraphGenerator().Generate(workspace, options, new[] { dependency, module }, module.QualifiedName); + var archive = LinkNode(graph, "cpp.link.static", dependency.QualifiedName); + var library = LinkNode(graph, "cpp.link.shared", module.QualifiedName); + True(archive.Path!.EndsWith(platform == BuildPlatform.Windows ? ".lib" : ".a", StringComparison.Ordinal), + "explicit static target has an archive extension"); + True(archive.Outputs.Count == 0, "static archive has no import-library side output"); + True(LinkInputs(library).Contains(archive.Id, StringComparer.Ordinal), "shared module links its static dependency"); + True(library.Outputs.Count == (platform == BuildPlatform.Windows ? 1 : 0), "shared module keeps platform side outputs"); + } + } + + private static BuildGraphNode LinkNode(BuildGraph graph, string kind, string targetName) + { + return graph.Nodes.Single(node => node.Command is not null && + BuildActionKind.Extract(node.Command) == kind && + node.Command.Split('\n').Contains("target=" + targetName, StringComparer.Ordinal)); + } + + private static BuildGraphNode CompileNode(BuildGraph graph, string targetName) + { + return graph.Nodes.Single(node => node.Command is not null && + BuildActionKind.Extract(node.Command) == "cpp.compile" && + node.Command.Split('\n').Contains("target=" + targetName, StringComparer.Ordinal)); + } + + private static IReadOnlyList LinkInputs(BuildGraphNode node) + { + return node.Command!.Split('\n') + .Where(line => line.StartsWith("input=", StringComparison.Ordinal)) + .Select(line => line["input=".Length..]) + .ToArray(); + } + + private static void ActionExecutorInitialization() + { + using var scope = new TestScope(); + var root = scope.Project("ActionExecutorInitialization"); + var workspace = new BuildWorkspace(root); + var options = BuildOptions.HostDefault(); + var first = TestFileActionNode("out/first.txt", "first", delayMilliseconds: 5); + var second = TestFileActionNode("out/second.txt", "second", delayMilliseconds: 5); + var graph = new BuildGraph(2, options, new[] { first, second }, new[] { first.Id, second.Id }); + var initializeCount = 0; + var initialized = false; + var executor = new TestFileActionExecutor( + onStarted: _ => True(initialized, "action starts after executor initialization"), + onInitialize: actions => + { + ++initializeCount; + Equal(2, actions.Count, "initialized action count"); + initialized = true; + }); + var makeSystem = new MakeSystemBackend(new[] { executor }, maxParallelism: 2); + + makeSystem.BuildAsync(workspace, graph).GetAwaiter().GetResult(); + Equal(1, initializeCount, "single initialization for multiple actions"); + + makeSystem.BuildAsync(workspace, graph).GetAwaiter().GetResult(); + Equal(1, initializeCount, "up-to-date build skips initialization"); + } + + private static void MsvcEnvironmentCapture() + { + var environment = MsvcToolchainLocator.ParseEnvironmentOutput(""" + ignored setup output + __LUNABUILD_MSVC_ENVIRONMENT__ + PATH=C:\MSVC\bin;C:\Windows + Path=C:\stale + INCLUDE=C:\SDK\include=value + """); + Equal("C:\\MSVC\\bin;C:\\Windows", environment["PATH"], "uppercase vcvars PATH wins"); + Equal("C:\\SDK\\include=value", environment["INCLUDE"], "environment value preserves equals signs"); + + var reversed = MsvcToolchainLocator.ParseEnvironmentOutput(""" + __LUNABUILD_MSVC_ENVIRONMENT__ + Path=C:\stale + PATH=C:\MSVC\bin + """); + Equal("C:\\MSVC\\bin", reversed["PATH"], "uppercase PATH wins regardless of output order"); + + var startInfo = new ProcessStartInfo(); + ProcessRunner.ApplyEnvironmentVariables(startInfo, environment); + var pathKeys = startInfo.Environment.Keys + .Where(key => key.Equals("PATH", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + Equal(1, pathKeys.Length, "child process receives one normalized PATH entry"); + Equal("C:\\MSVC\\bin;C:\\Windows", startInfo.Environment[pathKeys[0]], "child process receives captured PATH"); + } + + private static void MsvcUtf8CompilationOption() + { + using var scope = new TestScope(); + var root = scope.Project("MsvcUtf8"); + Write(root, "app.cpp", "const char* text = \"中文\"; int main() { return text[0] == 0; }"); + Write(root, "App.Target.cs", string.Empty); + var workspace = new BuildWorkspace(root); + var options = BuildOptions.HostDefault() with + { + Platform = BuildPlatform.Windows, + Architecture = "x64", + RhiApi = RhiApi.D3D12, + }; + + var defaultTarget = new ApplicationTargetRules().ToDefinition( + workspace, options, "Host", "utf8-default", isHostProject: true); + True(defaultTarget.EnableMsvcUtf8, "MSVC UTF-8 default"); + var defaultGraph = new CppTargetGraphGenerator().Generate( + workspace, options, new[] { defaultTarget }, defaultTarget.QualifiedName); + var defaultCompile = defaultGraph.Nodes.Single( + node => node.Command is not null && BuildActionKind.Extract(node.Command) == "cpp.compile"); + True(defaultCompile.Command!.Contains("msvc_utf8=True", StringComparison.Ordinal), + "enabled MSVC UTF-8 action identity"); + var defaultCommandsPath = Path.Combine(root, "compile_commands.default.json"); + CompileCommandsWriter.Write(workspace, defaultGraph, defaultCommandsPath); + True(File.ReadAllText(defaultCommandsPath).Contains("/utf-8", StringComparison.Ordinal), + "default MSVC UTF-8 compiler argument"); + + var disabledTarget = new ApplicationTargetRules(enableMsvcUtf8: false).ToDefinition( + workspace, options, "Host", "utf8-disabled", isHostProject: true); + True(!disabledTarget.EnableMsvcUtf8, "disabled MSVC UTF-8 target option"); + var disabledGraph = new CppTargetGraphGenerator().Generate( + workspace, options, new[] { disabledTarget }, disabledTarget.QualifiedName); + var disabledCompile = disabledGraph.Nodes.Single( + node => node.Command is not null && BuildActionKind.Extract(node.Command) == "cpp.compile"); + True(disabledCompile.Command!.Contains("msvc_utf8=False", StringComparison.Ordinal), + "disabled MSVC UTF-8 action identity"); + var disabledCommandsPath = Path.Combine(root, "compile_commands.disabled.json"); + CompileCommandsWriter.Write(workspace, disabledGraph, disabledCommandsPath); + True(!File.ReadAllText(disabledCommandsPath).Contains("/utf-8", StringComparison.Ordinal), + "disabled MSVC UTF-8 compiler argument"); + } + private static void AppleDeploymentSettings() { using var scope = new TestScope(); @@ -779,6 +1035,17 @@ private static void Equal(T expected, T actual, string message) } } + private static void SequenceEqual(IEnumerable expected, IEnumerable actual, string message) + { + var expectedArray = expected.ToArray(); + var actualArray = actual.ToArray(); + if(!expectedArray.SequenceEqual(actualArray)) + { + throw new InvalidOperationException( + $"Assertion failed: {message}. Expected `[{string.Join(", ", expectedArray)}]`, got `[{string.Join(", ", actualArray)}]`."); + } + } + private static void Throws(Action action, string messageFragment, string message) where T : Exception { @@ -828,19 +1095,57 @@ public void Dispose() private sealed class ApplicationTargetRules : TargetRules { - public ApplicationTargetRules() + public ApplicationTargetRules(bool? enableMsvcUtf8 = null) : base("App", ".", "App.Target.cs") { Kind = BuildTargetKind.Application; Sources("app.cpp"); + if(enableMsvcUtf8.HasValue) + { + MsvcUtf8(enableMsvcUtf8.Value); + } + } + } + + private sealed class NativeGraphTargetRules : TargetRules + { + public NativeGraphTargetRules( + string name, + string source, + BuildTargetKind kind, + IReadOnlyList? dependencies = null) + : base(name, ".", name + ".Target.cs") + { + Kind = kind; + Sources(source); + if(dependencies is not null) + { + DependsOn(dependencies.ToArray()); + } } } private sealed class TestFileActionExecutor : KnownActionExecutor { - public TestFileActionExecutor() + private readonly Action? _onStarted; + private readonly Action? _onFinished; + private readonly Action>? _onInitialize; + + public TestFileActionExecutor( + Action? onStarted = null, + Action? onFinished = null, + Action>? onInitialize = null) : base("test.file") { + _onStarted = onStarted; + _onFinished = onFinished; + _onInitialize = onInitialize; + } + + public override Task InitializeAsync(IReadOnlyList actions, CancellationToken cancellationToken) + { + _onInitialize?.Invoke(actions); + return Task.CompletedTask; } public override string GetDescription(MakeActionContext context) @@ -850,11 +1155,14 @@ public override string GetDescription(MakeActionContext context) public override async Task ExecuteAsync(MakeActionContext context, CancellationToken cancellationToken) { + var description = RequiredPayloadValue(context.ActionPayload, "description"); var delay = int.Parse(RequiredPayloadValue(context.ActionPayload, "delay")); var output = context.Workspace.ResolveRepositoryPath(RequiredPayloadValue(context.ActionPayload, "output")); + _onStarted?.Invoke(description); await Task.Delay(delay, cancellationToken); Directory.CreateDirectory(Path.GetDirectoryName(output)!); await File.WriteAllTextAsync(output, "generated", cancellationToken); + _onFinished?.Invoke(description); } private static string RequiredPayloadValue(string payload, string name) diff --git a/Tools/LunaProjectGenerator/LunaProjectGenerator.csproj b/Tools/LunaProjectGenerator/LunaProjectGenerator.csproj index 91b0189f..8fb0a107 100644 --- a/Tools/LunaProjectGenerator/LunaProjectGenerator.csproj +++ b/Tools/LunaProjectGenerator/LunaProjectGenerator.csproj @@ -2,6 +2,7 @@ Exe net9.0 + Major enable enable lunaproject diff --git a/Tools/LunaProjectGenerator/ProjectGenerator.cs b/Tools/LunaProjectGenerator/ProjectGenerator.cs index 0d615541..7c659e05 100644 --- a/Tools/LunaProjectGenerator/ProjectGenerator.cs +++ b/Tools/LunaProjectGenerator/ProjectGenerator.cs @@ -231,6 +231,7 @@ private static string RunnerProject(string sdkReference) Exe net9.0 + Major enable enable lunabuild @@ -255,7 +256,7 @@ private static string DotNetSdkSelection() { "sdk": { "version": "9.0.100", - "rollForward": "latestFeature" + "rollForward": "major" } } """ + "\n"; diff --git a/Tools/LunaProjectGenerator/tests/Program.cs b/Tools/LunaProjectGenerator/tests/Program.cs index 65c61439..593a3e43 100644 --- a/Tools/LunaProjectGenerator/tests/Program.cs +++ b/Tools/LunaProjectGenerator/tests/Program.cs @@ -42,7 +42,8 @@ static void CreatesProject() Contains(projectRoot, "SampleApp.Project.cs", "project.ImportProject(\"../../LunaSDK\")"); Contains(projectRoot, "SampleApp.Target.cs", "DependsOn(\"LunaSDK.Runtime\")"); Contains(projectRoot, "LunaBuild.csproj", "../../LunaSDK"); - Contains(projectRoot, "global.json", "\"rollForward\": \"latestFeature\""); + Contains(projectRoot, "LunaBuild.csproj", "Major"); + Contains(projectRoot, "global.json", "\"rollForward\": \"major\""); }); } diff --git a/Tools/LunaSetup/LunaSetup.csproj b/Tools/LunaSetup/LunaSetup.csproj index af69501d..b289aaba 100644 --- a/Tools/LunaSetup/LunaSetup.csproj +++ b/Tools/LunaSetup/LunaSetup.csproj @@ -2,6 +2,7 @@ Exe net9.0 + Major enable enable lunasetup diff --git a/Tools/LunaSetup/Program.cs b/Tools/LunaSetup/Program.cs index 5837f2a4..e752969d 100644 --- a/Tools/LunaSetup/Program.cs +++ b/Tools/LunaSetup/Program.cs @@ -2,6 +2,7 @@ using System.IO.Compression; using System.Net.Http.Headers; using System.Runtime.InteropServices; +using System.Security.Cryptography; return await LunaSetupApp.RunAsync(args); @@ -37,17 +38,23 @@ public static async Task RunAsync(string[] args) var sdksDirectory = Path.Combine(root, "SDKs"); var markerPath = Path.Combine(sdksDirectory, ".luna-sdk-version"); + var downloadDirectory = Path.Combine(root, "build", "LunaSetup"); + Directory.CreateDirectory(downloadDirectory); if(!options.Force && IsSdkReady(sdksDirectory, markerPath, platform)) { Console.WriteLine($"SDKs {SdkVersion}/{platform} already present: {sdksDirectory}"); + await SourceSdkInstaller.InstallAsync(root, sdksDirectory, downloadDirectory, options.Force); return 0; } - var downloadDirectory = Path.Combine(root, "build", "LunaSetup"); - Directory.CreateDirectory(downloadDirectory); var archivePath = Path.Combine(downloadDirectory, $"SDKs-{SdkVersion}-{platform}.zip"); - - var refreshArchive = options.Force || !File.Exists(markerPath) || MissingSdkFiles(sdksDirectory, platform).Count > 0; + var legacyArchivePath = Path.Combine(sdksDirectory, $"SDKs-{SdkVersion}-{platform}.zip"); + if(!options.Force && !IsZipArchiveReady(archivePath) && IsZipArchiveReady(legacyArchivePath)) + { + Console.WriteLine($"Reusing legacy SDK archive: {legacyArchivePath}"); + File.Copy(legacyArchivePath, archivePath, overwrite: true); + } + var refreshArchive = options.Force || !IsZipArchiveReady(archivePath); await DownloadAsync(url, archivePath, refreshArchive); if(options.Force && Directory.Exists(sdksDirectory)) @@ -75,6 +82,7 @@ public static async Task RunAsync(string[] args) ValidateSdkReady(sdksDirectory, platform); WriteMarker(markerPath, platform, url); Console.WriteLine($"SDKs {SdkVersion}/{platform} installed: {sdksDirectory}"); + await SourceSdkInstaller.InstallAsync(root, sdksDirectory, downloadDirectory, options.Force); return 0; } catch(Exception ex) @@ -130,6 +138,20 @@ private static bool IsSdkReady(string sdksDirectory, string markerPath, string p MissingSdkFiles(sdksDirectory, platform).Count == 0; } + private static bool IsZipArchiveReady(string path) + { + if(!File.Exists(path)) return false; + try + { + using var archive = ZipFile.OpenRead(path); + return archive.Entries.Count > 0; + } + catch(InvalidDataException) + { + return false; + } + } + private static void ValidateSdkReady(string sdksDirectory, string platform) { var missingFiles = MissingSdkFiles(sdksDirectory, platform); @@ -185,12 +207,16 @@ private static IReadOnlyList MissingSdkFiles(string sdksDirectory, strin .ToArray(); } - private static async Task DownloadAsync(string url, string archivePath, bool force) + internal static async Task DownloadAsync(string url, string archivePath, bool force, string? sha256 = null) { if(File.Exists(archivePath) && !force) { - Console.WriteLine($"Using cached archive: {archivePath}"); - return; + if(sha256 is null || await MatchesHashAsync(archivePath, sha256)) + { + Console.WriteLine($"Using cached archive: {archivePath}"); + return; + } + Console.WriteLine($"Cached archive checksum mismatch; downloading again: {archivePath}"); } Console.WriteLine($"Downloading {url}"); @@ -201,28 +227,46 @@ private static async Task DownloadAsync(string url, string archivePath, bool for var totalBytes = response.Content.Headers.ContentLength; await using var source = await response.Content.ReadAsStreamAsync(); - await using var destination = File.Create(archivePath); - - var buffer = new byte[1024 * 1024]; - long downloaded = 0; - var stopwatch = Stopwatch.StartNew(); - while(true) + var temporaryPath = archivePath + ".download-" + Guid.NewGuid().ToString("N"); + try { - var read = await source.ReadAsync(buffer); - if(read == 0) + await using(var destination = File.Create(temporaryPath)) { - break; + var buffer = new byte[1024 * 1024]; + long downloaded = 0; + var stopwatch = Stopwatch.StartNew(); + while(true) + { + var read = await source.ReadAsync(buffer); + if(read == 0) break; + await destination.WriteAsync(buffer.AsMemory(0, read)); + downloaded += read; + if(stopwatch.ElapsedMilliseconds >= 1000) + { + PrintProgress(downloaded, totalBytes); + stopwatch.Restart(); + } + } + PrintProgress(downloaded, totalBytes); + Console.WriteLine(); } - await destination.WriteAsync(buffer.AsMemory(0, read)); - downloaded += read; - if(stopwatch.ElapsedMilliseconds >= 1000) + if(sha256 is not null && !await MatchesHashAsync(temporaryPath, sha256)) { - PrintProgress(downloaded, totalBytes); - stopwatch.Restart(); + throw new InvalidDataException($"SHA-256 mismatch for {url}; expected {sha256}."); } + File.Move(temporaryPath, archivePath, overwrite: true); + } + finally + { + if(File.Exists(temporaryPath)) File.Delete(temporaryPath); } - PrintProgress(downloaded, totalBytes); - Console.WriteLine(); + } + + private static async Task MatchesHashAsync(string path, string expected) + { + await using var stream = File.OpenRead(path); + var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream)); + return string.Equals(hash, expected, StringComparison.OrdinalIgnoreCase); } private static void PrintProgress(long downloaded, long? totalBytes) @@ -314,7 +358,18 @@ private static void CopyDirectory(string sourceDirectory, string destinationDire var relative = Path.GetRelativePath(sourceDirectory, file); var destination = Path.Combine(destinationDirectory, relative); Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - File.Copy(file, destination, overwrite: true); + // Some platform SDK files are distributed read-only. Permit setup to + // refresh its own installed files, then restore that attribute. + var readOnly = File.Exists(destination) && (File.GetAttributes(destination) & FileAttributes.ReadOnly) != 0; + if(readOnly) File.SetAttributes(destination, File.GetAttributes(destination) & ~FileAttributes.ReadOnly); + try + { + File.Copy(file, destination, overwrite: true); + } + finally + { + if(readOnly && File.Exists(destination)) File.SetAttributes(destination, File.GetAttributes(destination) | FileAttributes.ReadOnly); + } } } diff --git a/Tools/LunaSetup/README.md b/Tools/LunaSetup/README.md new file mode 100644 index 00000000..989f4efe --- /dev/null +++ b/Tools/LunaSetup/README.md @@ -0,0 +1,50 @@ +# LunaSetup + +Run `setup.sh` on macOS or `setup.bat` on Windows to install the platform SDK +bundle and the source SDKs required by LunaSDK. Both scripts use this .NET tool. +LunaBuild itself does not download dependencies. + +`SourceSdks.json` pins each source SDK's version, archive URL, and SHA-256. +Setup downloads these archives to `build/LunaSetup`, verifies their hashes, and +extracts them into versioned directories under `SDKs`. A partial download or +failed extraction does not replace an installed source SDK. Cached archives are +verified before reuse. Missing installed files cause that source SDK to be +restored from its verified archive. + +Source SDK checks run even when the platform SDK bundle is already installed. +`--force` redownloads and replaces both the platform bundle and source SDKs. +Existing versioned platform archives are reused when repairing an installation, +including archives kept directly in `SDKs` by older setup scripts. + +## Zip dependencies + +- libzip is installed in `SDKs/libzip-1.11.4` from its [official release](https://libzip.org/download/). +- zlib is installed in `SDKs/zlib-1.3.2` from its [official release archive](https://zlib.net/fossils/). + +The repository stores only the recipes, source selection lists, and LunaBuild +configuration templates. Upstream library sources and licenses stay in `SDKs`, +which is ignored by Git. Distributors must include the licenses from the +downloaded SDKs when distributing the resulting libraries. + +Setup copies `SourceSdks/libzip/*.h.in` to the libzip SDK as configuration +headers and generates `lib/zip_err_str.c` from the downloaded `zip.h` and +`zipint.h`. This follows the upstream error-table generator without requiring +CMake. Unchanged generated files retain their timestamps when setup is rerun. + +The MSVC configuration enables the UCRT's secure memory/string functions and +[`_snprintf_s`/`_snwprintf_s`](https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/snprintf-s-snprintf-s-l-snwprintf-s-snwprintf-s-l). +Without these capability definitions, libzip's +compatibility macros expand inside UCRT function declarations and cause syntax +errors in Windows SDK headers. After updating a configuration template, rerun +`setup.bat` (or `setup.sh`) to refresh installed headers, including cached SDKs; +`--force` and clearing the SDK cache are not required. + +The `libzip` and `zlib` targets in the repository's `ThirdParty.Target.cs` compile +the downloaded C sources as static libraries through LunaBuild, including in +shared-module builds. Store and Deflate are enabled. Optional codecs and AES +backends are disabled, and Luna::Zip rejects encrypted data operations. Only +zlib's core compression sources are built; the gzip file API is excluded. + +To upgrade a dependency, update its recipe and SHA-256, compare the selected +source list and libzip configuration templates with upstream, run setup, then +build and run `ZipTest` and `Tests/ZipTest/Interop.py`. diff --git a/Tools/LunaSetup/SourceSdkInstaller.cs b/Tools/LunaSetup/SourceSdkInstaller.cs new file mode 100644 index 00000000..ef8cebc8 --- /dev/null +++ b/Tools/LunaSetup/SourceSdkInstaller.cs @@ -0,0 +1,168 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +internal static class SourceSdkInstaller +{ + private const string ReceiptName = ".luna-source-sdk.json"; + + public static async Task InstallAsync(string root, string sdksDirectory, string downloadDirectory, bool force) + { + var setupDirectory = Path.Combine(root, "Tools", "LunaSetup"); + var packages = JsonSerializer.Deserialize>( + File.ReadAllText(Path.Combine(setupDirectory, "SourceSdks.json")), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidDataException("SourceSdks.json contains no SDK recipes."); + + foreach(var (name, package) in packages) + { + var directoryName = name + "-" + package.Version; + var installedDirectory = Path.Combine(sdksDirectory, directoryName); + var configurationDirectory = Path.Combine(setupDirectory, "SourceSdks", name); + if(!force && IsReady(installedDirectory, package.Sha256)) + { + Prepare(name, installedDirectory, configurationDirectory); + Console.WriteLine($"Source SDK {directoryName} already present: {installedDirectory}"); + continue; + } + + var archivePath = Path.Combine(downloadDirectory, directoryName + ".tar.gz"); + await LunaSetupApp.DownloadAsync(package.Url, archivePath, force, package.Sha256); + + // Stage on the same filesystem as the installation so publication uses + // directory renames and a failed extraction preserves the old SDK. + Directory.CreateDirectory(sdksDirectory); + var stagingDirectory = Path.Combine(sdksDirectory, ".setup-" + directoryName + "-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(stagingDirectory); + try + { + Console.WriteLine($"Extracting {archivePath}"); + using(var archive = File.OpenRead(archivePath)) + using(var gzip = new GZipStream(archive, CompressionMode.Decompress)) + { + TarFile.ExtractToDirectory(gzip, stagingDirectory, overwriteFiles: false); + } + var contentDirectory = Path.Combine(stagingDirectory, directoryName); + if(!Directory.Exists(contentDirectory)) + { + throw new InvalidDataException($"Source archive does not contain {directoryName}."); + } + Prepare(name, contentDirectory, configurationDirectory); + var receipt = new SourceSdkReceipt(package.Sha256, + Directory.GetFiles(contentDirectory, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(contentDirectory, path)) + .Order(StringComparer.Ordinal) + .ToArray()); + File.WriteAllText(Path.Combine(contentDirectory, ReceiptName), JsonSerializer.Serialize(receipt)); + Publish(contentDirectory, installedDirectory, stagingDirectory); + } + finally + { + Directory.Delete(stagingDirectory, recursive: true); + } + Console.WriteLine($"Source SDK {directoryName} installed: {installedDirectory}"); + } + } + + private static bool IsReady(string directory, string sha256) + { + var receiptPath = Path.Combine(directory, ReceiptName); + if(!File.Exists(receiptPath)) return false; + try + { + var receipt = JsonSerializer.Deserialize(File.ReadAllText(receiptPath)); + return receipt is { Files.Length: > 0 } && + string.Equals(receipt.ArchiveSha256, sha256, StringComparison.OrdinalIgnoreCase) && + receipt.Files.All(file => File.Exists(Path.Combine(directory, file))); + } + catch(JsonException) + { + return false; + } + } + + private static void Prepare(string name, string directory, string configurationDirectory) + { + if(name == "libzip") + { + foreach(var header in new[] { "config.h", "zipconf.h" }) + { + WriteIfChanged(Path.Combine(directory, header), File.ReadAllText(Path.Combine(configurationDirectory, header + ".in"))); + } + WriteIfChanged(Path.Combine(directory, "lib", "zip_err_str.c"), GenerateLibZipErrors(directory)); + } + + var sourceDirectory = name == "libzip" ? Path.Combine(directory, "lib") : directory; + foreach(var source in File.ReadAllLines(Path.Combine(configurationDirectory, "Sources.txt"))) + { + if(!File.Exists(Path.Combine(sourceDirectory, source))) + { + throw new FileNotFoundException($"Source SDK is missing required file: {source}"); + } + } + } + + private static string GenerateLibZipErrors(string directory) + { + // Equivalent to libzip's cmake/GenerateZipErrorStrings.cmake. Generate the + // upstream-derived table in SDKs instead of storing it in the repository. + var result = new StringBuilder(""" + /* Generated by LunaSetup from libzip's zip.h and zipint.h. */ + #include "zipint.h" + #define L ZIP_ET_LIBZIP + #define N ZIP_ET_NONE + #define S ZIP_ET_SYS + #define Z ZIP_ET_ZLIB + #define E ZIP_DETAIL_ET_ENTRY + #define G ZIP_DETAIL_ET_GLOBAL + + """); + AppendTable("zip.h", "ZIP_ER_", "LNSZ", "_zip_err_str"); + AppendTable("zipint.h", "ZIP_ER_DETAIL_", "EG", "_zip_err_details"); + return result.ToString(); + + void AppendTable(string header, string prefix, string categories, string symbol) + { + var text = File.ReadAllText(Path.Combine(directory, "lib", header)); + var matches = Regex.Matches(text, @"(?m)^#define " + prefix + @"[A-Z0-9_]+\s+(\d+)\s+/\* ([" + categories + @"]) (.*?) \*/"); + if(matches.Count == 0) throw new InvalidDataException($"No libzip error definitions found in {header}."); + result.AppendLine($"const struct _zip_err_info {symbol}[] = {{"); + for(var i = 0; i < matches.Count; ++i) + { + var match = matches[i]; + if(int.Parse(match.Groups[1].Value) != i) + throw new InvalidDataException($"Non-contiguous libzip error definitions in {header}."); + var message = match.Groups[3].Value.Trim().Replace("\\", "\\\\").Replace("\"", "\\\""); + result.AppendLine($" {{ {match.Groups[2].Value}, \"{message}\" }},"); + } + result.AppendLine("};"); + result.AppendLine($"const int {symbol}_count = sizeof({symbol}) / sizeof({symbol}[0]);"); + } + } + + private static void WriteIfChanged(string path, string contents) + { + if(!File.Exists(path) || File.ReadAllText(path) != contents) File.WriteAllText(path, contents); + } + + private static void Publish(string sourceDirectory, string destinationDirectory, string stagingDirectory) + { + var backupDirectory = Path.Combine(stagingDirectory, "previous"); + var hasPrevious = Directory.Exists(destinationDirectory); + if(hasPrevious) Directory.Move(destinationDirectory, backupDirectory); + try + { + Directory.Move(sourceDirectory, destinationDirectory); + } + catch + { + if(hasPrevious) Directory.Move(backupDirectory, destinationDirectory); + throw; + } + } +} + +internal sealed record SourceSdkPackage(string Version, string Url, string Sha256); +internal sealed record SourceSdkReceipt(string ArchiveSha256, string[] Files); diff --git a/Tools/LunaSetup/SourceSdks.json b/Tools/LunaSetup/SourceSdks.json new file mode 100644 index 00000000..34007ac0 --- /dev/null +++ b/Tools/LunaSetup/SourceSdks.json @@ -0,0 +1,12 @@ +{ + "libzip": { + "version": "1.11.4", + "url": "https://libzip.org/download/libzip-1.11.4.tar.gz", + "sha256": "82e9f2f2421f9d7c2466bbc3173cd09595a88ea37db0d559a9d0a2dc60dc722e" + }, + "zlib": { + "version": "1.3.2", + "url": "https://zlib.net/fossils/zlib-1.3.2.tar.gz", + "sha256": "bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16" + } +} diff --git a/Tools/LunaSetup/SourceSdks/libzip/Sources.txt b/Tools/LunaSetup/SourceSdks/libzip/Sources.txt new file mode 100644 index 00000000..ea3b6d83 --- /dev/null +++ b/Tools/LunaSetup/SourceSdks/libzip/Sources.txt @@ -0,0 +1,112 @@ +zip_add.c +zip_add_dir.c +zip_add_entry.c +zip_algorithm_deflate.c +zip_buffer.c +zip_close.c +zip_delete.c +zip_dir_add.c +zip_dirent.c +zip_discard.c +zip_entry.c +zip_error.c +zip_error_clear.c +zip_error_get.c +zip_error_get_sys_type.c +zip_error_strerror.c +zip_error_to_str.c +zip_extra_field.c +zip_extra_field_api.c +zip_fclose.c +zip_fdopen.c +zip_file_add.c +zip_file_error_clear.c +zip_file_error_get.c +zip_file_get_comment.c +zip_file_get_external_attributes.c +zip_file_get_offset.c +zip_file_rename.c +zip_file_replace.c +zip_file_set_comment.c +zip_file_set_encryption.c +zip_file_set_external_attributes.c +zip_file_set_mtime.c +zip_file_strerror.c +zip_fopen.c +zip_fopen_encrypted.c +zip_fopen_index.c +zip_fopen_index_encrypted.c +zip_fread.c +zip_fseek.c +zip_ftell.c +zip_get_archive_comment.c +zip_get_archive_flag.c +zip_get_encryption_implementation.c +zip_get_file_comment.c +zip_get_name.c +zip_get_num_entries.c +zip_get_num_files.c +zip_hash.c +zip_io_util.c +zip_libzip_version.c +zip_memdup.c +zip_name_locate.c +zip_new.c +zip_open.c +zip_pkware.c +zip_progress.c +zip_realloc.c +zip_rename.c +zip_replace.c +zip_set_archive_comment.c +zip_set_archive_flag.c +zip_set_default_password.c +zip_set_file_comment.c +zip_set_file_compression.c +zip_set_name.c +zip_source_accept_empty.c +zip_source_begin_write.c +zip_source_begin_write_cloning.c +zip_source_buffer.c +zip_source_call.c +zip_source_close.c +zip_source_commit_write.c +zip_source_compress.c +zip_source_crc.c +zip_source_error.c +zip_source_file_common.c +zip_source_file_stdio.c +zip_source_free.c +zip_source_function.c +zip_source_get_dostime.c +zip_source_get_file_attributes.c +zip_source_is_deleted.c +zip_source_layered.c +zip_source_open.c +zip_source_pass_to_lower_layer.c +zip_source_pkware_decode.c +zip_source_pkware_encode.c +zip_source_read.c +zip_source_remove.c +zip_source_rollback_write.c +zip_source_seek.c +zip_source_seek_write.c +zip_source_stat.c +zip_source_supports.c +zip_source_tell.c +zip_source_tell_write.c +zip_source_window.c +zip_source_write.c +zip_source_zip.c +zip_source_zip_new.c +zip_stat.c +zip_stat_index.c +zip_stat_init.c +zip_strerror.c +zip_string.c +zip_unchange.c +zip_unchange_all.c +zip_unchange_archive.c +zip_unchange_data.c +zip_utf-8.c +zip_err_str.c diff --git a/Tools/LunaSetup/SourceSdks/libzip/config.h.in b/Tools/LunaSetup/SourceSdks/libzip/config.h.in new file mode 100644 index 00000000..8ed25cec --- /dev/null +++ b/Tools/LunaSetup/SourceSdks/libzip/config.h.in @@ -0,0 +1,53 @@ +/* LunaBuild configuration for the pinned libzip sources. */ +#ifndef HAD_CONFIG_H +#define HAD_CONFIG_H +#include "zipconf.h" +#define PACKAGE "libzip" +#define VERSION LIBZIP_VERSION +#define HAVE_STDBOOL_H 1 +#define HAVE_SNPRINTF 1 +#define HAVE_STRTOLL 1 +#define HAVE_STRTOULL 1 +#if defined(_WIN32) +#define SIZEOF_OFF_T 4 +#define HAVE__CLOSE 1 +#define HAVE__DUP 1 +#define HAVE__FDOPEN 1 +#define HAVE__FILENO 1 +#define HAVE__FSEEKI64 1 +#define HAVE__FSTAT64 1 +#define HAVE__SETMODE 1 +#define HAVE__STAT64 1 +#define HAVE__STRDUP 1 +#define HAVE__STRICMP 1 +#define HAVE__UNLINK 1 +#define HAVE_LOCALTIME_S 1 +#define HAVE_GETSECURITYINFO 1 +#if defined(_MSC_VER) +/* UCRT supplies these functions. Otherwise compat.h defines fallback macros + * that corrupt their declarations when Windows CRT headers are included. */ +#define HAVE__SNPRINTF_S 1 +#define HAVE__SNWPRINTF_S 1 +#define HAVE_MEMCPY_S 1 +#define HAVE_STRERROR_S 1 +#define HAVE_STRNCPY_S 1 +#endif +#else +#define SIZEOF_OFF_T 8 +#define HAVE_FILENO 1 +#define HAVE_FCHMOD 1 +#define HAVE_FSEEKO 1 +#define HAVE_FTELLO 1 +#define HAVE_LOCALTIME_R 1 +#define HAVE_MKSTEMP 1 +#define HAVE_STRCASECMP 1 +#define HAVE_STRDUP 1 +#define HAVE_STRINGS_H 1 +#define HAVE_UNISTD_H 1 +#endif +#if defined(_WIN64) || defined(__LP64__) +#define SIZEOF_SIZE_T 8 +#else +#define SIZEOF_SIZE_T 4 +#endif +#endif diff --git a/Tools/LunaSetup/SourceSdks/libzip/zipconf.h.in b/Tools/LunaSetup/SourceSdks/libzip/zipconf.h.in new file mode 100644 index 00000000..cf9b3a89 --- /dev/null +++ b/Tools/LunaSetup/SourceSdks/libzip/zipconf.h.in @@ -0,0 +1,30 @@ +/* LunaBuild configuration for libzip 1.11.4; based on upstream zipconf.h.in. */ +#ifndef _HAD_ZIPCONF_H +#define _HAD_ZIPCONF_H +#include +#define ZIP_STATIC +#define LIBZIP_VERSION "1.11.4" +#define LIBZIP_VERSION_MAJOR 1 +#define LIBZIP_VERSION_MINOR 11 +#define LIBZIP_VERSION_MICRO 4 +typedef int8_t zip_int8_t; +typedef uint8_t zip_uint8_t; +typedef int16_t zip_int16_t; +typedef uint16_t zip_uint16_t; +typedef int32_t zip_int32_t; +typedef uint32_t zip_uint32_t; +typedef int64_t zip_int64_t; +typedef uint64_t zip_uint64_t; +#define ZIP_INT8_MIN (-ZIP_INT8_MAX-1) +#define ZIP_INT8_MAX 0x7f +#define ZIP_UINT8_MAX 0xff +#define ZIP_INT16_MIN (-ZIP_INT16_MAX-1) +#define ZIP_INT16_MAX 0x7fff +#define ZIP_UINT16_MAX 0xffff +#define ZIP_INT32_MIN (-ZIP_INT32_MAX-1L) +#define ZIP_INT32_MAX 0x7fffffffL +#define ZIP_UINT32_MAX 0xffffffffLU +#define ZIP_INT64_MIN (-ZIP_INT64_MAX-1LL) +#define ZIP_INT64_MAX 0x7fffffffffffffffLL +#define ZIP_UINT64_MAX 0xffffffffffffffffULL +#endif diff --git a/Tools/LunaSetup/SourceSdks/zlib/Sources.txt b/Tools/LunaSetup/SourceSdks/zlib/Sources.txt new file mode 100644 index 00000000..59d0b9ab --- /dev/null +++ b/Tools/LunaSetup/SourceSdks/zlib/Sources.txt @@ -0,0 +1,11 @@ +adler32.c +compress.c +crc32.c +deflate.c +infback.c +inffast.c +inflate.c +inftrees.c +trees.c +uncompr.c +zutil.c