diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc152bd87..43f80871f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,17 @@ jobs: - name: Fetch dependencies (with retries) shell: bash run: bash scripts/ci-retry.sh zig build --fetch + # Every test binary is compiled before any of them runs, so no compiler is alive beside a + # running test. On Windows, Zig 0.16 spawns each child inheriting every inheritable handle + # the build runner holds, the pipes it is making for its other children included. A + # compiler spawned while a test's pipes exist keeps a copy of that test's stdout: the test + # passes and exits, but its stdout reaches EOF only when the compiler exits, and the runner + # gives up after a minute with "test runner failed to respond", on whichever tiny test was + # unlucky, about one run in thirty. `check` is exactly the compiles `test` needs, so the + # step after it spawns only test binaries, which live for milliseconds. + - name: Compile tests + shell: bash + run: bash scripts/ci-retry.sh zig build check --summary all - name: Run tests shell: bash run: bash scripts/ci-retry.sh zig build test --summary all @@ -211,6 +222,12 @@ jobs: if: matrix.name == 'macOS' run: bash scripts/ci-retry.sh zig build -Dnative-backend=sdl3 -Dno-emit --summary all + # A link-only app (`-Ddynamic-plugins=false`): its gates on loading plugins at runtime are + # comptime, so only a build with it off shows that build still compiles. + - name: Compile a link-only app + if: matrix.name == 'Linux' + run: bash scripts/ci-retry.sh zig build -Ddynamic-plugins=false -Dno-emit --summary all + # The suites for SDL's patches on macOS (FizzyGPU's Metal tests; the rest are Linux's), when # SDL's patches or pins changed: see the `sdl` job. Is there a Metal device? The probe says, # and the suite then requires Metal, so a runner with one can't skip the Metal tests. (On diff --git a/.github/workflows/shaders.yml b/.github/workflows/shaders.yml new file mode 100644 index 000000000..11c7017ab --- /dev/null +++ b/.github/workflows/shaders.yml @@ -0,0 +1,133 @@ +# The SPIR-V and DXIL fizzy's native backend embeds (Vulkan, D3D12), compiled from their HLSL here +# rather than on someone's machine, and checked against what is committed. +# +# Each `X.hlsl` beside compiled forms of it (`compiled/{spv,dxil,msl,glsl}/X.*`) is compiled by +# `scripts/compile-shaders.sh` and compared byte for byte. A difference fails the job and uploads the fresh +# files as the `compiled-shaders` artifact, laid out as the repository has them: download it and +# commit them. Before this, nothing noticed when the HLSL changed and the compiled files did not, +# and Windows and Linux quietly drew the previous glass (#242, #373). +# +# The toolchain: SDL_shadercross, pinned, with the DirectXShaderCompiler release it pins itself +# (Microsoft's prebuilt v1.9.2602, by SHA-256), as shadercross's own CI builds it. Nothing heavy is +# built here: SPIRV-Cross and shadercross, a few minutes, cached by the pin. The shaders that had +# not changed since they were compiled (the backend's defaults) reproduce with it byte for byte, +# SPIR-V and DXIL: it is the toolchain they were made with. + +name: Shaders + +on: + pull_request: + paths: + - "**/*.hlsl" + - "**/shaders/compiled/**" + - "scripts/compile-shaders.sh" + - ".github/workflows/shaders.yml" + push: + branches: [main] + paths: + - "**/*.hlsl" + - "**/shaders/compiled/**" + - "scripts/compile-shaders.sh" + - ".github/workflows/shaders.yml" + workflow_dispatch: + +env: + SHADERCROSS_REPO: libsdl-org/SDL_shadercross + # Its build-scripts/download-prebuilt-DirectXShaderCompiler.cmake pins DXC v1.9.2602. + SHADERCROSS_SHA: 1ff05bec573988a98ef9e0260b4da44f512b8367 + +jobs: + compile: + runs-on: ubuntu-24.04 + timeout-minutes: 150 + steps: + - uses: actions/checkout@v4 + + - name: Cached shadercross + id: cache + uses: actions/cache@v4 + with: + path: ~/shadercross + key: shadercross-${{ env.SHADERCROSS_SHA }}-prebuilt-dxc-1 + + - name: Toolchain + run: | + sudo apt-get update -y + sudo apt-get install -y ninja-build cmake spirv-tools + + # Every run: shadercross links SDL, cached or not (setup-sdl caches its own build). + - name: SDL + id: sdl + uses: libsdl-org/setup-sdl@main + with: + version: 3-latest + cmake-generator: Ninja + install-linux-dependencies: true + + - name: Build shadercross (prebuilt DXC) + if: steps.cache.outputs.cache-hit != 'true' + run: | + git clone --filter=blob:none "https://github.com/$SHADERCROSS_REPO" sc + git -C sc checkout "$SHADERCROSS_SHA" + git -C sc submodule update --init --depth 1 external/SPIRV-Cross + # Microsoft's release of DXC, as shadercross pins it (version and SHA-256). + cmake -P sc/build-scripts/download-prebuilt-DirectXShaderCompiler.cmake + dxc_root="$PWD/sc/external/DirectXShaderCompiler-binaries" + cmake -S sc/external/SPIRV-Cross -B spirv_cross_build -GNinja \ + -DCMAKE_BUILD_TYPE=Release -DSPIRV_CROSS_SHARED=ON -DSPIRV_CROSS_STATIC=ON \ + -DCMAKE_INSTALL_PREFIX="$HOME/shadercross" + cmake --build spirv_cross_build + cmake --install spirv_cross_build + cmake -S sc -B sc/build -GNinja \ + -DCMAKE_BUILD_TYPE=Release \ + -DSDLSHADERCROSS_VENDORED=OFF \ + -DSDLSHADERCROSS_CLI=ON \ + -DSDLSHADERCROSS_SHARED=ON \ + -DSDLSHADERCROSS_STATIC=OFF \ + -DSDLSHADERCROSS_WERROR=OFF \ + -DSDLSHADERCROSS_INSTALL=ON \ + -DSDLSHADERCROSS_INSTALL_RUNTIME=ON \ + -DDirectXShaderCompiler_ROOT="$dxc_root" \ + -Dspirv_cross_c_shared_ROOT="$HOME/shadercross:$HOME/shadercross/share/spirv_cross_c_shared" \ + -DCMAKE_INSTALL_PREFIX="$HOME/shadercross" + cmake --build sc/build --parallel + cmake --install sc/build + # The compiler shadercross loads at run time, beside it. + find "$dxc_root" -name 'lib*.so*' -exec cp -L {} "$HOME/shadercross/lib/" \; + ls "$HOME/shadercross/lib" + + - name: Compile and compare + run: | + export PATH="$HOME/shadercross/bin:$PATH" + export LD_LIBRARY_PATH="$HOME/shadercross/lib:${{ steps.sdl.outputs.prefix }}/lib:$LD_LIBRARY_PATH" + shadercross --help > /dev/null 2>&1 || { echo "shadercross does not run"; ldd "$HOME/shadercross/bin/shadercross"; exit 1; } + command -v spirv-cross + failed=0 + bash scripts/compile-shaders.sh out || failed=1 + stale=0 + while IFS= read -r fresh; do + committed=${fresh#out/} + if cmp -s "$committed" "$fresh"; then + echo "ok $committed" + else + echo "::error file=$committed::$committed is not what its HLSL compiles to: download the compiled-shaders artifact and commit it" + echo " committed $(stat -c %s "$committed") bytes, compiled $(stat -c %s "$fresh")" + stale=1 + fi + case $fresh in + *.spv) spirv-val "$fresh" || { echo "::error file=$committed::$fresh does not validate"; failed=1; } ;; + esac + done < <(find out -type f | sort) + echo "stale=$stale" >> "$GITHUB_ENV" + echo "failed=$failed" >> "$GITHUB_ENV" + + - name: Upload the fresh compiled shaders + if: env.stale == '1' || env.failed == '1' + uses: actions/upload-artifact@v4 + with: + name: compiled-shaders + path: out/ + + - name: Fail on stale or broken compiled shaders + if: env.stale == '1' || env.failed == '1' + run: exit 1 diff --git a/AGENTS.md b/AGENTS.md index dc0d5375f..fb191062e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,9 +62,16 @@ Full contract: **[`docs/PLUGINS.md`](docs/PLUGINS.md)**. Living reshape plan: ** Which plugins an executable links in is build data: `build/sdk.zig`'s `bundledPluginsModule` generates `bundled_plugins` (`pub const modules = .{ @import("workbench"), … }`) and the -runtime iterates it — nothing in `app/` or `src/` names a plugin except the workbench (its -state still lives on the Editor). An app built on fizzy adds its own with `defer-app` + -`fizzy.buildApp(fizzy_dep, &.{ .{ .name, .module = dep.module("plugin") } })`; see +runtime iterates it. Nothing in `app/` or `src/` registers a plugin by name, though two are +imported as libraries and so always built: workbench (its state still lives on the Editor, so +every list must include it) and markdown (the store draws READMEs with it). An app built on +fizzy lists every plugin it links with `defer-app` + `fizzy.buildApp(fizzy_dep, &.{ +fizzy.plugins.workbench, fizzy.plugins.text, .{ .name, .module = dep.module("plugin") } })`: +fizzy's own are entries with no module, built for each executable (`build/plugins.zig`'s +`link`), and only those listed register or get a dylib. `.@"dynamic-plugins" = false` (or +`-Ddynamic-plugins=false`) makes an app link-only: no plugin dylib is built, and nothing loads +at runtime (fizzy's dylibs, the user plugins directory, the store, a web page's `?plugin=`), +all decided at comptime (`App.loads_plugins`). See [`fizzyedit/example-app`](https://github.com/fizzyedit/example-app), which bundles `fizzyedit/example-plugin`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a1f9a628..2ca1a9eb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,6 +150,13 @@ maintainer makes the SDK **live** on the site (below). | Plans | private, in [`fizzyedit/plans`](https://github.com/fizzyedit/plans): issues, and a long design as `plans/_PLAN.md` opening with a status line (`Status: proposed`, `in progress, fizzyedit/fizzy#N`, or `done`) | | The forks fizzy builds on | `docs/DEPENDENCIES.md` | | Releases | `RELEASING.md` | +| Licensing, and the name | `LICENSING.md`, `TRADEMARKS.md` | + +## Licensing contributions + +Fizzy is MIT (`LICENSING.md`), and a contribution comes in under the license it goes out under: +opening a PR offers its change under MIT. There is no contributor license agreement. Code +copied from elsewhere must carry a license compatible with MIT, and keeps its notice. ## Habits reviewers ask for diff --git a/LICENSE b/LICENSE index f288702d2..d71a44d82 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,21 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. +MIT License + +Copyright (c) 2022-2026 Colton Franklin and Fizzy contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 000000000..ce80ec63e --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,36 @@ +# Licensing + +## Fizzy is free, forever + +Fizzy is MIT-licensed (`LICENSE`): the application, the framework in `app/`, the plugin SDK in +`sdk/`, `core/`, and the bundled plugins in `plugins/`. Every version ever released stays MIT. +That grant cannot be withdrawn, by the maintainers or by anyone who comes after them. + +## Build what you want on it, commercial work included + +- **Plugins** may carry any license, closed-source and paid included. A plugin meets Fizzy only + through the SDK, and the SDK asks nothing of it beyond keeping the MIT notice with any copy of + the SDK's own source it ships. +- **Applications** built on Fizzy as a library (`fizzy.buildApp`) may be commercial and + closed-source, under the same condition. +- **The plugin store** may list free and paid plugins alike; each listing states its license. + +## What the project commits to + +- Fizzy itself, the editor and the framework, will never become paid, open-core, or split into + a "community edition". No feature of Fizzy is held back for a paid tier; paid work belongs in + plugins, made by whoever wants to make them, the maintainers included. +- The SDK will never require a license key, an account, or a payment to load a plugin. +- Contributions come in under the license they go out under: MIT, with no contributor license + agreement. No single party collects the right to relicense other people's work. + +## The name + +"Fizzy" and the Fizzy logo are trademarks of Colton Franklin. Forks are welcome under another +name; what you may call a build of Fizzy, and how, is in `TRADEMARKS.md`. + +## History + +Fizzy was MIT from 2022 until May 2026, GPL-3.0 from then until this file was added, and MIT +again since. Code in the GPL-3.0 releases was written by Colton Franklin, apart from one +contribution whose author agreed to its relicensing, so the same code is offered under MIT too. diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 000000000..c576d077f --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,27 @@ +# Trademarks + +The code is MIT (`LICENSE`); the name is not. "Fizzy" and the Fizzy logo are trademarks of +Colton Franklin. This policy exists so that a person who downloads something called Fizzy gets +Fizzy, and it is meant to stay out of the way of everyone building on it. + +## Fine without asking + +- Saying what your work is for: "a plugin for Fizzy", "built with Fizzy", "Fizzy theme pack". +- Redistributing an unmodified Fizzy build under its own name and logo. +- Packaging Fizzy for an operating system or package manager, with only the changes needed to + build and install it there. +- Writing, teaching, streaming and talking about Fizzy, logo included. +- Naming a plugin `fizzy-` or `-for-fizzy`. + +## Ask first + +- Calling a modified build "Fizzy", or shipping it with the Fizzy logo or icon. +- A product, company or domain name that contains "Fizzy" or could be mistaken for it. +- Using the logo in a way that suggests the project endorses or sponsors you. + +A fork is welcome; give it a name and icon of its own and say it is based on Fizzy. + +## Asking + +Open an issue on [`fizzyedit/fizzy`](https://github.com/fizzyedit/fizzy/issues). Reasonable +requests get a yes. diff --git a/app/App.zig b/app/App.zig index 4621b08c4..d1de8c64a 100644 --- a/app/App.zig +++ b/app/App.zig @@ -10,6 +10,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); pub const Secrets = @import("Secrets.zig"); +const Tracks = @import("Tracks.zig"); const sdk = @import("fizzy_sdk"); const builtin = @import("builtin"); const build_opts = @import("build_opts"); @@ -202,6 +203,9 @@ folder_retired: std.ArrayListUnmanaged([]const u8) = .empty, pending_folder_close: bool = false, open_files: std.AutoArrayHashMapUnmanaged(u64, sdk.DocHandle) = .empty, +/// Each open document's history, timed (`Tracks`): set up once the app is at its final address, +/// and only then the host's recorder. +tracks: ?Tracks = null, file_id_counter: u64 = 0, @@ -885,7 +889,14 @@ const missing_symbols_reason = "the plugin is missing required entry symbols — const load_failure_reasons = .{ .AbiMismatch = "built against an incompatible Fizzy SDK — rebuild the plugin against this Fizzy build", - .AbiBuildEnvMismatch = "SDK versions match, but optimize mode does not match", + .AbiOptimizeMismatch = if (std.mem.eql(u8, sdk.dylib.optimize_safety_class, "safe")) + "built ReleaseFast or ReleaseSmall, but this Fizzy is a " ++ @tagName(builtin.mode) ++ + " build — rebuild the plugin in Debug, or run a release build of Fizzy" + else + "built Debug or ReleaseSafe, but this Fizzy is a " ++ @tagName(builtin.mode) ++ + " build — rebuild the plugin with -Doptimize=ReleaseFast", + .AbiShapeMismatch = "same SDK version but a different SDK shape — a Fizzy built from main past " ++ + "a release needs plugins built with zig build --fork=/sdk (or this Fizzy is older than the plugin's SDK)", .SdkVersionMismatch = "requires a newer Fizzy SDK — update Fizzy or install a matching plugin build", .PluginIdMismatch = "plugin id in the dylib does not match its filename — rename the file or fix manifest.id", .DylibOpenFailed = "the plugin library could not be opened (missing file, wrong architecture, or unresolved symbols)", @@ -918,8 +929,11 @@ pub fn formatPluginProbeDetail(allocator: std.mem.Allocator, info: PluginLoader. /// Removes the entry from the plugin's document registry; fizzy still removes /// the matching `DocHandle` from `open_files`. pub fn closeDocumentResources(app: *App, doc: sdk.DocHandle) void { + if (app.tracks) |*t| t.closed(doc.id); // Whoever held it is holding nothing now (`sdk.Claim`). app.host.releaseDocumentClaims(doc.id); + // Nor are its steps anyone's now (`Host.stepAuthor`). + app.host.forgetDocumentAuthors(doc.id); _ = doc.owner.closeDocument(doc); doc.owner.unregisterDocument(doc.id); } @@ -1141,12 +1155,17 @@ pub fn syncLoadedPluginRenderBridge(app: *App) void { } } +/// Whether this build loads plugins at runtime (`-Ddynamic-plugins`, default on): fizzy's own +/// dylibs, the user plugins directory, the store, and on the web a page's `?plugin=`. Off, the +/// app runs exactly the plugins it links. +pub const loads_plugins = if (@hasDecl(build_opts, "dynamic_plugins")) build_opts.dynamic_plugins else true; + /// Whether a bundled plugin should be loaded from `{exe_dir}/plugins//` rather than the -/// copy linked in: never on wasm, not when the build pinned it static +/// copy linked in: never on wasm or in a link-only build, not when the build pinned it static /// (`build_opts.static_`), and not when `FIZZY_STATIC_=1` is set — the bisection /// switch for dylib loading trouble. pub fn bundledDylibEnabled(gpa: std.mem.Allocator, comptime id: []const u8) bool { - if (comptime builtin.target.cpu.arch == .wasm32) return false; + if (comptime builtin.target.cpu.arch == .wasm32 or !loads_plugins) return false; if (comptime @hasDecl(build_opts, "static_" ++ id)) { if (comptime @field(build_opts, "static_" ++ id)) return false; } diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig new file mode 100644 index 000000000..f00163b02 --- /dev/null +++ b/app/HistoryTransport.zig @@ -0,0 +1,215 @@ +//! A document's transport: a bar of glass at the bottom of its pane, its history as a line with a +//! knob where the document is, and undo and redo either side (`plans/RECORDING_PLAN.md`, "Undo is +//! a seek"). Undo and redo show it (`stir`); it stays while the pointer is on it and goes a few +//! seconds after (`history_transport.Shown`). A press or a drag on the line goes to the step under +//! it, one `historyGoTo` a frame at most, so a drag across a thousand steps costs a jump a frame. +//! +//! Over any owner's history (`Plugin.historyLine`, `historyGoTo`); an owner without them has no +//! transport. Nothing runs for a bar that is not showing: no history is asked for, no timer is +//! set. One per document pane, kept by the app beside the pane. +const HistoryTransport = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const core = @import("core"); +const sdk = @import("fizzy_sdk"); +const icons = @import("icons"); +const rules = @import("history_transport.zig"); + +shown: rules.Shown = .{}, +repeat: rules.Repeat = .{}, +/// The stop the pointer holds the knob at while it drags the line, until the drag ends. Only +/// while the bar holds the mouse: a drag whose capture went (the bar stopped drawing) is over. +drag: ?usize = null, + +const bar_h: f32 = 40; +const button_d: f32 = 34; +const gap: f32 = 10; +const max_w: f32 = 460; + +/// Undo or redo was pressed at `now_ns`, or something else asked for the bar: it shows. +pub fn stir(self: *HistoryTransport, now_ns: i128) void { + self.shown.stir(now_ns); + dvui.refresh(null, @src(), null); +} + +/// A press of undo or redo (`forward`) at `now_ns` on this document: the bar shows, and the +/// press moves this many steps (`history_transport.Repeat`: more the longer the key is held). +pub fn press(self: *HistoryTransport, now_ns: i128, forward: bool) usize { + self.stir(now_ns); + return self.repeat.press(now_ns, forward); +} + +/// Where the bar is over `area` (a document's pane, physical) while it shows, as far as the +/// pointer keeps it: what else drawn at the bottom of the pane (the activity feed) stays above. +/// Null when it is not showing at `now_ns`, or the pane is too narrow for it. +pub fn place(self: *const HistoryTransport, area: dvui.Rect.Physical, now_ns: i128) ?dvui.Rect.Physical { + if (self.drag == null and self.shown.openness(now_ns) <= 0) return null; + const whole = geometry(area.toNatural()) orelse return null; + return dvui.windowRectScale().rectToPhysical(whole.outsetAll(4)); +} + +/// The bar's rect over a pane's natural rect, or null when the pane is too narrow for it. +fn geometry(nat: dvui.Rect.Natural) ?dvui.Rect { + const w = @min(max_w, nat.w - 24); + if (w < 3 * button_d) return null; + const center: dvui.Point = .{ .x = nat.x + nat.w / 2, .y = nat.y + nat.h - 14 - bar_h / 2 }; + return .{ .x = center.x - w / 2, .y = center.y - bar_h / 2, .w = w, .h = bar_h }; +} + +/// The id `historyGoTo` takes for stop `i`: 0 before the first step, else the step's. +fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 { + return if (i == 0) 0 else line.entries[i - 1].id; +} + +/// Draw the bar over `area` (the document's pane, physical) when it shows, and take its input. +/// `id_extra` keeps one pane's bar apart from another's. +pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize) void { + const now = dvui.frameTimeNS(); + // The pointer on it, or a drag in it, keeps it. + if (self.drag != null) self.shown.stir(now) else if (self.place(area, now)) |b| if (b.contains(dvui.currentWindow().mouse_pt)) self.shown.stir(now); + const p = self.shown.openness(now); + if (p <= 0) return; + const arena = dvui.currentWindow().arena(); + const line = doc.owner.historyLine(doc, arena) orelse return; + const n = line.entries.len; + + const wrs = dvui.windowRectScale(); + const s = wrs.s; + const whole = geometry(area.toNatural()) orelse return; + const w = whole.w; + const center = whole.center(); + const back: dvui.Rect = .{ .x = whole.x, .y = center.y - button_d / 2, .w = button_d, .h = button_d }; + const fwd: dvui.Rect = .{ .x = whole.x + w - button_d, .y = back.y, .w = button_d, .h = button_d }; + const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap), .h = bar_h }; + const hit = wrs.rectToPhysical(whole.outsetAll(4)); + + var float: dvui.FloatingWidget = undefined; + float.init(@src(), .{}, .{ .rect = whole.outsetAll(16), .id_extra = id_extra }); + defer float.deinit(); + const bounds = float.data().rect; + const fid = float.data().id; + // A drag lives as long as the bar holds the mouse: drawn again after it was not (a claim came + // and went), it holds nothing, and the knob is the document's again. + if (self.drag != null and !dvui.captured(fid)) self.drag = null; + + const phys = [3]dvui.Rect.Physical{ wrs.rectToPhysical(back), wrs.rectToPhysical(track), wrs.rectToPhysical(fwd) }; + const pad = 14; + const x0 = phys[1].x + pad * s; + const x1 = phys[1].x + phys[1].w - pad * s; + // Where a press goes to a step: the line's band, under the title. A press on the title or + // the capsule's ends is the bar's, and does nothing. + const line_hit: dvui.Rect.Physical = .{ .x = x0 - 8 * s, .y = phys[1].y + phys[1].h * 0.45, .w = x1 - x0 + 16 * s, .h = phys[1].h * 0.55 }; + // Named for a tape or a test to aim at: the line from its first stop to its last, so a point + // a fraction along it is that fraction of the history. + const path = doc.owner.documentPath(doc); + core.anchor.markRect(fid, phys[0], true, "history.back:{s}", .{path}); + core.anchor.markRect(fid, .{ .x = x0, .y = phys[1].y, .w = x1 - x0, .h = phys[1].h }, true, "history.line:{s}", .{path}); + core.anchor.markRect(fid, phys[2], true, "history.forward:{s}", .{path}); + + // Input: the buttons step, the line goes where it is pressed and follows a drag. + var go: ?usize = null; + for (dvui.events()) |*e| { + if (!dvui.eventMatch(e, .{ .id = fid, .r = hit })) continue; + const me = switch (e.evt) { + .mouse => |m| m, + else => continue, + }; + switch (me.action) { + .press => if (me.button.pointer()) { + e.handle(@src(), float.data()); + if (phys[0].contains(me.p)) { + go = line.at -| 1; + } else if (phys[2].contains(me.p)) { + go = @min(n, line.at + 1); + } else if (line_hit.contains(me.p)) { + dvui.captureMouse(float.data(), e.num); + self.drag = rules.stopAt(me.p.x, x0, x1, n); + } + }, + .motion => if (self.drag != null and dvui.captured(fid)) { + e.handle(@src(), float.data()); + self.drag = rules.stopAt(me.p.x, x0, x1, n); + }, + .release => if (me.button.pointer() and dvui.captured(fid)) { + e.handle(@src(), float.data()); + dvui.captureMouse(null, e.num); + self.drag = null; + }, + else => {}, + } + } + if (self.drag) |d| go = d; + var at = line.at; + if (go) |to| if (to != at) { + if (doc.owner.historyGoTo(doc, stopId(line, to))) at = to; + dvui.refresh(null, @src(), fid); + }; + + const prev_alpha = dvui.alpha(p); + defer dvui.alphaSet(prev_alpha); + const pt = dvui.currentWindow().mouse_pt; + var bubbles: [3]core.dialogs.Bubble = undefined; + for (phys, &bubbles, 0..) |r, *b, i| b.* = .{ + .rect = r, + .light = if (i != 1 and r.contains(pt) and (if (i == 0) at > 0 else at < n)) core.dialogs.bubble_hover else 0, + }; + core.dialogs.glassBubbles(fid, &bubbles, s, .{ .merge = 14, .shadow = 1 }); + + const ink = dvui.themeGet().color(.window, .text); + button(bounds, back, "history_back", icons.tvg.lucide.undo, ink.opacity(if (at > 0) 0.85 else 0.3)); + button(bounds, fwd, "history_forward", icons.tvg.lucide.redo, ink.opacity(if (at < n) 0.85 else 0.3)); + + // What the document is at, over the line: the step's title, and how far along. + const font = dvui.Font.theme(.body).larger(-2); + var buf: [96]u8 = undefined; + const title = if (at == 0) "Before any change" else line.entries[at - 1].title; + const label = std.fmt.bufPrint(&buf, "{s} {d} / {d}", .{ title, at, n }) catch title; + dvui.labelNoFmt(@src(), label, .{}, .{ + .rect = .{ .x = track.x + pad - bounds.x, .y = track.y + 4 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() }, + .padding = .{}, + .margin = .{}, + .font = font, + .color_text = .{ .color = ink.opacity(0.85) }, + }); + paintLine(.{ .x = x0, .y = phys[1].y + phys[1].h - 16 * s, .w = @max(1, x1 - x0), .h = 10 * s }, at, n, ink, s); + + // Comes in, goes: a frame each while it fades, and one wake for when it next changes on its + // own (it starts to go, it is gone). + if (p < 1) dvui.refresh(null, @src(), fid); + if (self.shown.nextChange(now)) |t| dvui.timer(fid, @intCast(@min(@divTrunc(t - now, 1000) + 1, std.math.maxInt(i32)))); +} + +/// A step button's icon, in the middle of its bubble `r` (window-natural; `bounds` the layer's). +fn button(bounds: dvui.Rect, r: dvui.Rect, name: []const u8, tvg: []const u8, color: dvui.Color) void { + const d = r.w * 0.44; + core.icon.icon(@src(), name, tvg, .{ + .stroke_color = .{ .color = color }, + .fill_color = .{ .color = color }, + }, .{ + .id_extra = @intFromPtr(name.ptr), + .rect = .{ .x = r.x + (r.w - d) / 2 - bounds.x, .y = r.y + (r.h - d) / 2 - bounds.y, .w = d, .h = d }, + }); +} + +/// The line `r` (physical): the history to `at` lit, a tick per step while they fit, the knob. +fn paintLine(r: dvui.Rect.Physical, at: usize, n: usize, ink: dvui.Color, s: f32) void { + const h = 4 * s; + const line: dvui.Rect.Physical = .{ .x = r.x, .y = r.y + (r.h - h) / 2, .w = r.w, .h = h }; + line.fill(.all(h / 2), .{ .color = .{ .color = ink.opacity(0.15) } }); + const accent = dvui.themeGet().color(.highlight, .fill); + const knob_x = rules.stopX(at, r.x, r.x + r.w, n); + var done = line; + done.w = knob_x - line.x; + if (done.w > 0) done.fill(.all(h / 2), .{ .color = .{ .color = accent } }); + // A tick per step while they stand 6 points apart; past that the line is its own scale. + if (n > 0 and r.w / @as(f32, @floatFromInt(n)) >= 6 * s) for (1..n) |i| { + const x = rules.stopX(i, r.x, r.x + r.w, n); + const tick: dvui.Rect.Physical = .{ .x = x - 0.5 * s, .y = r.y + 1 * s, .w = 1 * s, .h = r.h - 2 * s }; + tick.fill(.{}, .{ .color = .{ .color = ink.opacity(0.25) } }); + }; + var knob: dvui.Path.Builder = .init(dvui.currentWindow().lifo()); + defer knob.deinit(); + knob.addArc(.{ .x = knob_x, .y = line.y + h / 2 }, 6 * s, std.math.tau, 0, true); + knob.build().fillConvex(.{ .color = .{ .color = accent }, .fade = 1 }); +} diff --git a/app/Tracks.zig b/app/Tracks.zig new file mode 100644 index 000000000..836462eb2 --- /dev/null +++ b/app/Tracks.zig @@ -0,0 +1,603 @@ +//! A track per open document: its history with the time put back in (`plans/RECORDING_PLAN.md`). +//! +//! The host's recorder (`sdk.Host.Recorder`): owners report each step as it happens +//! (`sdk.Step`), and a track keeps it with the moment it came, beside the document's state when +//! the track began (`base`) and every so often since (`checkpoints`), so any moment of it can be +//! put back into a document: the nearest state, then the steps after it (`replayInto`). +//! +//! **Nothing runs on a clock.** A step costs an append, when it comes. Taking a document's state +//! is never done while an edit is half applied (the owner reports a step from inside its edit): +//! a track asks for one, and the next frame takes it at its top (`tick`), before anything draws. +//! A frame with nothing asked of it returns at once, and a sleeping app does nothing at all. +//! +//! **What it costs.** A base per open document that records steps, as `captureDocumentState` +//! makes it, the steps' bytes, and the checkpoints, in memory for the session (`held`). A +//! checkpoint is kept as what differs from the latest whole state (`state_delta`): the base, or a +//! checkpoint kept whole because its delta had come to half the document. Older +//! checkpoints are thinned (`thin`), and past `cap` the oldest ended tracks go, then all but each +//! open track's newest checkpoint (`shed`); steps and bases stay, since undo will walk them. +//! Taking and diffing a checkpoint happen on the frame that takes it, on every target (the web has +//! no threads); were that ever too long, the diff would be spread over the frames after, a slice +//! each, the copy being immutable once taken. Until tracks are on by default they are behind `on` +//! (fizzy: `FIZZY_TRACKS=1`). `zig build bench-tracks` measures what a step, a checkpoint and a +//! replay cost. +const Tracks = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const core = @import("core"); +const sdk = @import("fizzy_sdk"); +const state_delta = @import("state_delta.zig"); + +gpa: std.mem.Allocator, +/// The frame clock: what a step's moment is read from. A bench gives its own. +clock: *const fn () i128 = dvui.frameTimeNS, +/// Whether documents are recorded. Set before documents open: one opened while off has no track. +on: bool = false, +/// The tracks of open documents, by document id. +open: std.AutoArrayHashMapUnmanaged(u64, *Track) = .empty, +/// Tracks that ended this session: their document closed, or its history started over. +ended: std.ArrayListUnmanaged(*Track) = .empty, +/// Which document the person was working in, each time it changed, as steps landed. +focus: std.ArrayListUnmanaged(Focus) = .empty, +/// Some track wants its document's state taken at the next frame's top. +due: bool = false, +/// A document being replayed into: its own steps are not recorded meanwhile. +muted: u64 = 0, +/// The session's start, on the frame clock (`dvui.frameTimeNS`): what `Track.Rec.at` counts from. +start_ns: ?i128 = null, +/// Bytes every track holds: bases, steps and checkpoints. +held: usize = 0, +/// What `held` may come to before tracks shed (`shed`). +cap: usize = default_cap, +/// Frames whose top did any work (`tick`): one per state asked for, never one a frame. +worked: u64 = 0, + +pub const default_cap = 256 << 20; +/// Checkpoints older than this are thinned to one per `sparse_steps` steps. +pub const dense_ms = 60 * std.time.ms_per_min; +pub const sparse_steps = 4096; + +/// When a step asks for a checkpoint. A checkpoint costs what differs from the latest whole state, +/// so it is taken only once the steps since the last are worth a share of one (`state_share`: +/// their bytes times it reach the last checkpoint's size), and then after `checkpoint_steps` of +/// them, or a pause of `checkpoint_gap_ms` before one. Past `checkpoint_max_steps` one is taken +/// whatever it costs, so a seek never replays more than that. +pub const checkpoint_steps = 256; +pub const checkpoint_max_steps = 8192; +pub const checkpoint_gap_ms = 30_000; +pub const state_share = 1; + +pub const Focus = struct { + /// ms since the session's start. + at: u32, + doc_id: u64, +}; + +pub const Track = struct { + doc_id: u64, + /// The document's own handle, stable for its life: what its states are taken from. + doc: sdk.DocHandle, + owner: *sdk.Plugin, + /// The owner's `step_format` when the track began. + format: u32, + /// The document when the track began; null until the next frame takes it. + base: ?State = null, + steps: std.ArrayListUnmanaged(Rec) = .empty, + /// Every step's effect and motion, one after another. + bytes: std.ArrayListUnmanaged(u8) = .empty, + checkpoints: std.ArrayListUnmanaged(State) = .empty, + checkpoint_due: bool = false, + /// The size of the last checkpoint kept as a delta: about what the next will cost. 0 after a + /// whole state, whose next delta starts small. + state_len: usize = 0, + /// Steps, and their bytes, since the last state taken. + since_steps: u32 = 0, + since_bytes: usize = 0, + + pub const Rec = struct { + kind: sdk.Step.Kind, + /// The history entry (`sdk.Step.id`). + id: u64, + /// ms since the session's start. + at: u32, + ms: u32, + effect: Span, + motion: Span, + }; + + pub const Span = struct { start: usize, len: usize }; + + /// The document as `captureDocumentState` gave it, after the first `step` steps: whole, or as + /// what differs from the latest whole state before it (`state_delta`). + pub const State = struct { + at: u32, + step: usize, + bytes: []u8, + whole: bool = true, + /// `documentFingerprint` then, when the owner has one: a replay that reaches this moment + /// must come out the same. + fingerprint: ?u64, + }; + + pub fn effect(self: *const Track, r: Rec) []const u8 { + return self.bytes.items[r.effect.start..][0..r.effect.len]; + } + + pub fn motion(self: *const Track, r: Rec) []const u8 { + return self.bytes.items[r.motion.start..][0..r.motion.len]; + } + + /// The state a replay to `count` steps starts from: the latest at or before it. + pub fn stateBefore(self: *const Track, count: usize) ?*const State { + var i = self.checkpoints.items.len; + while (i > 0) { + i -= 1; + if (self.checkpoints.items[i].step <= count) return &self.checkpoints.items[i]; + } + return if (self.base) |*b| b else null; + } + + /// The latest whole state before checkpoint `i` (`checkpoints.len`: after them all): what a + /// delta there is against. The base when no checkpoint before it is whole. + fn wholeBefore(self: *const Track, i: usize) []const u8 { + var j = i; + while (j > 0) { + j -= 1; + if (self.checkpoints.items[j].whole) return self.checkpoints.items[j].bytes; + } + return self.base.?.bytes; + } + + /// `s` (the base, or one of the checkpoints) as the whole state: borrowed when it is kept + /// whole, else made from its delta, `owned` by `gpa`. + pub fn wholeState(self: *const Track, gpa: std.mem.Allocator, s: *const State) state_delta.Error!struct { bytes: []const u8, owned: bool } { + if (s.whole) return .{ .bytes = s.bytes, .owned = false }; + const i = (@intFromPtr(s) - @intFromPtr(self.checkpoints.items.ptr)) / @sizeOf(State); + return .{ .bytes = try state_delta.apply(gpa, self.wholeBefore(i), s.bytes), .owned = true }; + } + + /// Bytes this track holds. + pub fn size(self: *const Track) usize { + var n = self.bytes.items.len + self.steps.items.len * @sizeOf(Rec); + if (self.base) |b| n += b.bytes.len; + for (self.checkpoints.items) |c| n += c.bytes.len; + return n; + } + + fn deinit(self: *Track, gpa: std.mem.Allocator) void { + if (self.base) |b| gpa.free(b.bytes); + for (self.checkpoints.items) |c| gpa.free(c.bytes); + self.checkpoints.deinit(gpa); + self.steps.deinit(gpa); + self.bytes.deinit(gpa); + gpa.destroy(self); + } +}; + +pub fn init(gpa: std.mem.Allocator) Tracks { + return .{ .gpa = gpa }; +} + +pub fn deinit(self: *Tracks) void { + for (self.open.values()) |t| t.deinit(self.gpa); + self.open.deinit(self.gpa); + for (self.ended.items) |t| t.deinit(self.gpa); + self.ended.deinit(self.gpa); + self.focus.deinit(self.gpa); + self.* = .{ .gpa = self.gpa, .clock = self.clock, .on = self.on, .cap = self.cap }; +} + +/// What the host calls with each step (`sdk.Host.recorder`). +pub fn recorder(self: *Tracks) sdk.Host.Recorder { + return .{ .ctx = self, .vtable = &.{ .wants = wants, .step = step } }; +} + +/// `doc` has opened: give it a track, when tracks are on and its owner records steps. Its base is +/// taken at the next frame's top. +pub fn opened(self: *Tracks, doc: sdk.DocHandle) void { + if (!self.on or doc.owner.step_format == 0 or doc.owner.vtable.captureDocumentState == null) return; + if (self.open.contains(doc.id)) return; + const t = self.gpa.create(Track) catch return; + t.* = .{ .doc_id = doc.id, .doc = doc, .owner = doc.owner, .format = doc.owner.step_format }; + self.open.put(self.gpa, doc.id, t) catch { + self.gpa.destroy(t); + return; + }; + self.due = true; +} + +/// `doc_id` has closed: its track ends, kept for the session. One whose base was never taken +/// holds nothing and goes. +pub fn closed(self: *Tracks, doc_id: u64) void { + const kv = self.open.fetchSwapRemove(doc_id) orelse return; + self.end(kv.value); +} + +fn end(self: *Tracks, t: *Track) void { + if (t.base == null) return self.drop(t); + self.ended.append(self.gpa, t) catch self.drop(t); +} + +fn drop(self: *Tracks, t: *Track) void { + self.held -|= t.size(); + t.deinit(self.gpa); +} + +/// At the top of a frame, before anything draws: take each state a track asked for. Returns at +/// once when none did. +pub fn tick(self: *Tracks) void { + if (!self.due) return; + self.due = false; + self.worked += 1; + const prof = core.profile.begin("fizzy", "tracks.tick"); + defer prof.end(); + for (self.open.values()) |t| { + const before = t.size(); + if (t.base == null) { + // Steps reported before the base was taken (an open then an edit in one frame) are + // in it, so they go. + t.steps.clearRetainingCapacity(); + t.bytes.clearRetainingCapacity(); + t.base = self.take(t.doc, 0) orelse continue; + } else if (t.checkpoint_due) { + var s = self.take(t.doc, t.steps.items.len) orelse continue; + // As what differs from the latest whole state, unless that is half the document or + // more: then whole, and the next ones differ from it. + if (state_delta.encode(self.gpa, t.wholeBefore(t.checkpoints.items.len), s.bytes)) |delta| { + if (delta.len * 2 < s.bytes.len) { + self.gpa.free(s.bytes); + s.bytes = delta; + s.whole = false; + } else self.gpa.free(delta); + } else |_| {} + t.checkpoints.append(self.gpa, s) catch { + self.gpa.free(s.bytes); + continue; + }; + self.thin(t); + } else continue; + t.checkpoint_due = false; + t.since_steps = 0; + t.since_bytes = 0; + t.state_len = if (t.checkpoints.getLastOrNull()) |c| (if (c.whole) 0 else c.bytes.len) else 0; + self.held = self.held - @min(self.held, before) + t.size(); + } + if (self.held > self.cap) self.shed(); +} + +/// Thin `t`'s checkpoints older than `dense_ms` to one per `sparse_steps` steps: a seek that far +/// back replays more, and the track holds less. Only deltas go: a whole state is what the deltas +/// after it are made from. +fn thin(self: *Tracks, t: *Track) void { + const now_ms = self.now(); + var kept: usize = 0; + var last_step: ?usize = null; + for (t.checkpoints.items) |c| { + const old = now_ms -| c.at >= dense_ms; + if (old and !c.whole) if (last_step) |l| if (c.step - l < sparse_steps) { + self.gpa.free(c.bytes); + continue; + }; + t.checkpoints.items[kept] = c; + kept += 1; + last_step = c.step; + } + t.checkpoints.shrinkRetainingCapacity(kept); +} + +/// Over the cap: the oldest ended tracks go first; then every open track keeps only its newest +/// checkpoint, and the whole state it is a delta of. Steps and bases stay: undo will walk them, +/// and compacting (plan step 12) is what makes them smaller. +pub fn shed(self: *Tracks) void { + const prof = core.profile.begin("fizzy", "tracks.shed"); + defer prof.end(); + var gone: usize = 0; + while (self.held > self.cap and gone < self.ended.items.len) : (gone += 1) self.drop(self.ended.items[gone]); + if (gone > 0) { + std.mem.copyForwards(*Track, self.ended.items, self.ended.items[gone..]); + self.ended.shrinkRetainingCapacity(self.ended.items.len - gone); + } + if (self.held <= self.cap) return; + for (self.open.values()) |t| { + const items = t.checkpoints.items; + if (items.len < 2) continue; + const last = items.len - 1; + // The whole state the newest is a delta of, when that is a checkpoint and not the base. + var whole: ?usize = null; + if (!items[last].whole) { + var j = last; + while (j > 0) { + j -= 1; + if (items[j].whole) { + whole = j; + break; + } + } + } + var kept: usize = 0; + for (items, 0..) |c, i| { + if (i == last or i == whole) { + items[kept] = c; + kept += 1; + continue; + } + self.held -|= c.bytes.len; + self.gpa.free(c.bytes); + } + t.checkpoints.shrinkRetainingCapacity(kept); + } +} + +fn take(self: *Tracks, doc: sdk.DocHandle, count: usize) ?Track.State { + const bytes = doc.owner.captureDocumentState(doc, self.gpa) orelse return null; + return .{ .at = self.now(), .step = count, .bytes = bytes, .fingerprint = doc.owner.documentFingerprint(doc) }; +} + +/// ms since the session's start, on the frame clock. +fn now(self: *Tracks) u32 { + const t = self.clock(); + const start = self.start_ns orelse blk: { + self.start_ns = t; + break :blk t; + }; + return std.math.lossyCast(u32, @divTrunc(t - start, std.time.ns_per_ms)); +} + +fn from(ctx: *anyopaque) *Tracks { + return @ptrCast(@alignCast(ctx)); +} + +fn wants(ctx: *anyopaque, doc_id: u64) bool { + const self = from(ctx); + return self.on and doc_id != self.muted and self.open.contains(doc_id); +} + +fn step(ctx: *anyopaque, doc_id: u64, st: sdk.Step) void { + const self = from(ctx); + const prof = core.profile.begin("fizzy", "tracks.step"); + defer prof.end(); + const t = self.open.get(doc_id) orelse return; + if (st.kind == .restarted) { + // What came before applies to nothing the document is now: a new track, from what it is. + const fresh = self.gpa.create(Track) catch return; + fresh.* = .{ .doc_id = doc_id, .doc = t.doc, .owner = t.owner, .format = t.owner.step_format }; + self.open.putAssumeCapacity(doc_id, fresh); + self.end(t); + self.due = true; + return; + } + const at = self.now(); + const last_at = if (t.steps.getLastOrNull()) |r| r.at else if (t.base) |b| b.at else at; + const effect_at = t.bytes.items.len; + t.bytes.ensureUnusedCapacity(self.gpa, st.effect.len + st.motion.len) catch return; + t.steps.ensureUnusedCapacity(self.gpa, 1) catch return; + t.bytes.appendSliceAssumeCapacity(st.effect); + t.bytes.appendSliceAssumeCapacity(st.motion); + t.steps.appendAssumeCapacity(.{ + .kind = st.kind, + .id = st.id, + .at = at, + .ms = st.ms, + .effect = .{ .start = effect_at, .len = st.effect.len }, + .motion = .{ .start = effect_at + st.effect.len, .len = st.motion.len }, + }); + t.since_steps += 1; + t.since_bytes += st.effect.len + st.motion.len; + self.held += st.effect.len + st.motion.len + @sizeOf(Track.Rec); + if (self.held > self.cap) self.shed(); + // After a pause the state is taken just after the step that ended it: near enough the + // pause's start for a seek, and taken where nothing is half applied. + const worth = t.since_bytes * state_share >= t.state_len; + if (t.since_steps >= checkpoint_max_steps or (worth and (t.since_steps >= checkpoint_steps or at -| last_at >= checkpoint_gap_ms))) { + t.checkpoint_due = true; + self.due = true; + } + + // A step in another document than the last is a cut. (Looking at a document without editing + // it is not seen here; a `fizzy.document.activated` event from the workbench is the seam.) + if (self.focus.getLastOrNull()) |f| if (f.doc_id == doc_id) return; + self.focus.append(self.gpa, .{ .at = at, .doc_id = doc_id }) catch {}; +} + +pub const ReplayError = error{ NoTrack, NoState, RestoreFailed, StepFailed, Mismatch }; + +/// Put `target` (a document of the track's owner) where `doc_id`'s track was after its first +/// `count` steps: the latest state at or before them, restored, then the steps since, applied at +/// once. `target` records none of it. Where a checkpoint stands at `count` with a fingerprint, +/// the replay must reach it (`error.Mismatch`). +pub fn replayInto(self: *Tracks, doc_id: u64, target: sdk.DocHandle, count: usize) ReplayError!void { + const prof = core.profile.begin("fizzy", "tracks.replay"); + defer prof.end(); + const t = self.trackOf(doc_id) orelse return error.NoTrack; + const n = @min(count, t.steps.items.len); + const s = t.stateBefore(n) orelse return error.NoState; + const state = t.wholeState(self.gpa, s) catch return error.RestoreFailed; + defer if (state.owned) self.gpa.free(state.bytes); + self.muted = target.id; + defer self.muted = 0; + if (!target.owner.restoreDocumentState(target, state.bytes)) return error.RestoreFailed; + for (t.steps.items[s.step..n]) |r| { + if (!target.owner.applyStep(target, t.effect(r), t.motion(r), 0)) return error.StepFailed; + } + for (t.checkpoints.items) |c| if (c.step == n) if (c.fingerprint) |want| { + if (target.owner.documentFingerprint(target) != want) return error.Mismatch; + }; +} + +/// The open document's track, or else the latest that ended for it. +pub fn trackOf(self: *Tracks, doc_id: u64) ?*Track { + if (self.open.get(doc_id)) |t| return t; + var i = self.ended.items.len; + while (i > 0) { + i -= 1; + if (self.ended.items[i].doc_id == doc_id) return self.ended.items[i]; + } + return null; +} + +// ---- tests --------------------------------------------------------------------------------- + +const testing = std.testing; + +/// A stand-in owner whose document is a buffer: its state a copy, a step written in at its start. +const Stand = struct { + var bytes: [4096]u8 = @splat(0); + var clock_ns: i128 = 0; + + fn clock() i128 { + return clock_ns; + } + fn capture(_: *anyopaque, _: sdk.DocHandle, allocator: std.mem.Allocator) anyerror![]u8 { + return allocator.dupe(u8, &bytes); + } + fn restore(_: *anyopaque, _: sdk.DocHandle, saved: []const u8) anyerror!void { + @memcpy(&bytes, saved); + } + fn fingerprint(_: *anyopaque, _: sdk.DocHandle) u64 { + return std.hash.Wyhash.hash(0, &bytes); + } + fn apply(_: *anyopaque, _: sdk.DocHandle, effect: []const u8, _: []const u8, _: u32) anyerror!void { + @memcpy(bytes[0..effect.len], effect); + } + + var state: u8 = 0; + const vtable: sdk.Plugin.VTable = .{ .captureDocumentState = capture, .restoreDocumentState = restore, .documentFingerprint = fingerprint, .applyStep = apply }; + var plugin: sdk.Plugin = .{ .state = &state, .vtable = &vtable, .id = "stand", .display_name = "Stand", .step_format = 1 }; + + fn doc(id: u64) sdk.DocHandle { + return .{ .ptr = &state, .owner = &plugin, .id = id }; + } + + fn tracks() Tracks { + bytes = @splat(0); + clock_ns = 0; + var t: Tracks = .init(testing.allocator); + t.clock = clock; + t.on = true; + return t; + } + + /// A step of `effect` (written into the document as the owner would have) at `ms` past the last. + fn step(t: *Tracks, id: u64, effect: []const u8, ms: i128) void { + clock_ns += ms * std.time.ns_per_ms; + @memcpy(bytes[0..effect.len], effect); + const rec = t.recorder(); + rec.vtable.step(rec.ctx, id, .{ .kind = .added, .id = 1, .effect = effect }); + } +}; + +test "a frame's top does work only when a track asked for some" { + var t = Stand.tracks(); + defer t.deinit(); + t.opened(Stand.doc(1)); + t.tick(); + try testing.expectEqual(@as(u64, 1), t.worked); + for (0..1000) |_| t.tick(); + try testing.expectEqual(@as(u64, 1), t.worked); + // A step that asks for nothing asks nothing of a frame. + Stand.step(&t, 1, "ab", 10); + t.tick(); + try testing.expectEqual(@as(u64, 1), t.worked); + try testing.expectEqual(@as(usize, 1), t.trackOf(1).?.steps.items.len); +} + +test "a checkpoint is what differs from the whole state, taken once the steps since are worth one" { + var t = Stand.tracks(); + defer t.deinit(); + t.opened(Stand.doc(1)); + t.tick(); + const track = t.trackOf(1).?; + const sixteen: [16]u8 = @splat(5); + // The first after the base is a small delta: 16 bytes changed of 4 KB. + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &sixteen, 1); + t.tick(); + try testing.expectEqual(@as(usize, 1), track.checkpoints.items.len); + try testing.expect(!track.checkpoints.items[0].whole); + try testing.expect(track.checkpoints.items[0].bytes.len < 64); + // A bigger change, a third of the document: still a delta, and what the next one weighs. + const third: [1500]u8 = @splat(6); + Stand.step(&t, 1, &third, 1); + for (0..checkpoint_steps - 1) |_| Stand.step(&t, 1, "", 1); + t.tick(); + try testing.expectEqual(@as(usize, 2), track.checkpoints.items.len); + try testing.expect(!track.checkpoints.items[1].whole); + // Steps worth less than that delta ask for none. + for (0..checkpoint_steps) |_| Stand.step(&t, 1, "x", 1); + t.tick(); + try testing.expectEqual(@as(usize, 2), track.checkpoints.items.len); + // However little they are worth, a seek replays no more than the most. + for (0..checkpoint_max_steps) |_| Stand.step(&t, 1, "", 1); + t.tick(); + try testing.expectEqual(@as(usize, 3), track.checkpoints.items.len); + // Changed by more than half: kept whole, and what the next deltas are made from. + const most: [3000]u8 = @splat(7); + Stand.step(&t, 1, &most, 1); + for (0..checkpoint_max_steps) |_| Stand.step(&t, 1, "", 1); + t.tick(); + try testing.expect(track.checkpoints.getLast().whole); + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &sixteen, 1); + t.tick(); + try testing.expect(!track.checkpoints.getLast().whole); + try testing.expectEqual(t.held, track.size()); + + // A replay lands where the document was through a delta, and checks the fingerprint kept. + const want = Stand.bytes; + @memset(&Stand.bytes, 0); + try t.replayInto(1, Stand.doc(2), track.steps.items.len); + try testing.expectEqualSlices(u8, &want, &Stand.bytes); + // And through the one before the whole state. + try t.replayInto(1, Stand.doc(2), track.checkpoints.items[1].step); +} + +test "checkpoints older than an hour thin to one per sparse_steps" { + var t = Stand.tracks(); + defer t.deinit(); + t.opened(Stand.doc(1)); + t.tick(); + const track = t.trackOf(1).?; + // A checkpoint every `checkpoint_steps` steps of 16 bytes (a state's worth), a ms apart: + // dense while young. + var buf: [16]u8 = @splat(7); + for (0..sparse_steps * 2 / checkpoint_steps) |i| { + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &buf, 1); + buf[0] = @truncate(i); + t.tick(); + } + const young = track.checkpoints.items.len; + try testing.expectEqual(@as(usize, sparse_steps * 2 / checkpoint_steps), young); + // An hour on, the next checkpoint thins the old ones. + Stand.clock_ns += dense_ms * std.time.ns_per_ms; + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &buf, 1); + t.tick(); + const kept = track.checkpoints.items; + try testing.expect(kept.len < young); + for (kept[1 .. kept.len - 1], kept[0 .. kept.len - 2]) |c, prev| try testing.expect(c.step - prev.step >= sparse_steps); + try testing.expectEqual(t.held, track.size()); +} + +test "over the cap, ended tracks go first, then all but each open track's newest checkpoint" { + var t = Stand.tracks(); + defer t.deinit(); + for ([_]u64{ 1, 2 }) |id| t.opened(Stand.doc(id)); + t.tick(); + var buf: [16]u8 = @splat(3); + for (0..3) |_| { + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &buf, 1); + t.tick(); + } + try testing.expectEqual(@as(usize, 3), t.trackOf(1).?.checkpoints.items.len); + t.closed(2); + try testing.expectEqual(@as(usize, 1), t.ended.items.len); + + t.cap = t.held - 1; + t.shed(); + try testing.expectEqual(@as(usize, 0), t.ended.items.len); + try testing.expect(t.trackOf(2) == null); + t.cap = 1; + t.shed(); + const track = t.trackOf(1).?; + try testing.expectEqual(@as(usize, 1), track.checkpoints.items.len); + try testing.expectEqual(track.steps.items.len, track.checkpoints.items[0].step); + try testing.expectEqual(t.held, track.size()); +} diff --git a/app/automation/Service.zig b/app/automation/Service.zig index 7c1400a56..b8d7b7a4f 100644 --- a/app/automation/Service.zig +++ b/app/automation/Service.zig @@ -27,6 +27,8 @@ gpa: std.mem.Allocator, driver: *LiveDriver, /// Something else that drives the app, refusing a tape while it does — fizzy's demos. other: ?Other = null, +/// Told who each live tape plays for (`OnAuthor`). Null: nobody listens, and authors are dropped. +on_author: ?OnAuthor = null, /// What `snapshot` takes, framed by the app (`Snapshot.beginFrame` / `endFrame`). Null: no /// snapshots, and `snapshotText` is always null. snapshots: ?*Snapshot = null, @@ -51,6 +53,13 @@ pub const Other = struct { driving: *const fn (ctx: *anyopaque) bool, }; +/// Who the next live tape plays for (`Api.playAs`; null: itself), told to the app that runs its +/// commands and frames: fizzy's demo, which acts as them (`Host.act`). +pub const OnAuthor = struct { + ctx: *anyopaque, + set: *const fn (ctx: *anyopaque, author: ?sdk.Author) void, +}; + const Past = struct { ticket: Api.Ticket, outcome: Api.Outcome, @@ -63,6 +72,7 @@ pub fn bind(self: *Service) void { const vtable: Api.VTable = .{ .play = play, + .playAs = playAs, .stop = stop, .outcome = outcome, .settled = settled, @@ -82,13 +92,21 @@ fn othersDriving(self: *Service) bool { } fn play(ctx: *anyopaque, bytes: []const u8) Api.Play { - const self = from(ctx); + return playFor(from(ctx), bytes, null); +} + +fn playAs(ctx: *anyopaque, bytes: []const u8, author: sdk.Author) Api.Play { + return playFor(from(ctx), bytes, author); +} + +fn playFor(self: *Service, bytes: []const u8, author: ?sdk.Author) Api.Play { if (self.driver.playing() or self.othersDriving()) return .busy; var check = Input.check; check.live = true; const owned = Tape.load(self.gpa, bytes, check) catch |err| return .{ .invalid = @errorName(err) }; // Read before the driver starts over and forgets it. const last = self.driverOutcome(); + if (self.on_author) |o| o.set(o.ctx, author); self.driver.play(owned) catch |err| return switch (err) { error.Busy => .busy, else => .{ .invalid = @errorName(err) }, diff --git a/app/crash/crash.zig b/app/crash/crash.zig index b56154aa5..abf5fcced 100644 --- a/app/crash/crash.zig +++ b/app/crash/crash.zig @@ -11,10 +11,9 @@ //! - a panic in the app's own code (`panic`, the root file's panic handler); //! - a fault anywhere in the process (`handleSegfault`, std's segfault handler, which the app //! switches on in every build mode): SIGSEGV, SIGBUS, SIGILL, SIGFPE, or an access violation; -//! - an abort or trap anywhere (SIGABRT, SIGTRAP; POSIX only). A plugin's panic lands here: its -//! dylib has its own std, whose panic handler prints the message and aborts. The message then -//! reaches stderr but not the report; carrying it is the dylib entry's `panic` forwarding to -//! the host's `panic` through an exported symbol, a follow-up. +//! - a panic in a plugin dylib, which has its own std: the generated root's handler +//! (`sdk.dylib.panic`) forwards it through `EditorAPI.panic` to `panic`, message and frames; +//! - an abort or trap anywhere (SIGABRT, SIGTRAP; POSIX only). //! //! After the report, each hands on to what would have run without it: std's own handler, which //! prints the symbolized stack trace, or the signal's default action. diff --git a/app/history_transport.zig b/app/history_transport.zig new file mode 100644 index 000000000..7aae14312 --- /dev/null +++ b/app/history_transport.zig @@ -0,0 +1,146 @@ +//! The rules of a document's transport (`HistoryTransport`): when its bar shows and for how long, +//! how far a held undo or redo goes each press, and which step a point on its line is. +//! +//! Pure decisions over times and sizes, std-only, so they test without a window +//! (`fizzy-history-transport-tests`). +const std = @import("std"); + +/// How long the bar stays once nothing touches it (`plans/RECORDING_PLAN.md`, "Undo is a seek"). +pub const linger_ns: i128 = 3 * std.time.ns_per_s; +/// How long it takes to come in, and to go. +pub const fade_ns: i128 = 160 * std.time.ns_per_ms; + +/// When the bar was last asked for: an undo, a redo, the pointer on it. +pub const Shown = struct { + at_ns: ?i128 = null, + + /// Asked for at `now_ns`. Still coming in, it carries on; showing, it stays in and its linger + /// starts over; gone, it comes in from nothing. + pub fn stir(self: *Shown, now_ns: i128) void { + if (self.at_ns) |at| if (now_ns - at < fade_ns) return; + self.at_ns = if (self.openness(now_ns) > 0) now_ns - fade_ns else now_ns; + } + + /// How much of the bar shows at `now_ns`, 0 gone to 1 in: in over `fade_ns`, held, and out over + /// `fade_ns` once it has lingered. + pub fn openness(self: Shown, now_ns: i128) f32 { + const at = self.at_ns orelse return 0; + const since = now_ns - at; + if (since < 0) return 1; + if (since < fade_ns) return frac(since, fade_ns); + const left = linger_ns + fade_ns - since; + if (left <= 0) return 0; + return if (left < fade_ns) frac(left, fade_ns) else 1; + } + + /// When the bar next changes on its own (comes fully in, starts to go, is gone): what its timer + /// waits for. Null when it is gone, so a bar not showing wakes nothing. + pub fn nextChange(self: Shown, now_ns: i128) ?i128 { + const at = self.at_ns orelse return null; + for ([_]i128{ at + fade_ns, at + linger_ns, at + linger_ns + fade_ns }) |t| if (t > now_ns) return t; + return null; + } + + fn frac(n: i128, d: i128) f32 { + return @floatCast(@as(f64, @floatFromInt(n)) / @as(f64, @floatFromInt(d))); + } +}; + +/// Presses closer together than this are a key held down, its repeats. +pub const repeat_gap_ns: i128 = 150 * std.time.ns_per_ms; + +/// How far each press of a held undo or redo goes: one step at a time for the first few, then +/// further, so holding the key scrubs a long history in a second or two. +pub const Repeat = struct { + last_ns: ?i128 = null, + /// Which way the run goes: a press the other way starts a new one. + forward: bool = false, + /// Presses in this run, the first one included. + run: u32 = 0, + + /// A press at `now_ns`, of redo (`forward`) or undo: how many steps it moves. + pub fn press(self: *Repeat, now_ns: i128, forward: bool) usize { + const held = if (self.last_ns) |l| now_ns - l < repeat_gap_ns and forward == self.forward else false; + self.run = if (held) self.run + 1 else 1; + self.last_ns = now_ns; + self.forward = forward; + return stepsAt(self.run); + } + + /// The step a run of `run` presses takes: 1 for the first six (a deliberate few undos stay one + /// at a time), then doubling every four presses, up to 32. + pub fn stepsAt(run: u32) usize { + if (run <= 6) return 1; + const doublings: u5 = @intCast(@min(5, (run - 7) / 4 + 1)); + return @as(usize, 1) << doublings; + } +}; + +/// The step a point `x` on a line from `x0` to `x1` means, in a history of `n` steps: how many +/// of them are applied, 0 (before the first) to `n`. The line is split into `n + 1` equal stops, +/// the point taking the nearest. +pub fn stopAt(x: f32, x0: f32, x1: f32, n: usize) usize { + if (n == 0 or x1 <= x0) return 0; + const f = std.math.clamp((x - x0) / (x1 - x0), 0, 1); + return @intFromFloat(@round(f * @as(f32, @floatFromInt(n)))); +} + +/// Where stop `i` of `n` sits on the line from `x0` to `x1`. +pub fn stopX(i: usize, x0: f32, x1: f32, n: usize) f32 { + if (n == 0) return x0; + return x0 + (x1 - x0) * @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(n)); +} + +// ---- tests --------------------------------------------------------------------------------- + +const testing = std.testing; +const ms = std.time.ns_per_ms; + +test "the bar comes in, lingers, goes, and wakes nothing once gone" { + var s: Shown = .{}; + try testing.expectEqual(@as(f32, 0), s.openness(0)); + try testing.expect(s.nextChange(0) == null); + s.stir(1000 * ms); + try testing.expectEqual(@as(f32, 0.5), s.openness(1080 * ms)); + try testing.expectEqual(@as(f32, 1), s.openness(2000 * ms)); + try testing.expectEqual(@as(?i128, 1000 * ms + linger_ns), s.nextChange(2000 * ms)); + try testing.expect(s.openness(1000 * ms + linger_ns + fade_ns / 2) < 1); + try testing.expectEqual(@as(f32, 0), s.openness(1000 * ms + linger_ns + fade_ns)); + try testing.expect(s.nextChange(1000 * ms + linger_ns + fade_ns) == null); +} + +test "asked for again while it shows, it stays in rather than fading in again" { + var s: Shown = .{}; + s.stir(0); + try testing.expectEqual(@as(f32, 1), s.openness(1000 * ms)); + s.stir(2500 * ms); + try testing.expectEqual(@as(f32, 1), s.openness(2500 * ms)); + // Its linger starts again from the stir. + try testing.expectEqual(@as(f32, 1), s.openness(2500 * ms + linger_ns - fade_ns)); + try testing.expectEqual(@as(f32, 0), s.openness(2500 * ms + linger_ns + 2 * fade_ns)); + // Gone, a stir brings it in from nothing. + s.stir(60_000 * ms); + try testing.expect(s.openness(60_000 * ms) < 0.01); +} + +test "a held key goes one step at a time, then further" { + var r: Repeat = .{}; + var got: [16]usize = undefined; + for (&got, 0..) |*g, i| g.* = r.press(@as(i128, @intCast(i)) * 50 * ms, false); + try testing.expectEqualSlices(usize, &.{ 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 8, 8 }, &got); + try testing.expectEqual(@as(usize, 32), Repeat.stepsAt(1000)); + // The other way, at once, is a first press: overshooting and coming back is one step. + try testing.expectEqual(@as(usize, 1), r.press(16 * 50 * ms, true)); + // A pause, and the next press is a first one again. + try testing.expectEqual(@as(usize, 1), r.press(10_000 * ms, true)); +} + +test "a point on the line is the nearest step" { + try testing.expectEqual(@as(usize, 0), stopAt(0, 0, 100, 10)); + try testing.expectEqual(@as(usize, 10), stopAt(100, 0, 100, 10)); + try testing.expectEqual(@as(usize, 5), stopAt(52, 0, 100, 10)); + try testing.expectEqual(@as(usize, 0), stopAt(-40, 0, 100, 10)); + try testing.expectEqual(@as(usize, 10), stopAt(140, 0, 100, 10)); + try testing.expectEqual(@as(usize, 0), stopAt(50, 0, 100, 0)); + for (0..11) |i| try testing.expectEqual(i, stopAt(stopX(i, 20, 220, 10), 20, 220, 10)); +} diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index f2af5dc34..a295a8a97 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -926,6 +926,7 @@ test { _ = @import("Region.zig"); _ = @import("Drop.zig"); _ = @import("ViewDrag.zig"); + _ = @import("Picker.zig"); } /// Declare a region — the verb form of `Region.init`, so a shape writes `f.region(...)` beside /// `f.split(...)` and never names the type. Same arrangement as `dvui.box` over `BoxWidget.init`. diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index efa7b7040..c159d52c0 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -11,6 +11,10 @@ //! Cards assign a surface here. That is how a sidebar view stays in the //! sidebar, and how a plugin readme lands in the center — keywords are the //! default guess, an assignment is the answer. "Back to defaults" undoes it. +//! The cards are listed under the place holding them, or else the place their +//! keywords are for — this place first, then each other place by its name, then +//! "Other" — so a view made for the panel reads as one, wherever the picker was +//! opened, and a view the user moved reads under where it went. //! //! A card is also a handle. Dragged out of the popup it becomes the same //! floating view the corner button lifts out of a place (`ViewDrag`), and @@ -28,6 +32,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); const sdk = @import("fizzy_sdk"); +const icons = @import("icons"); const Split = core.widgets.Split; const Layout = @import("Layout.zig"); const State = @import("State.zig"); @@ -143,7 +148,10 @@ pub fn draw(self: *Picker, f: *Layout) void { // On the tree, any leaf with a sibling can go — a declared place's pin moves to the // sibling. Off it, only a minted leaf can. A float can always go: it closes, and what // it holds goes home. - const removable = created or state.canRemove(region.name) or state.floats.find(region.name) != null; + // A minted half split again goes by merging its other half into it (`ViewDrag.closeEmptied`); + // with that half split too there is nothing to merge, and Remove would only clear it. + const merges = !state.splits.isSplit(region.name) or state.siblingLeaf(region.name) != null; + const removable = (created and merges) or state.canRemove(region.name) or state.floats.find(region.name) != null; const assigned = state.assignment(region.name); const showing = if (assigned) |ids| ids.len > 0 else f.selectedIn(®ion) != null; @@ -156,10 +164,10 @@ pub fn draw(self: *Picker, f: *Layout) void { .padding = .{ .y = 4 }, }); defer controls.deinit(); - if (modeButton(@src(), "Single", region.shows == .one, 1)) { + if (modeButton(@src(), "Single", "picker.mode:one", region.shows == .one, 1)) { setShows(f, ®ion, .one); } - if (modeButton(@src(), "Multiple", region.shows == .many, 2)) { + if (modeButton(@src(), "Multiple", "picker.mode:many", region.shows == .many, 2)) { setShows(f, ®ion, .many); } @@ -185,6 +193,7 @@ pub fn draw(self: *Picker, f: *Layout) void { .style = .control, }); const split_from = split.activeRect(); + core.anchor.mark(split.data(), "picker.action:split", .{}); { var label = dvui.box(@src(), .{ .dir = .horizontal }, .{}); defer label.deinit(); @@ -207,14 +216,14 @@ pub fn draw(self: *Picker, f: *Layout) void { .frost = core.widgets.menuFrost(), }, core.widgets.menuSurfaceOptions()); defer choices.deinit(); - if (core.widgets.menuRow(@src(), "Vertical", .{}) != null) { + if (core.widgets.menuRow(@src(), "Vertical", .{ .icon = icons.tvg.lucide.@"columns-2", .anchor = "picker.split:vertical" }) != null) { choices.close(); f.splitNamed(region.name, .horizontal); self.close(gpa); state.discardSnapshots(gpa); return; } - if (core.widgets.menuRow(@src(), "Horizontal", .{ .id_extra = 1 }) != null) { + if (core.widgets.menuRow(@src(), "Horizontal", .{ .icon = icons.tvg.lucide.@"rows-2", .id_extra = 1, .anchor = "picker.split:horizontal" }) != null) { choices.close(); f.splitNamed(region.name, .vertical); self.close(gpa); @@ -224,17 +233,17 @@ pub fn draw(self: *Picker, f: *Layout) void { } } if (!created) { - if (actionButton(@src(), "Clear", showing, 2)) { + if (actionButton(@src(), "Clear", "picker.action:clear", showing, 2)) { clearRegion(f, region.name); } - if (actionButton(@src(), "Defaults", assigned != null, 4)) { + if (actionButton(@src(), "Defaults", "picker.action:defaults", assigned != null, 4)) { state.unassign(gpa, region.name); state.markDirty(); dvui.refresh(null, @src(), null); } } if (removable) { - if (actionButton(@src(), "Remove", true, 3)) { + if (actionButton(@src(), "Remove", "picker.action:remove", true, 3)) { removeRegion(f, ®ion); self.close(gpa); state.discardSnapshots(gpa); @@ -269,11 +278,32 @@ pub fn draw(self: *Picker, f: *Layout) void { var row: ?*dvui.BoxWidget = null; defer if (row) |r| r.deinit(); var col: usize = 0; - for (offered(f, ®ion, &query)) |ranked| { + const places = placesFor(f, ®ion); + const ranked_all = offered(f, ®ion, &query); + // Each card under the place holding it, or else the place its keywords make it for; this one + // first, so what belongs in the explorer and what in the panel reads off the headers. A + // query ranks within each. Each card's section is worked out once, not once per section. + const sections = f.arena.alloc(usize, ranked_all.len) catch return; + for (ranked_all, sections) |ranked, *section| { + const s = &f.host.surfaces.items[ranked.item]; + section.* = sectionOf(places, s.id, s.keywords) orelse places.len; + } + var current: ?usize = null; + for (0..places.len + 1) |section| for (ranked_all, sections) |ranked, card_section| { // `i` is the surface's place in the registry, not in this list: a card keeps its widget // id while the filter reorders what is around it. const i = ranked.item; const s = &f.host.surfaces.items[i]; + if (card_section != section) continue; + if (current != section) { + // This place's own header was drawn above, empty, when nothing else came first. + if (current == null and section != 0) emptyHome(®ion, &query); + current = section; + if (row) |r| r.deinit(); + row = null; + col = 0; + sectionHeader(if (section < places.len) places[section].name else "Other", section); + } if (col == 0) { if (row) |r| r.deinit(); row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = i, .expand = .horizontal }); @@ -330,7 +360,8 @@ pub fn draw(self: *Picker, f: *Layout) void { } state.markDirty(); dvui.refresh(null, @src(), null); - } + }; + if (current == null) emptyHome(®ion, &query); if (state.store_catalog) |store| drawStoreSection(f, ®ion, store, &row, &col, &query); } @@ -350,6 +381,106 @@ fn offered(f: *Layout, region: *const Layout.Region, query: *const core.fuzzy.Qu return out.items; } +/// A place a card can be listed under: a region's name, the keywords it accepts and the views it +/// holds now. +const Place = struct { + name: []const u8, + keywords: []const []const u8, + holds: []const *sdk.Surface = &.{}, +}; + +/// The places the picker sorts cards under: `region` itself first, then each other place the +/// shape declared, in its order — never a document pane, which offers only documents. Places +/// resolved by keywords are one per keyword group (the icon rail and the explorer it chooses for +/// are one place); a place resolved by name (a split's leaf, a float) is its own. +fn placesFor(f: *Layout, region: *const Layout.Region) []const Place { + var out: std.ArrayListUnmanaged(Place) = .empty; + out.append(f.arena, .{ .name = region.name, .keywords = region.keywords, .holds = f.matchingIn(region) }) catch return &.{}; + for (f.state.regions.items) |*r| { + if (r.name.len == 0 or r.kind_slot) continue; + const seen = for (out.items) |p| { + if (std.mem.eql(u8, p.name, r.name)) break true; + if (!r.by_name and sdk.keywords.groupKey(p.keywords) == sdk.keywords.groupKey(r.keywords)) break true; + } else false; + if (!seen) out.append(f.arena, .{ .name = r.name, .keywords = r.keywords, .holds = f.matchingIn(r) }) catch break; + } + return out.items; +} + +/// The place a card is listed under. The place holding the view, when one does: a view the user +/// moved reads under where it is, not where it came from. Otherwise the place its keywords are +/// for, by the fit `Layout` binds with (`sdk.keywords.strength`); between equals the first, +/// which puts `places[0]`, the place being edited, ahead of the rest. Null when neither. +fn sectionOf(places: []const Place, id: []const u8, keywords: []const []const u8) ?usize { + for (places, 0..) |p, i| if (contains(p.holds, id)) return i; + var best: ?usize = null; + var best_fit: sdk.keywords.Fit = .none; + for (places, 0..) |p, i| { + const fit = sdk.keywords.strength(p.keywords, keywords); + if (@backingInt(fit) > @backingInt(best_fit)) { + best = i; + best_fit = fit; + } + } + return best; +} + +test "a view is listed under the place its keywords are for, the edited place first" { + const kw = sdk.keywords.ide; + const places = [_]Place{ + .{ .name = "Panel", .keywords = kw.panel }, + .{ .name = "Sidebar", .keywords = kw.sidebar }, + .{ .name = "Main", .keywords = kw.main }, + }; + try std.testing.expectEqual(@as(?usize, 1), sectionOf(&places, "v", &.{"explorer"})); + try std.testing.expectEqual(@as(?usize, 0), sectionOf(&places, "v", &.{"output"})); + // Made for both: the place being edited wins the tie. + try std.testing.expectEqual(@as(?usize, 0), sectionOf(&places, "v", &.{ "sidebar", "panel" })); + // A specific sub-place still lands under the area that holds it. + try std.testing.expectEqual(@as(?usize, 2), sectionOf(&places, "v", &.{"main.document"})); + try std.testing.expectEqual(@as(?usize, null), sectionOf(&places, "v", &.{"inspector"})); + try std.testing.expectEqual(@as(?usize, null), sectionOf(&places, "v", &.{})); +} + +test "a view a place holds is listed under that place, whatever its keywords" { + var files: sdk.Surface = .{ .id = "files", .title = "Files", .keywords = sdk.keywords.ide.sidebar, .draw = undefined }; + const holding = [_]*sdk.Surface{&files}; + const places = [_]Place{ + .{ .name = "Sidebar", .keywords = sdk.keywords.ide.sidebar }, + .{ .name = "Panel", .keywords = sdk.keywords.ide.panel, .holds = &holding }, + }; + try std.testing.expectEqual(@as(?usize, 1), sectionOf(&places, "files", files.keywords)); + // A split's new leaf accepts no view's keywords, and still takes the one put in it. + const leaf = [_]Place{ + .{ .name = "Panel/r1", .keywords = &.{"slot"}, .holds = &holding }, + .{ .name = "Sidebar", .keywords = sdk.keywords.ide.sidebar }, + }; + try std.testing.expectEqual(@as(?usize, 0), sectionOf(&leaf, "files", files.keywords)); +} + +/// The place being edited, when no card falls under it: its header still leads, so the popup +/// says which place it fills — a split's new leaf accepts no view's keywords until one is put in. +fn emptyHome(region: *const Layout.Region, query: *const core.fuzzy.Query) void { + var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer box.deinit(); + core.anchor.mark(box.data(), "picker.empty", .{}); + sectionHeader(region.name, 0); + dvui.labelNoFmt(@src(), if (query.isEmpty()) "Nothing here yet." else "No matching views here.", .{}, .{ + .padding = .{ .x = 4, .h = 4 }, + .color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.45) }, + }); +} + +/// A heading over a run of cards — a place's name, or the store. +fn sectionHeader(title: []const u8, id_extra: usize) void { + dvui.labelNoFmt(@src(), title, .{}, .{ + .id_extra = id_extra, + .font = dvui.Font.theme(.heading), + .padding = .{ .y = 10, .x = 4 }, + .color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.6) }, + }); +} + /// Where a view comes from, as its card says it: the plugin's name, or "Built-in" for one the app /// itself registered (Output, Settings). Not the app's own name — this picker is the framework's, /// and an app built on it is not fizzy. @@ -606,9 +737,9 @@ fn setShows(f: *Layout, region: *const Layout.Region, shows: Layout.Region.Shows dvui.refresh(null, @src(), null); } -fn modeButton(src: std.builtin.SourceLocation, label: []const u8, on: bool, id_extra: usize) bool { +fn modeButton(src: std.builtin.SourceLocation, label: []const u8, name: []const u8, on: bool, id_extra: usize) bool { const theme = dvui.themeGet(); - return dvui.button(src, label, .{}, .{ + return namedButton(src, label, name, .{ .font = actionFont(), .padding = .{ .x = 6, .y = 2, .w = 6, .h = 2 }, .margin = .{ .x = 4 }, @@ -625,9 +756,11 @@ fn actionFont() dvui.Font { /// One of the picker's actions. Disabled, it keeps its place (dimmed, no hover, never clicked) /// rather than leaving a gap the row closes up over. -fn actionButton(src: std.builtin.SourceLocation, label: []const u8, enabled: bool, id_extra: usize) bool { +fn actionButton(src: std.builtin.SourceLocation, label: []const u8, name: []const u8, enabled: bool, id_extra: usize) bool { const theme = dvui.themeGet(); - const clicked = dvui.button(src, label, .{}, .{ + // Named only while it can be pressed: a dimmed one swallows the click, and a driver waiting + // for the name should wait for the action, not for the button. + const clicked = namedButton(src, label, if (enabled) name else null, .{ .font = actionFont(), .padding = .{ .x = 6, .y = 2, .w = 6, .h = 2 }, .margin = .{ .w = 4 }, @@ -640,6 +773,23 @@ fn actionButton(src: std.builtin.SourceLocation, label: []const u8, enabled: boo return enabled and clicked; } +/// `dvui.button`, named `name` (when there is one) for a demo or an agent to aim at (`core.anchor`, `picker.*` in +/// docs/AUTOMATION.md). The row's buttons come and go with the place — Remove sits where Split +/// sat on a place with nothing to Clear — so a point read off one place's picker can land on +/// another action in the next; a name cannot. +fn namedButton(src: std.builtin.SourceLocation, label: []const u8, name: ?[]const u8, opts: dvui.Options) bool { + var bw: dvui.ButtonWidget = undefined; + bw.init(src, .{}, opts); + defer bw.deinit(); + if (name) |n| core.anchor.mark(bw.data(), "{s}", .{n}); + bw.processEvents(); + bw.drawBackground(); + const clicked = bw.clicked(); + dvui.labelNoFmt(@src(), label, .{ .align_x = 0.5, .align_y = 0.5 }, opts.strip().override(bw.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 })); + bw.drawFocus(); + return clicked; +} + fn contains(list: []const *sdk.Surface, id: []const u8) bool { for (list) |s| if (std.mem.eql(u8, s.id, id)) return true; return false; @@ -685,7 +835,11 @@ fn removeRegion(f: *Layout, region: *const Layout.Region) void { } } const forget = region.forget_when_empty or f.state.splits.canForget(region.name); - if (forget) { + if (forget and f.state.splits.canForget(region.name) and f.state.splits.isSplit(region.name)) { + // Split again, the place under this name is its whole split: the half that kept the + // name goes the way emptying it does, its other half's views merging into it. + _ = ViewDrag.closeEmptied(f, region.name); + } else if (forget) { if (region.id != .zero and (dvui.dataGet(null, region.id, "_size", f32) orelse 0) > 0) { Split.close(region.id); f.extents_changed = true; @@ -746,11 +900,7 @@ fn drawStoreSection( } col.* = 0; - dvui.labelNoFmt(@src(), "Store", .{}, .{ - .font = dvui.Font.theme(.heading), - .padding = .{ .y = 10, .x = 4 }, - .color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.6) }, - }); + sectionHeader("Store", 10_000); for (offers.items, 0..) |offer, i| { if (col.* == 0) { diff --git a/app/layout/Region.zig b/app/layout/Region.zig index cfb85bae7..b44cb67d7 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -22,6 +22,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); +const icons = @import("icons"); const sdk = @import("fizzy_sdk"); const Split = core.widgets.Split; const Layout = @import("Layout.zig"); @@ -67,7 +68,8 @@ resize: bool = false, forget_when_empty: bool = false, /// How this place lays its children. dir: dvui.enums.Direction = .vertical, -/// This frame's content size, in points. A menu split halves one axis. +/// This frame's border box, in its parent's units (points, unless something above it scales): +/// the reach a split of it halves along one axis. size: dvui.Size = .{}, /// This frame's border in physical pixels, for hit-testing a view drag. bounds: dvui.Rect.Physical = .{}, @@ -476,7 +478,8 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio // carries the asymmetry — out with a little overshoot, in without any (`core.anim.slide`). extent = Split.eased(id, target); dvui.dataSet(null, id, "_size", chosen); - if (init_opts.name.len > 0) persistExtent(self, init_opts, id, chosen, extent); + // A leaf draws on regardless: it walks no tree node. + if (init_opts.name.len > 0) _ = persistExtent(self, init_opts, id, chosen, extent); // Pin both ends. A minimum alone is only a floor, so a region whose content wants to be // wider than the size the user dragged it to simply stays wider, and the split appears to @@ -579,8 +582,7 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio const box = dvui.box(src, .{ .dir = init_opts.dir }, box_opts); if (init_opts.resize) Split.recordEdges(id, box.data(), axis); if (init_opts.name.len > 0) { - const cr = box.data().contentRect(); - self.state.setPlaceMetrics(init_opts.name, .{ .w = cr.w, .h = cr.h }, box.data().borderRectScale().r); + self.state.setPlaceMetrics(init_opts.name, box.data()); core.anchor.mark(box.data(), "region:{s}", .{init_opts.name}); } self.containers[self.depth] = .{ @@ -650,8 +652,10 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio /// How close the pointer must be to a region's top-right corner, in points, before the button /// shows. Far enough that a glance toward the corner finds it; near enough that it never /// appears while the user is working in the middle of the region: the button and a margin round it. -const corner_reach: f32 = core.widgets.round_button_size + 28; -const corner_button_size: f32 = core.widgets.round_button_size; +const corner_reach: f32 = core.widgets.glass_button.size + 28; +const corner_button_size: f32 = core.widgets.glass_button.size; +/// The button's distance in from the place's corner: room for the glass's shadow ring. +const corner_inset: f32 = 8; /// The small button in a region's top-right corner that opens the picker for it. This is the /// piece that makes placement *visual*: a user looks at the place in the window they want to @@ -736,19 +740,17 @@ fn cornerButton(self: *Layout, opts: InitOptions, keywords: []const []const u8, .label = .{ .text = "Arrange" }, .tab_index = 0, .rect = .{ - .x = content.w - corner_button_size - 4, - .y = 4, + .x = content.w - corner_button_size - corner_inset, + .y = corner_inset, .w = corner_button_size, .h = corner_button_size, }, - // Entypo's grid draws its squares well inside its own box, so the glyph is given nearly - // all of the button. - .padding = dvui.Rect.all(2), - .corners = dvui.CornerRect.all(corner_button_size / 2), - .background = true, - .color_fill = .{ .color = theme.color(.control, .fill).opacity(@max(alpha, 0.35)) }, - .border = dvui.Rect.all(1), - .color_border = .{ .color = theme.color(.control, .border).opacity(@max(alpha, 0.35)) }, + .padding = .all(0), + .margin = .{}, + .corners = .round(corner_button_size / 2), + .background = false, + .border = .all(0), + .box_shadow = null, }); defer bw.deinit(); bw.processEvents(); @@ -794,14 +796,25 @@ fn cornerButton(self: *Layout, opts: InitOptions, keywords: []const []const u8, } if (alpha > 0.01 and !dragged and !self.state.view_drag.active()) { - bw.drawBackground(); - core.icon.icon(@src(), "regions", dvui.entypo.grid, .{ - .fill_color = .{ .color = if (bw.hovered()) theme.color(.highlight, .fill) else theme.color(.control, .fill).lerp(theme.color(.control, .text), alpha) }, - // A clear stroke makes it one flat colour, which the icon cache tints rather than - // bakes: baked, every step of the fade (it comes and goes as a drag crosses a place) - // rasterized a new texture, two render-target switches a frame. - .stroke_color = .transparent, - }, .{ .expand = .both }); + // A glass bubble, as the controls over a document are (`core.widgets.glass_button`). Glass + // has no opacity, so it fades by growing in from its centre, its shadow with it. + const br = bw.data().borderRectScale(); + const grown = br.r.insetAll(br.r.w * (1 - alpha) / 2); + // An empty place's corner is its hatch, which does not change: read the frost once. A + // filled one's is the view's own content, read while the button shows. + const witness: ?u64 = if (filled) null else core.widgets.glass_button.witness(grown, 0); + core.dialogs.glassBubbles(bw.data().id, &.{.{ .rect = grown, .light = core.widgets.glass_button.light(&bw) }}, br.s, .{ .shadow = alpha, .witness = witness }); + // White, tinted by the text colour: the icon cache tints a flat colour rather than baking + // it, and every step of the fade (it comes and goes as a drag crosses a place) would + // otherwise rasterize a new texture, two render-target switches a frame. + core.icon.icon(@src(), "regions", icons.tvg.lucide.@"layout-grid", .{ .fill_color = .white, .stroke_color = .white }, .{ + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1, .h = 1 }, + .padding = .all(corner_button_size * 0.3 * alpha + (1 - alpha) * corner_button_size / 2), + .color_text = .{ .color = theme.color(.content, .text).opacity(alpha) }, + }); } if (bw.clicked() and !dragged) { self.state.openPicker(self.gpa, opts.name, bw.data().rectScale().r.toNatural().bottomLeft()); @@ -902,29 +915,39 @@ fn drawContentsPhotographed( ViewDrag.keepShot(self, shot, &pic); } -fn persistExtent(self: *Layout, opts: InitOptions, id: dvui.Id, chosen: f32, shown: f32) void { +/// Remember the extent of the place `opts` names, or forget the place when it has closed with +/// nothing in it. Returns true when it forgot it: the split tree no longer has it, and the nodes +/// a caller is walking to draw it may be gone, so it draws nothing more of it this frame. +fn persistExtent(self: *Layout, opts: InitOptions, id: dvui.Id, chosen: f32, shown: f32) bool { _ = shown; - const kept = if (self.state.assignment(opts.name)) |ids| ids.len > 0 else false; // A live drag writes a zero on the way through closed; forgetting here removes the // region and the next frame recreates a sentinel under the pointer — the pop. const dragging = dvui.dataGet(null, id, "_drag", bool) orelse false; - if (opts.forget_when_empty and !kept and !dragging) { - if (chosen <= 0) { - // Still travelling shut: collapsing here drops the leaf and the - // next frame has nothing to ease. `shown` can rest above zero - // after the curve, so "animation finished" is the real signal. - if (dvui.animationGet(id, "_ease") != null) { - if (self.state.setExtent(self.gpa, opts.name, 0)) self.extents_changed = true; - return; - } - if (self.state.clearExtent(self.gpa, opts.name)) self.extents_changed = true; - if (self.state.splits.collapse(self.gpa, opts.name)) self.extents_changed = true; - self.state.unassign(self.gpa, opts.name); - dvui.refresh(null, @src(), id); - return; + if (opts.forget_when_empty and !dragging and chosen <= 0) forget: { + // A place split again is every place in it (`SplitTree.Forest.collapse`): kept while any + // of them holds a view, and all of them forgotten with it. + const covered = self.state.splits.placeLeaves(self.arena, opts.name); + for (covered) |n| { + if (self.state.assignment(n)) |ids| if (ids.len > 0) break :forget; + } + // Still travelling shut: collapsing here drops the leaf and the + // next frame has nothing to ease. `shown` can rest above zero + // after the curve, so "animation finished" is the real signal. + if (dvui.animationGet(id, "_ease") != null) { + if (self.state.setExtent(self.gpa, opts.name, 0)) self.extents_changed = true; + return false; + } + const dropped = self.state.splits.collapse(self.gpa, opts.name); + if (dropped) self.extents_changed = true; + for (covered) |n| { + if (self.state.clearExtent(self.gpa, n)) self.extents_changed = true; + self.state.unassign(self.gpa, n); } + dvui.refresh(null, @src(), id); + return dropped; } if (self.state.setExtent(self.gpa, opts.name, chosen)) self.extents_changed = true; + return false; } /// The region's contents: its own chrome if it declared any, otherwise the active surface. @@ -985,6 +1008,9 @@ fn initTree( const branch = node.kind.branch; var box_opts = opts; var default_extent: f32 = 0; + // Forgotten this frame, the tree `branch` points into may be freed: its children wait for + // the next frame (`persistExtent` asked for it). + var dropped = false; if (init_opts.resize) { const given = opts.min_size_content orelse dvui.Size{}; const default: f32 = switch (axis) { @@ -1002,7 +1028,7 @@ fn initTree( } const extent = Split.eased(id, target); dvui.dataSet(null, id, "_size", chosen); - if (init_opts.name.len > 0) persistExtent(self, init_opts, id, chosen, extent); + if (init_opts.name.len > 0) dropped = persistExtent(self, init_opts, id, chosen, extent); box_opts.min_size_content = switch (axis) { .horizontal => .{ .w = extent, .h = given.h }, .vertical => .{ .w = given.w, .h = extent }, @@ -1059,7 +1085,7 @@ fn initTree( }; self.depth += 1; - try drawTreeChildren(self, src, init_opts, opts, branch); + if (!dropped) try drawTreeChildren(self, src, init_opts, opts, branch); return .{ .name = init_opts.name, @@ -1261,7 +1287,8 @@ fn drawTreeNode( const chosen = dvui.dataGet(null, id, "_size", f32) orelse default; const shown = Split.eased(id, chosen); dvui.dataSet(null, id, "_size", chosen); - persistExtent(self, .{ .name = b.origin, .forget_when_empty = true }, id, chosen, shown); + // Forgotten, its nodes are freed with it: nothing more of it to draw this frame. + if (persistExtent(self, .{ .name = b.origin, .forget_when_empty = true }, id, chosen, shown)) return; box_opts.min_size_content = switch (parent_dir) { .horizontal => .{ .w = shown }, .vertical => .{ .h = shown }, @@ -1292,8 +1319,7 @@ fn drawTreeNode( .forget_when_empty = true, .dir = b.dir, }); - const cr = box.data().contentRect(); - self.state.setPlaceMetrics(b.origin, .{ .w = cr.w, .h = cr.h }, box.data().borderRectScale().r); + self.state.setPlaceMetrics(b.origin, box.data()); } self.containers[self.depth] = .{ .dir = b.dir, @@ -1342,8 +1368,7 @@ pub fn fillInBox(self: *Layout, init_opts: InitOptions, box: *dvui.BoxWidget) !v .dir = init_opts.dir, }); - const cr = box.data().contentRect(); - self.state.setPlaceMetrics(init_opts.name, .{ .w = cr.w, .h = cr.h }, box.data().borderRectScale().r); + self.state.setPlaceMetrics(init_opts.name, box.data()); if (self.depth >= Layout.max_nesting) return; self.containers[self.depth] = .{ @@ -1387,8 +1412,13 @@ pub fn splitOn(self: *Layout, name: []const u8, side: SplitTree.Side) ?[]const u if (self.state.floatRoot(name) == null) { if (self.state.dock) |*dock| return splitDock(self, dock, name, side); } + return splitOnSized(self, name, side, ViewDrag.placeSize(self.state, name) orelse return null, 0.5); +} - const size = ViewDrag.placeSize(self.state, name) orelse return null; +/// `splitOn` for a place of `size` that need not have been drawn yet — a split of a half another +/// split made this frame, as a saved arrangement is rebuilt — with `share` of it for the new half. +/// Only the split forest's: a seed's dock tree splits its own drawn leaves (`splitOn`). +pub fn splitOnSized(self: *Layout, name: []const u8, side: SplitTree.Side, size: dvui.Size, share: f32) ?[]const u8 { const span = switch (SplitTree.axisOf(side)) { .horizontal => size.w, .vertical => size.h, @@ -1403,7 +1433,7 @@ pub fn splitOn(self: *Layout, name: []const u8, side: SplitTree.Side) ?[]const u intern.state = self.state; // Halved the way the preview halves it — the sash out of the middle, both // sides the same — so what slid open is the size that lands. - const want = @max(1, (span - Split.handle_size) / 2); + const want = @max(1, (span - Split.handle_size) * std.math.clamp(share, 0, 1)); const new = self.state.splits.split(self.gpa, intern.go, name, side, want, null) orelse return null; if (self.state.setExtent(self.gpa, new, want)) self.extents_changed = true; self.state.assign(self.gpa, new, &.{}) catch {}; diff --git a/app/layout/SPLITS.md b/app/layout/SPLITS.md index c0a5b5cab..de0797705 100644 --- a/app/layout/SPLITS.md +++ b/app/layout/SPLITS.md @@ -232,6 +232,13 @@ shape-declared place can only be emptied. Removing the last leaf collapses the branch and the origin becomes a whole place again, with its extent and assignment untouched throughout — a split and its undo are symmetric. +A minted half split again is two things under one name: the half its parent split made, and the +half its own split kept. Emptied or removed, it goes the way an origin goes — its other half's +views move into it and that half closes, so the column it stands for keeps its name, its size and +its link in `layout.zon` — and when that other half is split too, it stays, empty. Only the column +as a whole, every place in it empty, closes into its parent's other half (`Forest.collapse` takes +the branch, never the leaf that kept the name). + ## Where this lives | File | Holds | diff --git a/app/layout/SplitTree.zig b/app/layout/SplitTree.zig index 9c94dec7d..199b63145 100644 --- a/app/layout/SplitTree.zig +++ b/app/layout/SplitTree.zig @@ -139,7 +139,12 @@ pub const Forest = struct { return new_leaf.kind.leaf; } - /// Remove an empty new leaf. The sibling takes the branch's place. + /// Remove the place `name` from the split it is a half of. The sibling takes the branch's place. + /// + /// A place split again is the whole branch its split made, not the leaf that kept its name + /// inside it: that is how the split it is a half of sees it, and how it is sized there. Taking + /// only the leaf would hand the place's slot to its other half, sized along the wrong axis and + /// under a name no link records. pub fn collapse(self: *Forest, gpa: std.mem.Allocator, name: []const u8) bool { var it = self.roots.iterator(); while (it.next()) |e| { @@ -307,6 +312,49 @@ pub const Forest = struct { freeNode(gpa, kv.value); } + /// Whether `name` was split again: the origin of a split, so the place under that name is + /// that split's whole branch, its kept leaf one half of it. + pub fn isSplit(self: *const Forest, name: []const u8) bool { + if (self.root(name) != null) return true; + const node = self.findPlace(name) orelse return false; + return node.kind == .branch; + } + + /// Every single place `collapse(name)` would take, in drawing order: `name` alone for a leaf + /// or a name the forest does not know, each leaf of its branch for a place split again. A + /// root's is its own kept half, as `collapse` sees it, not the whole tree it is keyed by. + /// Allocated in `arena`. + pub fn placeLeaves(self: *const Forest, arena: std.mem.Allocator, name: []const u8) []const []const u8 { + const node = self.findPlace(name) orelse { + const one = arena.alloc([]const u8, 1) catch return &.{}; + one[0] = name; + return one; + }; + var out: std.ArrayListUnmanaged([]const u8) = .empty; + leavesIn(node, arena, &out); + return out.items; + } + + /// The node the place `name` is in its parent's split: the outermost that carries the name. + fn findPlace(self: *const Forest, name: []const u8) ?*Node { + var it = self.roots.valueIterator(); + while (it.next()) |n| { + if (placeIn(n.*, name)) |hit| return hit; + } + return null; + } + + fn placeIn(node: *Node, name: []const u8) ?*Node { + switch (node.kind) { + .leaf => return null, + .branch => |b| { + if (isPlaceNamed(b.a, name)) return b.a; + if (isPlaceNamed(b.b, name)) return b.b; + return placeIn(b.a, name) orelse placeIn(b.b, name); + }, + } + } + /// A leaf minted by a split, not a shape-declared root. The picker can Remove these. pub fn canForget(self: *const Forest, name: []const u8) bool { return self.root(name) == null and self.findLeaf(name) != null; @@ -355,8 +403,13 @@ pub const Forest = struct { } }; -fn isLeafNamed(node: *Node, name: []const u8) bool { - return if (leafName(node.*)) |n| std.mem.eql(u8, n, name) else false; +/// Whether `node` is the place `name` in its parent's split: its leaf, or the branch a split of +/// it made. +fn isPlaceNamed(node: *Node, name: []const u8) bool { + return switch (node.kind) { + .leaf => |n| std.mem.eql(u8, n, name), + .branch => |b| std.mem.eql(u8, b.origin, name), + }; } fn tryCollapse(gpa: std.mem.Allocator, slot: **Node, name: []const u8) bool { @@ -364,15 +417,16 @@ fn tryCollapse(gpa: std.mem.Allocator, slot: **Node, name: []const u8) bool { switch (node.kind) { .leaf => return false, .branch => |*b| { - if (isLeafNamed(b.a, name)) { + // Children before descending: the outermost node carrying the name is the place. + if (isPlaceNamed(b.a, name)) { slot.* = b.b; - gpa.destroy(b.a); + freeNode(gpa, b.a); gpa.destroy(node); return true; } - if (isLeafNamed(b.b, name)) { + if (isPlaceNamed(b.b, name)) { slot.* = b.a; - gpa.destroy(b.b); + freeNode(gpa, b.b); gpa.destroy(node); return true; } @@ -517,3 +571,39 @@ test "a split's places know their root, list as its leaves, and go with it" { try std.testing.expect(f.root("Float 1") == null); try std.testing.expect(f.rootOf("Float 1/r1") == null); } + +test "collapsing a place split again drops its whole branch, never just the leaf that kept the name" { + const gpa = std.testing.allocator; + var f: Forest = .{}; + defer f.deinit(gpa); + _ = f.split(gpa, internLiteral, "Center", .right, 80, "Center/r1").?; + _ = f.split(gpa, internLiteral, "Center/r1", .bottom, 40, "Center/r1/b1").?; + try std.testing.expect(f.isSplit("Center/r1")); + try std.testing.expect(!f.isSplit("Center/r1/b1")); + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const covered = f.placeLeaves(arena.allocator(), "Center/r1"); + try std.testing.expectEqual(@as(usize, 2), covered.len); + try std.testing.expectEqualStrings("Center/r1", covered[0]); + try std.testing.expectEqualStrings("Center/r1/b1", covered[1]); + try std.testing.expectEqual(@as(usize, 1), f.placeLeaves(arena.allocator(), "Center/r1/b1").len); + + try std.testing.expect(f.collapse(gpa, "Center/r1")); + try std.testing.expect(f.root("Center") == null); + try std.testing.expect(f.rootOf("Center/r1/b1") == null); +} + +test "collapsing the minted half of a nested split leaves its origin in the outer split" { + const gpa = std.testing.allocator; + var f: Forest = .{}; + defer f.deinit(gpa); + _ = f.split(gpa, internLiteral, "Center", .right, 80, "Center/r1").?; + _ = f.split(gpa, internLiteral, "Center/r1", .bottom, 40, "Center/r1/b1").?; + try std.testing.expect(f.collapse(gpa, "Center/r1/b1")); + try std.testing.expectEqualStrings("Center", f.siblingLeaf("Center/r1").?); + const links = f.collectLinks(gpa); + defer gpa.free(links); + try std.testing.expectEqual(@as(usize, 1), links.len); + try std.testing.expectEqualStrings("Center/r1", links[0].name); +} diff --git a/app/layout/State.zig b/app/layout/State.zig index 8c2040bef..cc6acba3f 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -37,6 +37,19 @@ pub const StoreOffer = struct { title: []const u8, }; +/// How an application gives a document a home outside the slots its plugins made: a view of its +/// own that holds documents (fizzy's workspaces). With one, a document carried in the view drag +/// lands in any place as a view would, inside a new such view (`ViewDrag.place`). +pub const Documents = struct { + ctx: *anyopaque, + /// A new view holding the documents `ids`, the first shown — a document not open yet opened + /// in it — for the layout to place like any view it lands. Its id, or null when none went in. + house: *const fn (ctx: *anyopaque, ids: []const []const u8) ?[]const u8, + /// The view holding document `id` and nothing else, if one does: carried alone, the + /// document carries that view, which lands as itself rather than inside a new one. + alone: *const fn (ctx: *anyopaque, id: []const u8) ?[]const u8, +}; + pub const StoreCatalog = struct { uninstalled: *const fn (arena: std.mem.Allocator) []const StoreOffer, install: *const fn (id: []const u8) void, @@ -83,6 +96,9 @@ picker: Picker = .{}, /// Filled in by the application when it has a plugin store. Null means the picker only lists /// loaded surfaces — tests, and an app that never switched the store on. store_catalog: ?StoreCatalog = null, +/// Filled in by the application when documents can have homes outside their slots. Null means a +/// document goes only into a slot made for it. +documents: ?Documents = null, /// Region waiting for a store install to finish so its new surfaces can be assigned. Empty /// while nothing is pending. gpa-owned. pending_store_region: []const u8 = "", @@ -457,9 +473,17 @@ pub fn assignment(self: *State, name: []const u8) ?[]const []const u8 { /// Set what region `name` shows. An empty list is a real choice — "nothing here" — distinct from /// `unassign`, which hands the region back to its keywords. A surface lives in one /// place: putting it here takes it out of every other assignment. +/// +/// `surfaces` may borrow from any assignment, this region's included — read, edit, write back +/// is how a pane adds a tab. So past `setAssignment`, which frees the old lists, only this +/// region's new, owned copy is read: its key and list, taken out of the map first, since +/// rewriting the other regions can grow the map and move its entries. pub fn assign(self: *State, gpa: std.mem.Allocator, name: []const u8, surfaces: []const []const u8) !void { try self.setAssignment(gpa, name, surfaces); - for (surfaces) |id| self.evictIdFromOthers(gpa, name, id); + const owned = self.assignments.getEntry(name).?; + const key = owned.key_ptr.*; + const ids = owned.value_ptr.*; + for (ids) |id| try self.evictIdFromOthers(gpa, key, id); } fn setAssignment(self: *State, gpa: std.mem.Allocator, name: []const u8, surfaces: []const []const u8) !void { @@ -484,33 +508,24 @@ fn setAssignment(self: *State, gpa: std.mem.Allocator, name: []const u8, surface gop.value_ptr.* = owned; } -fn evictIdFromOthers(self: *State, gpa: std.mem.Allocator, keep: []const u8, id: []const u8) void { - var names: [32][]const u8 = undefined; - var n: usize = 0; +fn evictIdFromOthers(self: *State, gpa: std.mem.Allocator, keep: []const u8, id: []const u8) !void { + var names: std.ArrayListUnmanaged([]const u8) = .empty; + defer names.deinit(gpa); var it = self.assignments.iterator(); while (it.next()) |e| { if (std.mem.eql(u8, e.key_ptr.*, keep)) continue; - for (e.value_ptr.*) |x| { - if (!std.mem.eql(u8, x, id)) continue; - if (n < names.len) { - names[n] = e.key_ptr.*; - n += 1; - } + for (e.value_ptr.*) |x| if (std.mem.eql(u8, x, id)) { + try names.append(gpa, e.key_ptr.*); break; - } + }; } - for (names[0..n]) |region| { + var kept: std.ArrayListUnmanaged([]const u8) = .empty; + defer kept.deinit(gpa); + for (names.items) |region| { const ids = self.assignment(region) orelse continue; - var kept: [32][]const u8 = undefined; - var k: usize = 0; - for (ids) |x| { - if (std.mem.eql(u8, x, id)) continue; - if (k < kept.len) { - kept[k] = x; - k += 1; - } - } - self.setAssignment(gpa, region, kept[0..k]) catch {}; + kept.clearRetainingCapacity(); + for (ids) |x| if (!std.mem.eql(u8, x, id)) try kept.append(gpa, x); + try self.setAssignment(gpa, region, kept.items); } } @@ -791,13 +806,17 @@ fn forgetWidgetSize(id: dvui.Id) void { dvui.dataRemove(null, id, "_chooser_press"); } -pub fn setPlaceMetrics(self: *State, name: []const u8, size: dvui.Size, bounds: dvui.Rect.Physical) void { +/// Where place `name` was drawn this frame, from the box it was drawn in: its border box, in +/// points and in physical pixels. The border box, not the content: a split's extent is the whole +/// reach of the place it opens, padding included, so a split measured from the content rect gave +/// a carded place's new half the card's padding twice over. +pub fn setPlaceMetrics(self: *State, name: []const u8, data: *const dvui.WidgetData) void { var i = self.regions_building.items.len; while (i > 0) { i -= 1; if (!std.mem.eql(u8, self.regions_building.items[i].name, name)) continue; - self.regions_building.items[i].size = size; - self.regions_building.items[i].bounds = bounds; + self.regions_building.items[i].size = data.borderRect().size(); + self.regions_building.items[i].bounds = data.borderRectScale().r; return; } } diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 2d9cec407..89208ddb5 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -225,7 +225,7 @@ pub const Target = struct { /// Interned, so it outlives the frame the map was taken on. name: []const u8, bounds: dvui.Rect.Physical, - /// Content size in points, for the extent a landing split settles at. + /// Border-box size in points (`Region.size`), for the extent a landing split settles at. size: dvui.Size, /// The window it is drawn in (`Region.layer`): 0 the main window, `n` the `n`th float. layer: u16 = 0, @@ -848,8 +848,9 @@ fn mapTargets(l: *Layout, d: *ViewDrag) void { // takes any view — so none shows its zones. if (std.mem.eql(u8, d.moved_id, held_id)) return; const surface_kw = draggedKeywords(l); - // A tab's content goes to a slot made for it, never a plain place (`Layout.slotted`). - const slotted = slottedId(l, d.moved_id); + // A tab's content goes to a slot made for it, never into a plain place (`Layout.slotted`) — + // only beside one, where the application gives it a home (`State.documents`). + const slotted = slottedId(l, d.moved_id) and l.state.documents == null; // Last frame's registry: complete, where this frame's is still being // filled in around the click that started the drag. const places = if (l.state.regions.items.len > 0) @@ -2242,10 +2243,16 @@ fn dropOnPluginChooser(l: *Layout, source: []const u8, dest: []const u8, mouse: /// that went in. pub fn insertInto(l: *Layout, source: []const u8, dest: []const u8, at: Insert) void { const moved = ownId(l.arena, movedFrom(l, source) orelse return) orelse return; - if (l.host.surfaceById(moved) == null) return; const r = regionNamed(l.state, dest) orelse return; if (!accepts(r.*, keywordsOf(l, moved))) return; - if (!r.kind_slot and slottedId(l, moved)) return; + // A document — open, or a file out of the tree — along a plain place's chooser goes in there + // inside a new view holding it (`house`). + if (!r.kind_slot and slottedId(l, moved)) { + if (l.state.documents == null) return; + const view = houseView(l, moved) orelse return; + return insertView(l, dest, view, at); + } + if (l.host.surfaceById(moved) == null) return; const assigned = l.state.assignment(dest); const held = holding(l, dest); const along = containsId(held, moved); @@ -2279,6 +2286,22 @@ pub fn insertInto(l: *Layout, source: []const u8, dest: []const u8, at: Insert) dvui.refresh(null, @src(), null); } +/// Put `view`, new and in no place yet, into `dest`'s list at `at`, and show it there. +fn insertView(l: *Layout, dest: []const u8, view: []const u8, at: Insert) void { + var list: std.ArrayListUnmanaged([]const u8) = .empty; + list.appendSlice(l.arena, holding(l, dest)) catch return; + const index = switch (at) { + .before => |id| indexOfId(list.items, id) orelse list.items.len, + .after => |id| if (indexOfId(list.items, id)) |i| i + 1 else list.items.len, + .end => list.items.len, + }; + list.insert(l.arena, index, view) catch return; + l.state.assign(l.gpa, dest, list.items) catch return; + selectNamed(l, dest, view); + l.state.markDirty(); + dvui.refresh(null, @src(), null); +} + /// What was carried with the view in hand that region `r` takes too (`with`), for its drop /// (`sdk.RegionSpec.Drop.others`): the rest of a selection lifted together, in its order. fn others(l: *Layout, r: Region) []const []const u8 { @@ -2329,7 +2352,7 @@ pub fn place(l: *Layout, source: []const u8, dest: []const u8, kind: Drop.Kind) if (!has_surface and !unopened(l, moved)) return; if (regionNamed(l.state, dest)) |r| { if (!accepts(r.*, keywordsOf(l, moved))) return; - if (!r.kind_slot and slottedId(l, moved)) return; + if (!r.kind_slot and slottedId(l, moved)) return house(l, dest, plan, moved); // A plugin's region is asked first: it makes its own places, so a split of it is // something only it can do (`RegionSpec.on_drop`). if (r.on_drop) |on_drop| { @@ -2376,6 +2399,79 @@ pub fn place(l: *Layout, source: []const u8, dest: []const u8, kind: Drop.Kind) dvui.refresh(null, @src(), null); } +/// A document carried onto a plain place, which only a slot made for it takes: it lands there +/// inside a new view of the application's that holds it, and what was carried with it +/// (`State.documents`), as a view carried there would land — an edge splits the place and the +/// new half holds it; the middle adds it to a place of tabs, and takes a place of one, the view +/// it replaces let go where its keywords show it (a document's slot is no place to send it back +/// to). Where the document came from is the slot's plugin's to tidy: its tab moves, as a drop +/// into another slot moves it. +fn house(l: *Layout, dest: []const u8, plan: Drop.Plan, moved: []const u8) void { + if (carriedView(l, moved)) |carried| return landView(l, carried.from, dest, plan, carried.view); + const place_name = switch (plan) { + .split => |sp| Region.splitOn(l, dest, sp.mint) orelse return, + .swap, .join => dest, + .remove, .float => return, + }; + const view = houseView(l, moved) orelse { + if (plan == .split) closeMinted(l, place_name); + return; + }; + const many = if (regionNamed(l.state, place_name)) |r| l.state.showsOf(place_name, r.shows) == .many else false; + const list = if (many and plan != .split) idsWith(l.arena, holding(l, place_name), view) else l.arena.dupe([]const u8, &.{view}) catch return; + l.state.assign(l.gpa, place_name, list) catch return; + selectNamed(l, place_name, view); + l.state.markDirty(); + dvui.refresh(null, @src(), null); +} + +/// The view a document carried alone carries with it (`State.Documents.alone`), and the place it +/// is in: the only tab of its workspace takes the workspace along, so the drop moves that, as +/// a drag of the workspace itself would, rather than making a new one round the document and +/// leaving the old to close — a round trip that drew the document anew twice, and blinked. +fn carriedView(l: *Layout, moved: []const u8) ?struct { view: []const u8, from: []const u8 } { + const docs = l.state.documents orelse return null; + if (l.state.view_drag.with_n > 1) return null; + const view = ownId(l.arena, docs.alone(docs.ctx, moved) orelse return null) orelse return null; + const from = ownId(l.arena, holderOf(l, view) orelse return null) orelse return null; + return .{ .view = view, .from = from }; +} + +/// Land view `view`, shown in place `from`, on `dest` as `plan` says — what `place` does with a +/// view carried out of its place, for one a document carried along (`carriedView`). +fn landView(l: *Layout, from: []const u8, dest: []const u8, plan: Drop.Plan, view: []const u8) void { + switch (plan) { + .split => |sp| { + const new = Region.splitOn(l, dest, sp.mint) orelse return; + l.state.assign(l.gpa, new, &.{view}) catch {}; + selectNamed(l, new, view); + takeOut(l, from, view, null); + }, + .swap, .join => if (std.mem.eql(u8, from, dest)) + return + else if (joins(l, from, dest)) + join(l, from, dest, view) + else + swap(l, from, dest, view), + .remove, .float => return, + } + shutIfEmptied(l, from); + l.state.markDirty(); + dvui.refresh(null, @src(), null); +} + +/// The application's new view holding `moved` and what was carried with it (`State.documents`). +fn houseView(l: *Layout, moved: []const u8) ?[]const u8 { + const docs = l.state.documents orelse return null; + var ids: std.ArrayListUnmanaged([]const u8) = .empty; + ids.append(l.arena, moved) catch return null; + const d = &l.state.view_drag; + if (d.active() and d.with_n > 1) for (d.with[1..d.with_n]) |it| { + if (it.id.len > 0 and slottedId(l, it.id)) ids.append(l.arena, ownId(l.arena, it.id) orelse continue) catch break; + }; + return ownId(l.arena, docs.house(docs.ctx, ids.items) orelse return null); +} + /// Whether the view carried out of `source` floats when it is dropped on the middle of `source` /// (`float_rules.canFloat`): not out of the picker, not a document — its place is the slot a /// plugin made for it — and not a view already alone in a float nobody split. @@ -2485,7 +2581,7 @@ fn floatOut(l: *Layout, source: []const u8, moved: []const u8, at: ?dvui.Point.P /// The declared place showing `id` now, if one is: where a view carried out of the picker comes /// from (`floatOut`). -fn holderOf(l: *Layout, id: []const u8) ?[]const u8 { +pub fn holderOf(l: *Layout, id: []const u8) ?[]const u8 { for (l.state.regions.items) |r| { for (holding(l, r.name)) |held| { if (std.mem.eql(u8, held, id)) return r.name; @@ -2680,7 +2776,8 @@ fn join(l: *Layout, source: []const u8, dest: []const u8, moved: []const u8) voi } } if (!containsId(ids.items, moved)) ids.append(l.arena, moved) catch {}; - l.state.setShows(l.gpa, pair.keep, .many); + // As tabs only for more than one: a view joined with an empty half is the one view. + if (ids.items.len > 1) l.state.setShows(l.gpa, pair.keep, .many); l.state.assign(l.gpa, pair.keep, ids.items) catch {}; l.state.assign(l.gpa, pair.drop, &.{}) catch {}; selectNamed(l, pair.keep, moved); @@ -2794,21 +2891,49 @@ fn shutIfEmptied(l: *Layout, name: []const u8) void { _ = closeEmptied(l, name); } +/// Take view `id` out of the place holding it, and close that place if it is left with nothing: +/// a half of a split the user made closes into the other half, and a float closes. What an +/// application does with a view of its own that has nothing left to show (fizzy's emptied +/// workspaces). A place that cannot close keeps the view, and this returns false: an emptied +/// view is still the place's to show, rather than a hole in it. +pub fn withdraw(l: *Layout, id: []const u8) bool { + const name = ownId(l.arena, holderOf(l, id) orelse return false) orelse return false; + const kept = idsWithout(l.arena, holding(l, name), id); + if (kept.len == 0 and !closable(l, name)) return false; + // A place its keywords fill, left something to show, is left alone as in `takeOut`. + if (kept.len == 0 or l.state.assignment(name) != null) l.state.assign(l.gpa, name, kept) catch {}; + reselect(l, name, id, kept); + shutIfEmptied(l, name); + l.state.markDirty(); + dvui.refresh(null, @src(), null); + return true; +} + +/// Whether the emptied place `name` would close (`closeEmptied`), rather than stay empty. +fn closable(l: *Layout, name: []const u8) bool { + if (l.state.floats.find(name) != null and l.state.splits.root(name) == null) return true; + if (l.state.isMinted(name)) return true; + const sibling = l.state.siblingLeaf(name) orelse return false; + return l.state.isMinted(sibling); +} + /// A merge of two halves of a split: `gone` closed, its views now in `into`. -const Merged = struct { gone: []const u8, into: []const u8 }; +pub const Merged = struct { gone: []const u8, into: []const u8 }; /// Close the emptied place `name`, one half of a split the user made, into the other half. A /// minted half closes outright (`closeMinted`). The half that was split keeps its name — it is -/// the shape's place — so it takes the other half's views and that half closes instead: the same -/// one place either way, under the name the shape knows. Returns that merge, when it was one. -/// A place no user split made, or whose other half is split again, stays as it is. -fn closeEmptied(l: *Layout, name: []const u8) ?Merged { +/// the shape's place, or the name its own parent split knows it by — so it takes the other half's +/// views and that half closes instead: the same one place either way, under the name the layout +/// knows. A minted half split again is that case too: closed outright it would take its whole +/// split with it. Returns that merge, when it was one. A place no user split made, or whose other +/// half is split again, stays as it is. +pub fn closeEmptied(l: *Layout, name: []const u8) ?Merged { // A float nobody split, left with nothing to show, closes: it was the view's window. if (l.state.floats.find(name) != null and l.state.splits.root(name) == null) { Floats.close(l, name, .emptied); return null; } - if (l.state.isMinted(name)) { + if (l.state.isMinted(name) and !l.state.splits.isSplit(name)) { closeMinted(l, name); return null; } @@ -2836,13 +2961,13 @@ fn closeMinted(l: *Layout, name: []const u8) void { return; } } - const r = regionNamed(l.state, name) orelse return; - if (r.id != .zero and Split.sizeOf(r.id) > 0) { + if (regionNamed(l.state, name)) |r| if (r.id != .zero and Split.sizeOf(r.id) > 0) { Split.close(r.id); l.extents_changed = true; return; - } - // Never drawn at a size, so there is nothing to slide: drop it outright. + }; + // Never drawn at a size — or not drawn at all yet, made this frame — so there is nothing to + // slide: drop it outright. if (l.state.splits.collapse(l.gpa, name)) { if (l.state.clearExtent(l.gpa, name)) l.extents_changed = true; l.state.unassign(l.gpa, name); diff --git a/app/root.zig b/app/root.zig index ee4c9939d..e87fbf614 100644 --- a/app/root.zig +++ b/app/root.zig @@ -40,6 +40,10 @@ pub const crash = @import("crash"); pub const Recents = @import("Recents.zig"); /// The commands just run on a document, shown on its pane (`Host.activity`). pub const ActivityFeed = @import("ActivityFeed.zig"); +/// A track per open document: its history with the time put back in. +pub const Tracks = @import("Tracks.zig"); +/// A document's transport: its history as a line at the bottom of its pane, undo and redo as seeks. +pub const HistoryTransport = @import("HistoryTransport.zig"); /// Key chords → command ids: the table, its ZON form, chord matching, and the dvui adapter. pub const keymap = struct { @@ -165,4 +169,6 @@ test { _ = watch.SettingsWatcher; _ = watch.ExecutableWatcher; _ = store.Loader; + _ = Tracks; + _ = HistoryTransport; } diff --git a/app/state_delta.zig b/app/state_delta.zig new file mode 100644 index 000000000..592af11b9 --- /dev/null +++ b/app/state_delta.zig @@ -0,0 +1,240 @@ +//! What one document state is, as the bytes that differ from another: how a track keeps its +//! checkpoints (`Tracks`) without a whole copy of the document each time. +//! +//! Generic over any owner's state bytes (`captureDocumentState`'s), so no owner writes anything for +//! it. Two steps, each a pass over the bytes: +//! +//! * **The common prefix and suffix are trimmed.** Typing in one place, strokes in one area, +//! leave a small middle between them that differs. +//! * **The middle,** when it is as long in both (nothing shifted: a canvas's pixels, a fixed +//! layout), is kept as the runs of `block`-byte blocks that differ, so strokes far apart are +//! a few runs rather than one span from the first to the last. When the lengths differ (text +//! inserted, which shifts what follows), it is kept as it is. +//! +//! Layout, every count LEB128: the new length, the prefix, the suffix, a mode byte, then the +//! mode's own: `raw`, the middle's bytes; `runs`, a count, then per run its gap from the last +//! run's end, its length, its bytes. +//! +//! std-only, so it tests without a window (`fizzy-state-delta-tests`). +const std = @import("std"); + +/// The unit runs are found in: smaller finds tighter runs over more of them. +pub const block = 64; + +const Mode = enum(u8) { raw, runs }; + +pub const Error = error{BadDelta} || std.mem.Allocator.Error; + +/// `new` as a delta against `ref`, allocated with `gpa`. +pub fn encode(gpa: std.mem.Allocator, ref: []const u8, new: []const u8) std.mem.Allocator.Error![]u8 { + const shortest = @min(ref.len, new.len); + // A block at a time while they agree (a memcmp each), then a byte at a time. + var prefix: usize = 0; + while (prefix + block <= shortest and std.mem.eql(u8, ref[prefix..][0..block], new[prefix..][0..block])) prefix += block; + while (prefix < shortest and ref[prefix] == new[prefix]) prefix += 1; + var suffix: usize = 0; + while (suffix + block <= shortest - prefix and std.mem.eql(u8, ref[ref.len - suffix - block ..][0..block], new[new.len - suffix - block ..][0..block])) suffix += block; + while (suffix < shortest - prefix and ref[ref.len - 1 - suffix] == new[new.len - 1 - suffix]) suffix += 1; + const a = ref[prefix .. ref.len - suffix]; + const b = new[prefix .. new.len - suffix]; + + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + const w = &out.writer; + write(w, new.len, prefix, suffix) catch return error.OutOfMemory; + if (a.len != b.len) { + w.writeByte(@backingInt(Mode.raw)) catch return error.OutOfMemory; + w.writeAll(b) catch return error.OutOfMemory; + return out.toOwnedSlice(); + } + w.writeByte(@backingInt(Mode.runs)) catch return error.OutOfMemory; + // Runs of differing blocks, found in one pass and kept so their count can lead them. + var runs: std.ArrayList(Run) = .empty; + defer runs.deinit(gpa); + var it: Runs = .{ .a = a, .b = b }; + while (it.next()) |r| try runs.append(gpa, r); + w.writeUleb128(runs.items.len) catch return error.OutOfMemory; + var end: usize = 0; + for (runs.items) |r| { + w.writeUleb128(r.start - end) catch return error.OutOfMemory; + w.writeUleb128(r.len) catch return error.OutOfMemory; + w.writeAll(b[r.start..][0..r.len]) catch return error.OutOfMemory; + end = r.start + r.len; + } + return out.toOwnedSlice(); +} + +fn write(w: *std.Io.Writer, len: usize, prefix: usize, suffix: usize) !void { + try w.writeUleb128(len); + try w.writeUleb128(prefix); + try w.writeUleb128(suffix); +} + +const Run = struct { start: usize, len: usize }; + +/// The runs of `block`-byte blocks where `a` and `b`, of one length, differ. +const Runs = struct { + a: []const u8, + b: []const u8, + at: usize = 0, + + fn next(self: *Runs) ?Run { + while (self.at < self.a.len and self.same(self.at)) self.at += block; + if (self.at >= self.a.len) return null; + const start = self.at; + while (self.at < self.a.len and !self.same(self.at)) self.at += block; + return .{ .start = start, .len = @min(self.at, self.a.len) - start }; + } + + fn same(self: *const Runs, at: usize) bool { + const n = @min(block, self.a.len - at); + return std.mem.eql(u8, self.a[at..][0..n], self.b[at..][0..n]); + } +}; + +/// The state `delta` stands for against `ref`, allocated with `gpa`. A delta that does not fit +/// `ref` is `error.BadDelta`. +pub fn apply(gpa: std.mem.Allocator, ref: []const u8, delta: []const u8) Error![]u8 { + var r: std.Io.Reader = .fixed(delta); + const len = take(&r) catch return error.BadDelta; + const prefix = take(&r) catch return error.BadDelta; + const suffix = take(&r) catch return error.BadDelta; + if (prefix > ref.len or suffix > ref.len - prefix or prefix > len or suffix > len - prefix) return error.BadDelta; + const mode = std.enums.fromInt(Mode, r.takeByte() catch return error.BadDelta) orelse return error.BadDelta; + const middle = len - prefix - suffix; + const out = try gpa.alloc(u8, len); + errdefer gpa.free(out); + @memcpy(out[0..prefix], ref[0..prefix]); + @memcpy(out[len - suffix ..], ref[ref.len - suffix ..]); + switch (mode) { + .raw => { + const bytes = r.take(middle) catch return error.BadDelta; + @memcpy(out[prefix..][0..middle], bytes); + }, + .runs => { + if (ref.len != len) return error.BadDelta; + @memcpy(out[prefix..][0..middle], ref[prefix..][0..middle]); + const n = take(&r) catch return error.BadDelta; + var end: usize = 0; + for (0..n) |_| { + const gap = take(&r) catch return error.BadDelta; + const run = take(&r) catch return error.BadDelta; + if (gap > middle - end or run > middle - end - gap) return error.BadDelta; + const bytes = r.take(run) catch return error.BadDelta; + @memcpy(out[prefix + end + gap ..][0..run], bytes); + end += gap + run; + } + }, + } + if (r.seek != delta.len) return error.BadDelta; + return out; +} + +fn take(r: *std.Io.Reader) !usize { + return r.takeLeb128(usize); +} + +// ---- tests --------------------------------------------------------------------------------- + +const testing = std.testing; + +fn roundTrip(ref: []const u8, new: []const u8) !usize { + const d = try encode(testing.allocator, ref, new); + defer testing.allocator.free(d); + const back = try apply(testing.allocator, ref, d); + defer testing.allocator.free(back); + try testing.expectEqualSlices(u8, new, back); + return d.len; +} + +test "an edit in one place is a delta of about its size" { + var ref: [64 << 10]u8 = undefined; + var prng: std.Random.DefaultPrng = .init(0xde17a); + prng.random().bytes(&ref); + var new = ref; + @memcpy(new[30_000..][0..20], "twenty bytes changed"); + try testing.expect(try roundTrip(&ref, &new) < 64); + // Nothing changed: next to nothing. + try testing.expect(try roundTrip(&ref, &ref) < 16); +} + +test "text inserted shifts what follows, and the delta is still the part between the edits" { + const line = "fn main() void {\n std.debug.print(\"hello\", .{});\n}\n"; + const ref: *const [line.len * 100]u8 = comptime blk: { + var buf: [line.len * 100]u8 = undefined; + for (0..100) |i| buf[i * line.len ..][0..line.len].* = line.*; + const out = buf; + break :blk &out; + }; + const new = ref[0..2000] ++ "// inserted\n" ++ ref[2000..]; + const size = try roundTrip(ref, new); + try testing.expect(size < 32); + // Two edits far apart, lengths differing: the middle between them, kept as it is. + const two = ref[0..100] ++ "x" ++ ref[100..5000] ++ "y" ++ ref[5000..]; + try testing.expect(try roundTrip(ref, two) < 5000); +} + +test "changes far apart on a canvas are a few runs, not the span between them" { + var ref: [1 << 20]u8 = @splat(0); + var new = ref; + for ([_]usize{ 1000, 400_000, 900_000 }) |at| @memset(new[at..][0..30], 0xff); + try testing.expect(try roundTrip(&ref, &new) < 4 * block * 2); +} + +test "growing, shrinking, emptying and starting from nothing" { + _ = try roundTrip("", ""); + _ = try roundTrip("", "abc"); + _ = try roundTrip("abc", ""); + _ = try roundTrip("abc", "abcdef"); + _ = try roundTrip("abcdef", "abc"); + _ = try roundTrip("aaaa", "aa"); + _ = try roundTrip("xabcx", "xx"); +} + +test "random edits round-trip" { + var prng: std.Random.DefaultPrng = .init(0x5ea1); + const r = prng.random(); + var ref: [4096]u8 = undefined; + r.bytes(&ref); + for (0..500) |_| { + var new: std.ArrayList(u8) = .empty; + defer new.deinit(testing.allocator); + try new.appendSlice(testing.allocator, &ref); + for (0..1 + r.uintLessThan(usize, 4)) |_| { + const at = r.uintLessThan(usize, new.items.len + 1); + switch (r.uintLessThan(u8, 3)) { + 0 => try new.insertSlice(testing.allocator, at, "inserted"), + 1 => new.replaceRangeAssumeCapacity(at, @min(16, new.items.len - at), ""), + else => if (at < new.items.len) { + new.items[at] +%= 1; + }, + } + } + _ = try roundTrip(&ref, new.items); + } +} + +test "a damaged delta is an error, never a crash" { + var ref: [2048]u8 = undefined; + var prng: std.Random.DefaultPrng = .init(0xbad); + const r = prng.random(); + r.bytes(&ref); + var new = ref; + @memset(new[100..][0..40], 1); + @memset(new[1500..][0..40], 2); + const d = try encode(testing.allocator, &ref, &new); + defer testing.allocator.free(d); + for (0..d.len) |n| if (apply(testing.allocator, &ref, d[0..n])) |got| { + testing.allocator.free(got); + return error.TestUnexpectedResult; + } else |err| try testing.expectEqual(error.BadDelta, err); + const copy = try testing.allocator.dupe(u8, d); + defer testing.allocator.free(copy); + for (0..4000) |_| { + @memcpy(copy, d); + copy[r.uintLessThan(usize, copy.len)] ^= @as(u8, 1) << r.int(u3); + if (apply(testing.allocator, &ref, copy)) |got| testing.allocator.free(got) else |_| {} + } + // Against the wrong reference: refused rather than misread where it can tell. + try testing.expectError(error.BadDelta, apply(testing.allocator, ref[0..100], d)); +} diff --git a/app/store/PluginLoader.zig b/app/store/PluginLoader.zig index 6f1f12e23..b74bef099 100644 --- a/app/store/PluginLoader.zig +++ b/app/store/PluginLoader.zig @@ -62,7 +62,8 @@ pub const LoadError = error{ SetRenderBridgeSymbolMissing, SdkVersionSymbolMissing, AbiMismatch, - AbiBuildEnvMismatch, + AbiOptimizeMismatch, + AbiShapeMismatch, SdkVersionMismatch, PluginIdMismatch, RegisterRejected, @@ -403,14 +404,14 @@ pub fn open(allocator: std.mem.Allocator, path: []const u8, expected_id: []const plugin_fp, }); } else { - // `sdk_version` is bumped exactly when the SDK boundary shape changes - // (`test-sdk-version` CI enforces this). So if the plugin was built against the - // same `sdk_version` we run, the fingerprint can only differ on things - // `sdk_version` does not cover — arch, os, or optimize mode (Debug vs ReleaseFast). - // Report that as a distinct, less alarming error than a genuine SDK drift. + // The fingerprint is the boundary shape folded with the optimize class, so a refusal + // is one or the other. The shape can move without the version: feature PRs record + // a new shape and only a release bumps `sdk_version`, so a Fizzy built from `main` + // past a release refuses plugins built against that release. + if (dylib_api.optimizeClassOf(plugin_fp) != null) return error.AbiOptimizeMismatch; const built_with = readVersionTriplet(lookupVersionFn(&lib, dylib_api.symbol_sdk_version)); if (std.SemanticVersion.order(built_with, version.sdk_version) == .eq) { - return error.AbiBuildEnvMismatch; + return error.AbiShapeMismatch; } return error.AbiMismatch; } diff --git a/app/store/PluginLoader_web.zig b/app/store/PluginLoader_web.zig index b81bb179e..d6e906f72 100644 --- a/app/store/PluginLoader_web.zig +++ b/app/store/PluginLoader_web.zig @@ -26,7 +26,8 @@ pub const LoadError = error{ Unsupported, LoadFailed, AbiMismatch, - AbiBuildEnvMismatch, + AbiOptimizeMismatch, + AbiShapeMismatch, SdkVersionMismatch, PluginIdMismatch, RegisterRejected, @@ -288,8 +289,10 @@ pub fn prepare(url: []const u8, expected_id: []const u8, lib_in: WebDynLib) Load const abi_fp_fn = lib.lookup(dylib_api.GetAbiFingerprintFn, dylib_api.symbol_abi_fingerprint) orelse return error.LoadFailed; const plugin_fp = abi_fp_fn(); if (!dylib_api.fingerprintMatches(plugin_fp)) { + // Same split as the desktop loader's: optimize class, then shape under one version. + if (dylib_api.optimizeClassOf(plugin_fp) != null) return error.AbiOptimizeMismatch; const built_with = readVersionTriplet(lib.lookup(dylib_api.GetSdkVersionFn, dylib_api.symbol_sdk_version)); - if (std.SemanticVersion.order(built_with, version.sdk_version) == .eq) return error.AbiBuildEnvMismatch; + if (std.SemanticVersion.order(built_with, version.sdk_version) == .eq) return error.AbiShapeMismatch; return error.AbiMismatch; } diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index ffa43ba7c..228ad1e56 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -23,6 +23,7 @@ const store = @import("registry/store.zig"); const Page = @import("PluginPage.zig"); const PluginLoader = @import("PluginLoader.zig"); const PluginManager = @import("PluginManager.zig"); +const App = @import("../App.zig"); /// Pretend two installed plugins have store updates waiting, so the update flow (the app's /// offer, or the silent path's logging) can be exercised without an actually out-of-date /// plugin. A build-time flag: flip it, run, flip it back. @@ -406,6 +407,9 @@ const app = &PluginManager.current; pub fn register(manager: PluginManager) !void { PluginManager.current = manager; + // A link-only build has no store: nothing to fetch, nowhere to install to. The manager is + // still set, because settings name plugins through it (`displayName`). + if (comptime !App.loads_plugins) return; const host = manager.host; const url = resolveRegistryUrl(); const fp_hex = try std.fmt.allocPrint(manager.gpa, "0x{x}", .{dylib.abi_fingerprint}); @@ -1333,6 +1337,7 @@ fn offerPendingUpdates() void { /// before the Host-registry iterations, so a freshly-registered or unloaded plugin never /// mutates a list mid-iteration. pub fn tick() void { + if (comptime !App.loads_plugins) return; if (comptime builtin.target.cpu.arch == .wasm32) { if (catalog) |*c| c.pump(web_fetch); StoreIcon.pump(); @@ -2870,8 +2875,7 @@ const store_optimize_class = "fast"; /// shard URL the store never publishes under, and *every* plugin — including ones whose SDK /// version matches this host exactly — reads "No compatible build in store". That message points /// at the store, but the cause is entirely local and comptime-known, so say so instead. Same -/// condition the local load path reports as `error.AbiBuildEnvMismatch` ("SDK versions match, but -/// optimize mode does not match"). +/// condition the local load path reports as `error.AbiOptimizeMismatch`. const host_optimize_matches_store = std.mem.eql(u8, dylib.optimize_safety_class, store_optimize_class); /// Cap on the reported min width of the no-build message (same `max_size_content` trick as diff --git a/backend/src/shaders/compiled/msl/program_example.fragment.msl b/backend/src/shaders/compiled/msl/program_example.fragment.msl index b32c867b6..dee14ac30 100644 --- a/backend/src/shaders/compiled/msl/program_example.fragment.msl +++ b/backend/src/shaders/compiled/msl/program_example.fragment.msl @@ -3,30 +3,26 @@ using namespace metal; -// `program_example.fragment.hlsl` in Metal, to the interface shadercross gives it: the entry -// point is `main0`, the inputs are the default vertex shader's `[[user(locnN)]]` outputs, -// textures and samplers are numbered from the draw's own (0), and the uniforms are buffer 0. - -struct main0_in +struct type_Uniforms { - float4 color [[user(locn0)]]; - float2 uv [[user(locn1)]]; + float4 data[2]; }; struct main0_out { - float4 color [[color(0)]]; + float4 out_var_SV_Target0 [[color(0)]]; }; -fragment main0_out main0( - main0_in in [[stage_in]], - texture2d Texture0 [[texture(0)]], sampler Sampler0 [[sampler(0)]], - texture2d Texture1 [[texture(1)]], sampler Sampler1 [[sampler(1)]], - constant float4 *data [[buffer(0)]]) +struct main0_in +{ + float4 in_var_COLOR [[user(locn0)]]; + float2 in_var_TEXCOORD0 [[user(locn1)]]; +}; + +fragment main0_out main0(main0_in in [[stage_in]], constant type_Uniforms& Uniforms [[buffer(0)]], texture2d Texture0 [[texture(0)]], texture2d Texture1 [[texture(1)]], sampler Sampler0 [[sampler(0)]], sampler Sampler1 [[sampler(1)]]) { main0_out out = {}; - float4 base = Texture0.sample(Sampler0, in.uv) * in.color * data[0]; - float4 extra = Texture1.sample(Sampler1, in.uv * data[1].x); - out.color = mix(base, extra, data[1].y); + out.out_var_SV_Target0 = mix((Texture0.sample(Sampler0, in.in_var_TEXCOORD0) * in.in_var_COLOR) * Uniforms.data[0], Texture1.sample(Sampler1, (in.in_var_TEXCOORD0 * Uniforms.data[1].x)), float4(Uniforms.data[1].y)); return out; } + diff --git a/build.zig b/build.zig index ab2046228..26b3365bb 100644 --- a/build.zig +++ b/build.zig @@ -65,6 +65,12 @@ var deferred: ?struct { pub const BundledPlugin = @import("build/sdk.zig").BundledPlugin; +/// Fizzy's own plugins, as entries in the list `buildApp` links: `fizzy.plugins.text`, or +/// `fizzy.plugins.all` for every one in fizzy's own order. Fizzy builds each for every executable. +/// `workbench` must be listed for now, because the editor still holds its state +/// (fizzyedit/plans#13). +pub const plugins = @import("build/plugins.zig").entries; + /// Build the application in a consumer's graph, with the plugins the consumer bundles. /// /// ```zig @@ -77,18 +83,27 @@ pub const BundledPlugin = @import("build/sdk.zig").BundledPlugin; /// .@"app-layout" = b.path("src/layout.zig"), /// }); /// try fizzy.buildApp(fizzy_dep, &.{ +/// fizzy.plugins.workbench, +/// fizzy.plugins.text, /// .{ .name = "pixi", .module = b.dependency("pixi", .{ .target = target, .optimize = optimize }).module("plugin") }, /// }); /// b.installArtifact(fizzy_dep.artifact("myapp")); /// ``` /// -/// `name` is the plugin's id (its root's `plugin_id`); `module` its static module, which the -/// plugin package exports and which fizzy wires to `dvui`, `core`, `fizzy_sdk` and `icons`. -/// Fizzy's own four are always bundled ahead of these. -pub fn buildApp(fizzy_dep: *std.Build.Dependency, plugins: []const BundledPlugin) !void { +/// `linked` is everything the app links, in registration order (the order of the sidebar's +/// views): fizzy's own (`fizzy.plugins`) and the app's. For an app's, `name` is the plugin's id +/// (its root's `plugin_id`) and `module` its static module, which the plugin package exports and +/// which fizzy wires to `dvui`, `core`, `fizzy_sdk` and `icons`. +pub fn buildApp(fizzy_dep: *std.Build.Dependency, linked: []const BundledPlugin) !void { const d = deferred orelse @panic("fizzy.buildApp: pass `.@\"defer-app\" = true` to b.dependency(\"fizzy\", …) first"); var opts = d.base; - opts.app_plugins = plugins; + // Until fizzyedit/example-app lists fizzy's plugins itself (its CI and fizzy's build each + // other's main): a list naming none of fizzy's gets all of them ahead of its own, as before. + // Goes in fizzyedit/plans#13 step 2. + const names_fizzys = for (linked) |p| { + if (p.module == null) break true; + } else false; + opts.app_plugins = if (names_fizzys) linked else try std.mem.concat(fizzy_dep.builder.allocator, BundledPlugin, &.{ &plugins.all, linked }); try @import("build/app.zig").construct(fizzy_dep.builder, d.target, d.optimize, opts, d.cfg); } diff --git a/build/app.zig b/build/app.zig index 8dad6a27e..bbf974d06 100644 --- a/build/app.zig +++ b/build/app.zig @@ -27,8 +27,8 @@ pub const WebPluginDir = struct { }; pub const Options = struct { - /// Plugins the application bundles beyond fizzy's own — see `build.zig`'s `buildApp`. - app_plugins: []const @import("sdk.zig").BundledPlugin = &.{}, + /// Every plugin the application links, fizzy's own among them — see `build.zig`'s `buildApp`. + app_plugins: []const @import("sdk.zig").BundledPlugin = &@import("plugins.zig").entries.all, /// Dependencies (by the name in `build.zig.zon`) whose `"plugin"` module the **web** build /// links in. The browser cannot `dlopen`, so a plugin exists there only if the application /// bundles it; this is fizzy-the-app's list, resolved for the wasm target. Each is looked @@ -85,6 +85,7 @@ pub const Config = struct { static_workbench: bool, static_text: bool, static_image: bool, + dynamic_plugins: bool, workbench_opts: *std.Build.Step.Options, /// Null on the configure pass that fetches Velopack (see `readConfig`). msvcup_before_compile: ?*std.Build.Step.Run, @@ -228,6 +229,11 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options build_opts.addOption([]const u8, "app_repo_url", app_repo_url); build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); build_opts.addOption(bool, "velopack_enabled", velopack_enabled); + // Off, the app runs only the plugins it links (`buildApp`'s list): no plugin dylib is built, + // and nothing loads at runtime — not fizzy's own dylibs, not the user plugins directory, not + // the store, not a web page's `?plugin=`. Comptime, so none of it is checked per frame. + const dynamic_plugins = b.option(bool, "dynamic-plugins", "Load plugins at runtime: fizzy's own dylibs, the user plugins directory, the store (default: true)") orelse true; + build_opts.addOption(bool, "dynamic_plugins", dynamic_plugins); // A consumer that wants its own shape passes `-Dapp-layout=` (a LazyPath to // `pub fn layout(?*anyopaque, *Layout)`). Fizzy itself uses `src/editor/layout.zig`. @@ -302,6 +308,7 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options .static_workbench = static_workbench, .static_text = static_text, .static_image = static_image, + .dynamic_plugins = dynamic_plugins, .workbench_opts = workbench_opts, .msvcup_before_compile = msvcup_before_compile, .accesskit = accesskit, @@ -352,6 +359,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. const static_workbench = cfg.static_workbench; const static_text = cfg.static_text; const static_image = cfg.static_image; + const dynamic_plugins = cfg.dynamic_plugins; const workbench_opts = cfg.workbench_opts; const msvcup_before_compile = cfg.msvcup_before_compile; const accesskit = cfg.accesskit; @@ -375,7 +383,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. web.addSteps(b, optimize, build_opts, workbench_opts, assets_module, opts.app_plugins, opts.web_plugin_deps, opts.web_plugin_dirs); - const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins); + const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); const exe = main_fizzy.exe; const package_fizzy: FizzyExecutable = package_blk: { @@ -397,8 +405,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. pack_opts.addOption(bool, "static_workbench", static_workbench); pack_opts.addOption(bool, "static_text", static_text); pack_opts.addOption(bool, "static_image", static_image); + pack_opts.addOption(bool, "dynamic_plugins", dynamic_plugins); pack_opts.addOption(bool, "has_app_layout", app_layout_path != null); - break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, pack_opts, workbench_opts, assets_module, macos_sdl_paths, true, app_name, app_layout_path, opts.app_plugins); + break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, pack_opts, workbench_opts, assets_module, macos_sdl_paths, true, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); }; const exe_for_package = package_fizzy.exe; @@ -645,6 +654,13 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // stacking and where a closed float's views go. std-only (see float_rules.zig); // `Floats.zig` and `ViewDrag.zig` apply it, covered by `tests/integration.zig`. .{ "fizzy-float-rules-tests", "app/layout/float_rules.zig" }, + // A document state as the bytes that differ from another: how a track's checkpoints + // stay small. std-only (see state_delta.zig); `Tracks` keeps its checkpoints with it. + .{ "fizzy-state-delta-tests", "app/state_delta.zig" }, + // When a document's transport shows and for how long, how far a held undo goes, which + // step a point on its line is. std-only (see history_transport.zig); `HistoryTransport` + // draws it. + .{ "fizzy-history-transport-tests", "app/history_transport.zig" }, // One material on two sliders: what every glass is at the window's opacity and roughness, // the OS's form and the app's. std-only (see glass_look.zig); `LiquidField` and `Popout` // apply it. @@ -940,6 +956,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. .dvui = dvui_testing_dep.module("dvui_testing"), .core = core_module_test, .sdk = sdk_module_test, + .icons = icons_test, }, fizzy_test_module); const devtools_module_test = plugins.devtools.addStaticModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), @@ -1233,6 +1250,32 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. bench_step.dependOn(&run_bench.step); } + // `zig build bench-tracks` — what a document's track costs (`app.Tracks`): a step, a frame's + // top idle and with a checkpoint, a replay, what a session holds. Same rules as `bench-text`. + { + const bench_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/bench/bench_tracks.zig"), + }); + bench_module.addImport("app", app_module_test); + bench_module.addImport("fizzy_sdk", sdk_module_test); + + const bench_tracks = b.addTest(.{ .name = "fizzy-bench-tracks", .root_module = bench_module }); + bench_tracks.root_module.link_libcpp = !target_is_windows_msvc; + if (target.result.os.tag == .windows) { + bench_tracks.root_module.linkSystemLibrary("comctl32", .{}); + } + if (velopack_enabled) if (vz) |v| { + try velopack.linkVelopack(b, v, bench_tracks, .{ .target = target, .optimize = optimize }); + }; + + const bench_step = b.step("bench-tracks", "Benchmark a document's track: steps, checkpoints, replays (prints timings)"); + const run_bench = b.addRunArtifact(bench_tracks); + run_bench.has_side_effects = true; + bench_step.dependOn(&run_bench.step); + } + // `zig build bench-tape` — saving and loading a tape, ZON against the binary form, at the // sizes of a demo, a recording and a long session. Same rules as `bench-text`; std-only, so // it needs no window and builds in seconds. diff --git a/build/exe.zig b/build/exe.zig index 7219a3b1f..72872b95f 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -15,8 +15,6 @@ const workbench_plugin = plugins.workbench; const text_plugin = plugins.text; const markdown_plugin = plugins.markdown; const image_plugin = plugins.image; -const archive_plugin = plugins.archive; -const devtools_plugin = plugins.devtools; const MacosSdlPaths = common.MacosSdlPaths; /// Install stripped exe + built-in plugin dylibs for `vpk pack --packDir`. @@ -201,10 +199,13 @@ pub fn addFizzyExecutableForTarget( /// `Host.layout_ctx`. The file imports `dvui`, `app`, `core`, and `fizzy_sdk` — not /// `fizzy` / `Editor` — so the module graph does not cycle. app_layout: ?std.Build.LazyPath, - /// Plugins the application bundles beyond fizzy's own four: each a static module from a - /// plugin package (`plugin_dep.module("plugin")`), linked in and listed in the generated - /// `bundled_plugins` module under its plugin id. + /// Every plugin the application links, in registration order: fizzy's own + /// (`fizzy.plugins`, built here) and the application's, each a static module from a plugin + /// package (`plugin_dep.module("plugin")`). Listed in the generated `bundled_plugins` module + /// under its plugin id. app_plugins: []const sdk.BundledPlugin, + /// False for a link-only app (`-Ddynamic-plugins=false`): no plugin dylib is built. + dynamic_plugins: bool, ) !FizzyExecutable { const native = nativeDvui(b, native_backend, resolved_target, optimize, accesskit, macos_sdl_paths); const dvui_dep = native.dep; @@ -292,86 +293,24 @@ pub fn addFizzyExecutableForTarget( const sdk_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_mod, proxy_bridge_host_mod, core_module, exe.root_module); const sdk_proxy_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_proxy_mod, proxy_bridge_plugin_mod, core_proxy_module, null); - const workbench_module = workbench_plugin.addStaticModule(b, resolved_target, optimize, .{ + // What this application links, as build data: a generated module whose `modules` tuple + // names every statically linked plugin, so the runtime registers, probes and falls back + // to whatever is listed here and never a plugin by name. An app built on fizzy lists its + // own (`buildApp`), fizzy's among them or not. + const linked = plugins.link(b, .{ + .target = resolved_target, + .optimize = optimize, .dvui = dvui_mod, .core = core_module, .sdk = sdk_module, .icons = icons_module, .backend = native.backend, - }, workbench_opts, exe.root_module); - const text_module = text_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - .icons = icons_module, - }, exe.root_module); - const image_module = image_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - }, exe.root_module); - const archive_module = archive_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - }, exe.root_module); - const devtools_module = devtools_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - }, exe.root_module); - const markdown_module: ?*std.Build.Module = if (resolved_target.result.cpu.arch != .wasm32) - markdown_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - }, exe.root_module) - else - null; - - // What this application bundles, as build data: a generated module whose `modules` tuple - // names every statically linked plugin, so the runtime registers, probes and falls back - // to whatever is listed here and never a plugin by name. An app built on fizzy lists its - // own. - var bundled_list: std.ArrayList(sdk.BundledPlugin) = .empty; - try bundled_list.appendSlice(b.allocator, &.{ - .{ .name = "workbench", .module = workbench_module }, - .{ .name = "text", .module = text_module }, - .{ .name = "image", .module = image_module }, - .{ .name = "archive", .module = archive_module }, - .{ .name = "markdown", .module = markdown_module }, - .{ .name = "devtools", .module = devtools_module }, - }); - for (app_plugins) |p| { - const src_mod = p.module orelse { - try bundled_list.append(b.allocator, p); - continue; - }; - // A copy per executable, not the plugin package's module itself: this function builds - // more than one exe (the packaged, Velopack-linked one beside the plain one), each with - // its own `dvui`/`core`/`fizzy_sdk`, and one module cannot import both sets. The copy - // keeps the plugin's own imports — its manifest options, its dependencies — and gets - // this exe's framework modules. - const m = b.createModule(.{ - .target = resolved_target, - .optimize = optimize, - .root_source_file = src_mod.root_source_file, - .link_libc = resolved_target.result.cpu.arch != .wasm32, - }); - var it = src_mod.import_table.iterator(); - while (it.next()) |kv| { - const name = kv.key_ptr.*; - if (std.mem.eql(u8, name, "dvui") or std.mem.eql(u8, name, "core") or std.mem.eql(u8, name, "fizzy_sdk") or std.mem.eql(u8, name, "icons")) continue; - m.addImport(name, kv.value_ptr.*); - } - m.addImport("dvui", dvui_mod); - m.addImport("core", core_module); - m.addImport("fizzy_sdk", sdk_module); - if (icons_module) |icons| m.addImport("icons", icons); - exe.root_module.addImport(p.name, m); - try bundled_list.append(b.allocator, .{ .name = p.name, .module = m }); - } - const bundled = sdk.bundledPluginsModule(b, resolved_target, optimize, bundled_list.items); + .workbench_opts = workbench_opts, + .consumer = exe.root_module, + .markdown = resolved_target.result.cpu.arch != .wasm32, + }, app_plugins); + const markdown_module = linked.markdown; + const bundled = sdk.bundledPluginsModule(b, resolved_target, optimize, linked.plugins); exe.root_module.addImport("bundled_plugins", bundled); const singleton_app_dep = b.dependency("dvui_singleton_app", .{ @@ -404,7 +343,7 @@ pub fn addFizzyExecutableForTarget( exe.root_module.addImport("app_layout", app_layout_mod); } - const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const workbench_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32) blk: { break :blk workbench_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -415,7 +354,7 @@ pub fn addFizzyExecutableForTarget( }, workbench_opts); } else null; - const text_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const text_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "text")) blk: { break :blk text_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -425,7 +364,7 @@ pub fn addFizzyExecutableForTarget( }); } else null; - const markdown_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const markdown_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "markdown")) blk: { break :blk markdown_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -434,11 +373,12 @@ pub fn addFizzyExecutableForTarget( }); } else null; - const image_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const image_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "image")) blk: { break :blk image_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, .sdk = sdk_proxy_module, + .icons = icons_module, .proxy_bridge = proxy_bridge_plugin_mod, }); } else null; diff --git a/build/plugins.zig b/build/plugins.zig index 63ed36916..2e8d484a8 100644 --- a/build/plugins.zig +++ b/build/plugins.zig @@ -3,10 +3,127 @@ //! Each built-in plugin keeps its fizzy-internal static-embed glue self-contained in //! `plugins//static/integration.zig`, separate from the canonical third-party files //! at the plugin-folder root (fizzy's `@import("")` resolves to the root -//! `.zig`). Fizzy root aggregates those integration files here. +//! `.zig`). Fizzy root aggregates those integration files here, and `link` turns the list +//! an application gives into modules for one executable. +const std = @import("std"); +const BundledPlugin = @import("sdk.zig").BundledPlugin; + pub const workbench = @import("../plugins/workbench/static/integration.zig"); pub const text = @import("../plugins/text/static/integration.zig"); pub const markdown = @import("../plugins/markdown/static/integration.zig"); pub const image = @import("../plugins/image/static/integration.zig"); pub const archive = @import("../plugins/archive/static/integration.zig"); pub const devtools = @import("../plugins/devtools/static/integration.zig"); + +/// Fizzy's own plugins, as entries in the list an application links (`fizzy.plugins`). An entry +/// with no module is built by fizzy for each executable, wired to that executable's framework. +pub const entries = struct { + pub const workbench: BundledPlugin = .{ .name = "workbench" }; + pub const text: BundledPlugin = .{ .name = "text" }; + pub const image: BundledPlugin = .{ .name = "image" }; + pub const archive: BundledPlugin = .{ .name = "archive" }; + pub const markdown: BundledPlugin = .{ .name = "markdown" }; + pub const devtools: BundledPlugin = .{ .name = "devtools" }; + + /// What fizzy itself links, in its sidebar order. + pub const all = [_]BundledPlugin{ @This().workbench, @This().text, @This().image, @This().archive, @This().markdown, @This().devtools }; +}; + +/// Whether `plugins` links fizzy's own `name`, rather than leaving it out or bringing a module of +/// its own under that id. +pub fn linksFizzys(plugins: []const BundledPlugin, name: []const u8) bool { + for (plugins) |p| if (p.module == null and std.mem.eql(u8, p.name, name)) return true; + return false; +} + +/// What one executable gives the plugins it links. +pub const Wiring = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + icons: ?*std.Build.Module, + /// workbench's: the native backend, null on the web. + backend: ?*std.Build.Module, + workbench_opts: *std.Build.Step.Options, + /// The executable's root module, which imports each plugin by its name. + consumer: *std.Build.Module, + /// False where markdown does not build (the native exe for wasm). + markdown: bool = true, +}; + +pub const Linked = struct { + /// The modules for `bundledPluginsModule`, in the order the application listed them. + plugins: []const BundledPlugin, + /// Built whether or not they are listed: fizzy's own code imports both. The editor holds + /// workbench's state, and the store draws a plugin's README with markdown. A module is made + /// once for its consumer, so a listed one is this one. + workbench: *std.Build.Module, + markdown: ?*std.Build.Module, +}; + +/// Make the modules for `plugins`: fizzy's own built for this executable, an application's +/// re-homed onto it. workbench must be listed, because the editor still holds its state +/// (fizzyedit/plans#13). +pub fn link(b: *std.Build, w: Wiring, plugins: []const BundledPlugin) Linked { + if (!linksFizzys(plugins, "workbench")) std.debug.panic("fizzy: the plugin list must include `fizzy.plugins.workbench`; the editor still holds its state (fizzyedit/plans#13)", .{}); + + const workbench_mod = workbench.addStaticModule(b, w.target, w.optimize, .{ + .dvui = w.dvui, + .core = w.core, + .sdk = w.sdk, + .icons = w.icons, + .backend = w.backend, + }, w.workbench_opts, w.consumer); + const markdown_mod: ?*std.Build.Module = if (w.markdown) markdown.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) else null; + + var out: std.ArrayList(BundledPlugin) = .empty; + for (plugins) |p| { + const m: ?*std.Build.Module = if (p.module) |src| rehome(b, w, p.name, src) else if (std.mem.eql(u8, p.name, "workbench")) + workbench_mod + else if (std.mem.eql(u8, p.name, "markdown")) + markdown_mod + else if (std.mem.eql(u8, p.name, "text")) + text.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk, .icons = w.icons }, w.consumer) + else if (std.mem.eql(u8, p.name, "image")) + image.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk, .icons = w.icons }, w.consumer) + else if (std.mem.eql(u8, p.name, "archive")) + archive.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) + else if (std.mem.eql(u8, p.name, "devtools")) + devtools.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) + else + std.debug.panic("fizzy: plugin '{s}' has no module and is not one of fizzy's own (`fizzy.plugins`)", .{p.name}); + // A plugin this target does not build (markdown for the native exe on wasm) is left out. + if (m) |module| out.append(b.allocator, .{ .name = p.name, .module = module }) catch @panic("OOM"); + } + return .{ .plugins = out.items, .workbench = workbench_mod, .markdown = markdown_mod }; +} + +/// A copy of an application's plugin module for this executable, not the plugin package's module +/// itself: one application builds several executables (the packaged, Velopack-linked one beside +/// the plain one, the web one), each with its own `dvui`/`core`/`fizzy_sdk`, and one module cannot +/// import both sets. The copy keeps the plugin's own imports (its manifest options, its +/// dependencies) and gets this executable's framework modules. +pub fn rehome(b: *std.Build, w: Wiring, name: []const u8, src: *std.Build.Module) *std.Build.Module { + const wasm = w.target.result.cpu.arch == .wasm32; + const m = b.createModule(.{ + .target = w.target, + .optimize = w.optimize, + .root_source_file = src.root_source_file, + .link_libc = !wasm, + .single_threaded = if (wasm) true else null, + }); + var it = src.import_table.iterator(); + while (it.next()) |kv| { + const import = kv.key_ptr.*; + if (std.mem.eql(u8, import, "dvui") or std.mem.eql(u8, import, "core") or std.mem.eql(u8, import, "fizzy_sdk") or std.mem.eql(u8, import, "icons")) continue; + m.addImport(import, kv.value_ptr.*); + } + m.addImport("dvui", w.dvui); + m.addImport("core", w.core); + m.addImport("fizzy_sdk", w.sdk); + if (w.icons) |icons| m.addImport("icons", icons); + w.consumer.addImport(name, m); + return m; +} diff --git a/build/sdk.zig b/build/sdk.zig index 836c58695..0a340532a 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -147,8 +147,9 @@ pub fn buildOptsModule(opts: *std.Build.Step.Options) *std.Build.Module { pub const BundledPlugin = struct { /// The plugin's import name and id — `plugin_id` in its root, `plugin.zig.zon`'s `.id`. name: []const u8, - /// Null for a plugin this target does not build (markdown on wasm); it is left out. - module: ?*std.Build.Module, + /// The plugin's static module. Null for one of fizzy's own (`fizzy.plugins`), which fizzy + /// builds for each executable. + module: ?*std.Build.Module = null, }; /// The generated `bundled_plugins` module: `pub const modules = .{ @import("a"), @import("b") … }` diff --git a/build/web.zig b/build/web.zig index bf6109579..0408f200f 100644 --- a/build/web.zig +++ b/build/web.zig @@ -5,13 +5,6 @@ const core_mod = @import("fizzy_sdk").core_module; const plugins = @import("plugins.zig"); const sdk = @import("sdk.zig"); -const workbench_plugin = plugins.workbench; -const text_plugin = plugins.text; -const image_plugin = plugins.image; -const archive_plugin = plugins.archive; -const devtools_plugin = plugins.devtools; -const markdown_plugin = plugins.markdown; - pub fn addSteps( b: *std.Build, optimize: std.builtin.OptimizeMode, @@ -174,59 +167,28 @@ pub fn addSteps( // lazy analysis skips file-scope consts that no reachable body uses. // So no `backend` module is wired in for the web build. - const workbench_module_web = workbench_plugin.addStaticModule(b, web_target, optimize, .{ + // What the application links, as the native exe has it (`build/exe.zig`), fizzy's own built + // here for wasm and the application's re-homed: modules made for the native target get this + // build's framework modules, their own imports kept. + const wiring: plugins.Wiring = .{ + .target = web_target, + .optimize = optimize, .dvui = dvui_web, .core = core_module_web, .sdk = sdk_module_web, .icons = icons_web, .backend = null, - }, workbench_opts, web_exe.root_module); - const text_module_web = text_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - .icons = icons_web, - }, web_exe.root_module); - const image_module_web = image_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - }, web_exe.root_module); - const archive_module_web = archive_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - }, web_exe.root_module); - const markdown_module_web = markdown_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - }, web_exe.root_module); - const devtools_module_web = devtools_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - }, web_exe.root_module); + .workbench_opts = workbench_opts, + .consumer = web_exe.root_module, + }; + const linked = plugins.link(b, wiring, app_plugins); + const markdown_module_web = linked.markdown; var bundled_list: std.ArrayList(sdk.BundledPlugin) = .empty; - bundled_list.appendSlice(b.allocator, &.{ - .{ .name = "workbench", .module = workbench_module_web }, - .{ .name = "text", .module = text_module_web }, - .{ .name = "image", .module = image_module_web }, - .{ .name = "archive", .module = archive_module_web }, - .{ .name = "markdown", .module = markdown_module_web }, - .{ .name = "devtools", .module = devtools_module_web }, - }) catch @panic("OOM"); - // Out-of-tree plugins the web build links in: the application's own list (`buildApp`), - // whose modules were made for the native target and are re-homed here, and the named - // dependencies resolved directly for wasm. Same re-homing the native exe does — a copy - // with this build's framework modules and the plugin's own imports kept. - for (app_plugins) |p| { - const src_mod = p.module orelse continue; - bundled_list.append(b.allocator, .{ .name = p.name, .module = rehome(b, web_target, optimize, src_mod, dvui_web, core_module_web, sdk_module_web, icons_web) }) catch @panic("OOM"); - } + bundled_list.appendSlice(b.allocator, linked.plugins) catch @panic("OOM"); + // And the named dependencies, resolved directly for wasm. for (web_plugin_deps) |dep_name| { const dep = b.lazyDependency(dep_name, .{ .target = web_target, .optimize = optimize }) orelse continue; - bundled_list.append(b.allocator, .{ .name = dep_name, .module = rehome(b, web_target, optimize, dep.module("plugin"), dvui_web, core_module_web, sdk_module_web, icons_web) }) catch @panic("OOM"); + bundled_list.append(b.allocator, .{ .name = dep_name, .module = plugins.rehome(b, wiring, dep_name, dep.module("plugin")) }) catch @panic("OOM"); } // A plugin may ship pages of its own beside the app (its `web/` directory → `plugins//`): // the far end of a popup round trip that is the plugin's, not fizzy's — a provider's @@ -394,33 +356,3 @@ fn stampStorage(b: *std.Build) std.Build.LazyPath { const stamped = std.mem.replaceOwned(u8, b.allocator, html, token, name) catch @panic("OOM"); return b.addWriteFiles().add("index.html", stamped); } - -fn rehome( - b: *std.Build, - web_target: std.Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, - src_mod: *std.Build.Module, - dvui_mod: *std.Build.Module, - core_module: *std.Build.Module, - sdk_mod: *std.Build.Module, - icons_mod: ?*std.Build.Module, -) *std.Build.Module { - const m = b.createModule(.{ - .target = web_target, - .optimize = optimize, - .root_source_file = src_mod.root_source_file, - .link_libc = false, - .single_threaded = true, - }); - var it = src_mod.import_table.iterator(); - while (it.next()) |kv| { - const name = kv.key_ptr.*; - if (std.mem.eql(u8, name, "dvui") or std.mem.eql(u8, name, "core") or std.mem.eql(u8, name, "fizzy_sdk") or std.mem.eql(u8, name, "icons")) continue; - m.addImport(name, kv.value_ptr.*); - } - m.addImport("dvui", dvui_mod); - m.addImport("core", core_module); - m.addImport("fizzy_sdk", sdk_mod); - if (icons_mod) |icons| m.addImport("icons", icons); - return m; -} diff --git a/core/anim.zig b/core/anim.zig index 6d271cbd0..21c4d824f 100644 --- a/core/anim.zig +++ b/core/anim.zig @@ -228,6 +228,9 @@ pub const CrossFade = struct { const mode: BlitMode = if (self.opaque_snapshots) .over else .lerp; if (self.incoming) |tex| blitWith(tex, &self.incoming_frost, self.incoming_rect, self.inner, s.in_blur, s.in_alpha, mode); if (self.texture) |tex| blitWith(tex, &self.frost, self.rect, self.inner, s.out_blur, s.out_alpha, mode); + // Over the incoming view, whose glass describes only the view: it reads again. + if (self.incoming != null) BlurBackdrop.underChanged(self.incoming_rect); + if (self.texture != null) BlurBackdrop.underChanged(self.rect); // Nothing else is animating, so without this an idle app would sleep mid-fade. dvui.refresh(null, @src(), null); diff --git a/core/dialogs.zig b/core/dialogs.zig index 66569c808..5dc879650 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -23,6 +23,7 @@ const Snapshot = @import("replay").Snapshot; const anim = @import("anim.zig"); const draw = @import("draw.zig"); const screens = @import("screens.zig"); +const native_glass = @import("native_glass.zig"); /// Core-owned dialog chrome state, set by the dialog framework and read by /// fizzy so core stays decoupled from the editor. When a modal is open fizzy @@ -188,6 +189,21 @@ pub fn carriedGlass(id: dvui.Id, r: dvui.Rect.Physical, scale: f32) void { glassShadow(r, corners, scale, surfaceShadow(), 1); } +/// A carried thing's glass where it is drawn in a floating widget of its own (`layer`, its id): +/// the OS's where the OS draws glass declared outside a view drag (`native_glass.offered`: macOS +/// 26), as a view's card carried there is, and what the layer holds goes over that glass in the +/// OS's overlay (`screens.markCarried`), drawn in none of the app's own windows; elsewhere +/// `carriedGlass`. A row lifted out of a list carried this way is the same glass a tab or a file +/// is when it is carried. +pub fn carriedCard(layer: dvui.Id, r: dvui.Rect.Physical, scale: f32) void { + if (native_glass.offered()) { + native_glass.add(.{ .rect = r, .radius = @min(rounding.scaled(rounding.card) * scale, @min(r.w, r.h) / 2) }); + screens.markCarried(layer); + return; + } + carriedGlass(layer, r, scale); +} + /// Whether carried things are shown in windows of their own this run (fizzy's float windows on /// macOS): a carried view's photograph is taken without the place's background, its content over /// the window's material — set by the app before anything draws. diff --git a/core/gfx/LiquidField.zig b/core/gfx/LiquidField.zig index 426567d50..b216613a5 100644 --- a/core/gfx/LiquidField.zig +++ b/core/gfx/LiquidField.zig @@ -1,5 +1,5 @@ //! One layer of liquid glass: rounded boxes that run together where they come close, drawn as -//! frosted, refracting glass in one pass at every pixel (`shaders/liquid_glass.glsl`). +//! frosted, refracting glass in one pass at every pixel (`shaders/liquid_glass.fragment.hlsl`). //! //! **What it is.** Each shape is a rounded box — a circle is a square rounded all the way, a //! capsule a rect rounded across its short side. Their outlines are joined by a smooth minimum @@ -148,7 +148,7 @@ pub fn clusters(self: *const LiquidField) Clusters { pub const uniform_vec4s = header_vec4s + shape_vec4s * max_shapes; -/// What the program reads, `uData` in `shaders/liquid_glass.glsl`. +/// What the program reads, `uData` in `shaders/liquid_glass.fragment.hlsl`. pub const Uniforms = extern struct { /// Where the frost starts, and 1 / its size: physical pixels to its uv. frost_map: [4]f32, @@ -231,17 +231,18 @@ const rim_line_width: f32 = 0.8; // ── Drawing ───────────────────────────────────────────────────────────────────────────────────── -/// The program for the native backend on Vulkan and D3D12, compiled by shadercross from -/// `shaders/liquid_glass.fragment.hlsl` (the commands are at its top). Copied out of the embed to -/// be aligned: Vulkan takes SPIR-V as 32-bit words. +/// The program, written once in `shaders/liquid_glass.fragment.hlsl` and compiled from it for every +/// backend (`scripts/compile-shaders.sh`, checked in CI): SPIR-V and DXIL for the native backend on +/// Vulkan and D3D12, Metal for it on macOS, GLSL ES 3.00 for the web. The binary forms are copied +/// out of the embed to be aligned: Vulkan takes SPIR-V as 32-bit words. const compiled = struct { const spirv align(8) = @embedFile("shaders/compiled/spv/liquid_glass.fragment.spv").*; const dxil align(8) = @embedFile("shaders/compiled/dxil/liquid_glass.fragment.dxil").*; }; var program: programs.Program = .from(.{ - .glsl = @embedFile("shaders/liquid_glass.glsl"), - .msl = @embedFile("shaders/liquid_glass.metal"), + .glsl = @embedFile("shaders/compiled/glsl/liquid_glass.fragment.glsl"), + .msl = @embedFile("shaders/compiled/msl/liquid_glass.fragment.msl"), .spirv = &compiled.spirv, .dxil = &compiled.dxil, }, .{ .textures = 1, .uniform_vec4s = uniform_vec4s }); @@ -431,7 +432,7 @@ pub const Sample = struct { light: f32, }; -/// `shaders/liquid_glass.glsl`'s field, line for line, over every shape: for hit tests and tests. +/// `shaders/liquid_glass.fragment.hlsl`'s field, line for line, over every shape: for hit tests and tests. pub fn sample(self: *const LiquidField, p: dvui.Point.Physical) Sample { const big = std.math.floatMax(f32); var d1: f32 = big; diff --git a/core/gfx/glass_look.zig b/core/gfx/glass_look.zig index 5207160dd..577e017a0 100644 --- a/core/gfx/glass_look.zig +++ b/core/gfx/glass_look.zig @@ -216,7 +216,7 @@ pub fn window(opacity: f32) Window { // ── The app's glass ───────────────────────────────────────────────────────────────────────────── -/// The app's glass at the opacity, as the glass program (`shaders/liquid_glass.glsl`) reads it. Shaped +/// The app's glass at the opacity, as the glass program (`shaders/liquid_glass.fragment.hlsl`) reads it. Shaped /// after Apple's lens, measured over a striped pattern: the backdrop pulled *inward* from a band /// near the rim — `bevel` of the shape's shorter half, at most `bevel_cap` points — by up to `bend` /// times that band, which magnifies it and, past 1, folds it into a mirrored, darker band just diff --git a/core/gfx/programs.zig b/core/gfx/programs.zig index 942f4d7ae..f7e027196 100644 --- a/core/gfx/programs.zig +++ b/core/gfx/programs.zig @@ -53,6 +53,11 @@ //! `MAX_VEC4` for the length of its uniforms, `uniform vec4 uData[MAX_VEC4]`. Inputs are //! `vColor` and `vTextureCoord`; samplers are `uSampler` (unit 0, the draw's own texture), //! `uTex1` and `uTex2` (the extra textures `begin` binds). +//! +//! **Or generated.** A complete GLSL ES 3.00 source — one starting with its `#version` line — is +//! compiled as it is, on WebGL2 only. That is what `scripts/compile-shaders.sh` makes from a +//! program written once in HLSL, with the names above; fizzy's glass is one +//! (`shaders/liquid_glass.fragment.hlsl`). const std = @import("std"); const dvui = @import("dvui"); diff --git a/core/gfx/shaders/compiled/dxil/liquid_glass.fragment.dxil b/core/gfx/shaders/compiled/dxil/liquid_glass.fragment.dxil index e8a82f9af..24e69dce5 100644 Binary files a/core/gfx/shaders/compiled/dxil/liquid_glass.fragment.dxil and b/core/gfx/shaders/compiled/dxil/liquid_glass.fragment.dxil differ diff --git a/core/gfx/shaders/compiled/glsl/liquid_glass.fragment.glsl b/core/gfx/shaders/compiled/glsl/liquid_glass.fragment.glsl new file mode 100644 index 000000000..3c3f45f42 --- /dev/null +++ b/core/gfx/shaders/compiled/glsl/liquid_glass.fragment.glsl @@ -0,0 +1,477 @@ +#version 300 es +precision highp float; +precision highp int; + +uniform highp vec4 uData[55]; +uniform highp sampler2D uSampler; +uniform highp sampler2D uTex1; + +in highp vec4 vColor; +in highp vec2 vTextureCoord; +layout(location = 0) out highp vec4 out_var_SV_Target0; + +void main() +{ + highp vec4 _678; + do + { + int _90 = int((vColor.y * 255.0) + 0.5); + int _95 = int((vColor.z * 255.0) + 0.5); + bool _111 = uData[5].y > 0.0; + highp float _113; + highp float _116; + highp vec2 _118; + highp vec2 _120; + highp vec4 _122; + highp vec4 _124; + highp float _126; + highp float _128; + highp float _130; + highp float _132; + _113 = 1000000000.0; + _116 = 1000000000.0; + _118 = vec2(0.0); + _120 = vec2(0.0); + _122 = vec4(0.0); + _124 = vec4(0.0); + _126 = 1000000000.0; + _128 = 1000000000.0; + _130 = 1000000000.0; + _132 = 1000000000.0; + highp float _117; + highp vec2 _121; + highp vec4 _125; + highp float _129; + highp float _133; + highp float _114; + highp vec2 _119; + highp vec4 _123; + highp float _127; + highp float _131; + int _134 = _90; + for (;;) + { + bool _143; + if (_134 < (_90 + _95)) + { + _143 = _134 < 16; + } + else + { + _143 = false; + } + if (_143) + { + int _145 = 3 * _134; + int _146 = 7 + _145; + int _152 = _145 + 9; + highp vec2 _156 = vTextureCoord - uData[_146 * 1 + 0].xy; + highp float _160 = min(uData[_146 * 1 + 0].z, uData[_146 * 1 + 0].w); + highp vec4 _162 = min(uData[(_145 + 8) * 1 + 0], vec4(_160)); + highp float _163 = _156.x; + highp float _184; + if (_163 > 0.0) + { + highp float _175; + if (_156.y > 0.0) + { + _175 = _162.z; + } + else + { + _175 = _162.y; + } + _184 = _175; + } + else + { + highp float _183; + if (_156.y > 0.0) + { + _183 = _162.w; + } + else + { + _183 = _162.x; + } + _184 = _183; + } + highp vec2 _188 = (abs(_156) - uData[_146 * 1 + 0].zw) + vec2(_184); + highp float _190 = (_163 < 0.0) ? (-1.0) : 1.0; + highp float _193 = (_156.y < 0.0) ? (-1.0) : 1.0; + highp float _195 = _188.x; + bool _201; + if (_195 > 0.0) + { + _201 = _188.y > 0.0; + } + else + { + _201 = false; + } + highp vec2 _215; + if (_201) + { + _215 = normalize(_188) * vec2(_190, _193); + } + else + { + highp vec2 _214; + if (_195 > _188.y) + { + _214 = vec2(_190, 0.0); + } + else + { + _214 = vec2(0.0, _193); + } + _215 = _214; + } + highp float _222 = (min(max(_195, _188.y), 0.0) + length(max(_188, vec2(0.0)))) - _184; + bool _223 = _222 < _132; + if (_223) + { + _114 = _116; + _123 = _124; + _131 = _132; + } + else + { + bool _227 = _222 < _130; + bvec4 _229 = bvec4(_227); + _114 = _227 ? _160 : _113; + _123 = vec4(_229.x ? uData[_152 * 1 + 0].x : _122.x, _229.y ? uData[_152 * 1 + 0].y : _122.y, _229.z ? uData[_152 * 1 + 0].z : _122.z, _229.w ? uData[_152 * 1 + 0].w : _122.w); + _131 = _227 ? _222 : _130; + } + _117 = _223 ? _160 : _116; + bvec4 _232 = bvec4(_223); + _125 = vec4(_232.x ? uData[_152 * 1 + 0].x : _124.x, _232.y ? uData[_152 * 1 + 0].y : _124.y, _232.z ? uData[_152 * 1 + 0].z : _124.z, _232.w ? uData[_152 * 1 + 0].w : _124.w); + _133 = _223 ? _222 : _132; + highp vec2 _277; + highp float _278; + if (uData[_152 * 1 + 0].w < 0.5) + { + highp float _243 = (vTextureCoord.x - uData[_146 * 1 + 0].x) + uData[_146 * 1 + 0].z; + highp float _245 = (uData[_146 * 1 + 0].x + uData[_146 * 1 + 0].z) - vTextureCoord.x; + highp float _250 = (vTextureCoord.y - uData[_146 * 1 + 0].y) + uData[_146 * 1 + 0].w; + highp float _252 = (uData[_146 * 1 + 0].y + uData[_146 * 1 + 0].w) - vTextureCoord.y; + highp float _256 = min(min(_243, _245), min(_250, _252)); + highp vec4 _261 = exp((vec4(_256) - vec4(_243, _245, _250, _252)) / vec4(uData[1].y)); + highp float _262 = _261.x; + highp float _263 = _261.y; + highp float _265 = _261.z; + highp float _267 = _261.w; + highp float _268 = ((_262 + _263) + _265) + _267; + _277 = vec2(_263 - _262, _267 - _265) / vec2(_268); + _278 = (uData[1].y * log(_268)) - _256; + } + else + { + _277 = _215; + _278 = _222; + } + bool _279 = _278 < _128; + if (_279) + { + _119 = _120; + _127 = _128; + } + else + { + bool _283 = _278 < _126; + bvec2 _284 = bvec2(_283); + _119 = vec2(_284.x ? _277.x : _118.x, _284.y ? _277.y : _118.y); + _127 = _283 ? _278 : _126; + } + bvec2 _287 = bvec2(_279); + _121 = vec2(_287.x ? _277.x : _120.x, _287.y ? _277.y : _120.y); + _129 = _279 ? _278 : _128; + _113 = _114; + _116 = _117; + _118 = _119; + _120 = _121; + _122 = _123; + _124 = _125; + _126 = _127; + _128 = _129; + _130 = _131; + _132 = _133; + _134++; + continue; + } + else + { + break; + } + } + highp float _289 = max(uData[1].x, 9.9999997473787516355514526367188e-05); + highp float _294 = max(_289 - abs(_132 - _130), 0.0) / _289; + highp float _295 = _294 * _294; + highp float _296 = _295 * 0.5; + highp float _301; + if (_130 < _132) + { + _301 = 1.0 - _296; + } + else + { + _301 = _296; + } + highp float _305 = min(_132, _130) - ((_295 * _289) * 0.25); + highp float _310 = max(_289 - abs(_128 - _126), 0.0) / _289; + highp float _311 = _310 * _310; + highp float _312 = _311 * 0.5; + highp float _317; + if (_126 < _128) + { + _317 = 1.0 - _312; + } + else + { + _317 = _312; + } + highp float _323 = clamp(0.5 - _305, 0.0, 1.0); + if (_323 <= 0.0) + { + discard; + } + if (vColor.x < 0.5) + { + _678 = vec4(0.0, 0.0, 0.0, _323); + break; + } + highp vec4 _334 = mix(_124, _122, vec4(_301)); + highp vec2 _336 = mix(_120, _118, vec2(_317)); + highp float _338 = max(0.0, -(min(_128, _126) - ((_311 * _289) * 0.25))); + highp float _339 = -_338; + highp float _342 = exp(_339 / uData[1].z); + highp float _348 = _334.y; + highp float _382; + highp float _383; + highp vec2 _384; + if (_111) + { + highp float _356 = max(min(uData[5].y * mix(_116, _113, _301), uData[6].w), 1.0); + highp float _359 = clamp(1.0 - (_338 / _356), 0.0, 1.0); + highp float _364 = (((uData[5].z * _356) * _348) * _359) * _359; + _382 = _359; + _383 = _364; + _384 = clamp(((vTextureCoord - (_336 * _364)) - uData[0].xy) * uData[0].zw, vec2(0.0), vec2(1.0)); + } + else + { + _382 = 0.0; + _383 = 0.0; + _384 = clamp(((vTextureCoord + (_336 * ((uData[2].x * _348) * _342))) - uData[0].xy) * uData[0].zw, vec2(0.0), vec2(1.0)); + } + highp vec4 _388 = textureLod(uSampler, _384, 0.0); + bool _390 = uData[4].w > 0.5; + highp vec4 _398; + if (_390) + { + _398 = textureLod(uTex1, _384, 0.0); + } + else + { + _398 = _388; + } + bool _403; + if (_111) + { + _403 = uData[6].y > 0.0; + } + else + { + _403 = false; + } + bool _407; + if (_403) + { + _407 = _383 > 0.5; + } + else + { + _407 = false; + } + highp vec4 _468; + highp vec4 _469; + if (_407) + { + highp vec2 _415 = (_336 * ((_383 * 0.039999999105930328369140625) * uData[6].y)) * uData[0].zw; + highp vec2 _417 = clamp(_384 + _415, vec2(0.0), vec2(1.0)); + highp vec2 _419 = clamp(_384 - _415, vec2(0.0), vec2(1.0)); + highp vec4 _423 = textureLod(uSampler, _417, 0.0); + highp float _428 = _388.w; + highp float _429 = (_423.x / max(_423.w, 9.9999997473787516355514526367188e-05)) * _428; + highp vec4 _430 = _388; + _430.x = _429; + highp vec4 _434 = textureLod(uSampler, _419, 0.0); + highp float _439 = (_434.z / max(_434.w, 9.9999997473787516355514526367188e-05)) * _428; + _430.z = _439; + highp vec4 _467; + if (_390) + { + highp vec4 _447 = textureLod(uTex1, _417, 0.0); + highp vec4 _454 = _398; + _454.x = (_447.x / max(_447.w, 9.9999997473787516355514526367188e-05)) * _398.w; + highp vec4 _458 = textureLod(uTex1, _419, 0.0); + _454.z = (_458.z / max(_458.w, 9.9999997473787516355514526367188e-05)) * _398.w; + _467 = _454; + } + else + { + highp vec4 _465 = _398; + _465.x = _429; + _465.z = _439; + _467 = _465; + } + _468 = _467; + _469 = _430; + } + else + { + _468 = _398; + _469 = _388; + } + highp float _477; + if (uData[6].x > 0.5) + { + _477 = 1.0; + } + else + { + _477 = _469.w; + } + highp vec4 _486 = vec4(_469.xyz / vec3(max(_469.w, 9.9999997473787516355514526367188e-05)), 1.0); + highp vec4 _495 = vec4(_468.xyz / vec3(max(_468.w, 9.9999997473787516355514526367188e-05)), 1.0); + bool _497 = uData[4].z > 0.5; + highp float _502; + if (_497) + { + _502 = uData[4].x; + } + else + { + _502 = 0.0; + } + highp float _511; + if (_111) + { + _511 = _502 * (1.0 - ((_382 * _382) * (1.0 - smoothstep(0.800000011920928955078125, 1.0, _502)))); + } + else + { + _511 = _502; + } + highp float _514 = 1.0 - _511; + highp float _522; + if (_111) + { + _522 = _382; + } + else + { + _522 = _342 * _342; + } + highp float _525 = clamp(((uData[2].y * _348) * _522) * _514, 0.0, 1.0); + highp vec4 _531 = ((_495 * _525) + ((mix(_495, _486, vec4(clamp(_334.x, 0.0, 1.0))) * _514) * (1.0 - _525))) + (uData[3] * _511); + highp float _536 = dot(_336, vec2(-0.707106769084930419921875)); + highp float _537 = max(_536, 0.0); + highp float _539 = max(-_536, 0.0); + highp float _542 = abs(_536); + bool _547; + if (_111) + { + _547 = uData[6].z > 0.0; + } + else + { + _547 = false; + } + highp float _567; + if (_547) + { + _567 = (uData[6].z * (1.0 - smoothstep(0.20000000298023223876953125, 0.699999988079071044921875, _542))) * exp((-max(0.0, -_305)) / (uData[2].z * 2.5)); + } + else + { + _567 = 0.85000002384185791015625 * (1.0 - smoothstep(0.0, uData[2].z * 0.625, -_305)); + } + highp float _575; + if (_111) + { + _575 = max((uData[5].w * _382) * _382, _567); + } + else + { + _575 = 0.0; + } + highp float _578 = _477 * (1.0 - _575); + highp vec4 _584 = vec4((_531 + (_486 * (1.0 - _531.w))).xyz * _578, _578 + _575); + highp float _603; + if (_111) + { + highp float _594; + if (_547) + { + _594 = uData[6].z * smoothstep(0.75, 1.0, _542); + } + else + { + _594 = 0.25; + } + _603 = _594; + } + else + { + _603 = (0.449999988079071044921875 * ((_537 * sqrt(_537)) + ((0.75 * _539) * sqrt(_539)))) + 0.0599999986588954925537109375; + } + highp float _630; + if (_547) + { + _630 = exp((-max(0.0, -_305)) / (uData[2].z * 1.25)); + } + else + { + highp float _629; + if (_111) + { + highp float _617 = -_305; + _629 = smoothstep(uData[2].z * 0.3125, uData[2].z * 0.625, _617) * (1.0 - smoothstep(uData[2].z * 1.5, uData[2].z * 2.0, _617)); + } + else + { + _629 = exp((-max(0.0, -_305)) / uData[2].z); + } + _630 = _629; + } + highp float _637; + if (_111) + { + _637 = 0.0; + } + else + { + _637 = (0.039999999105930328369140625 * exp(_339 / uData[1].w)) * _537; + } + highp float _643; + if (_497) + { + _643 = uData[4].y; + } + else + { + _643 = 0.0; + } + highp vec3 _653 = _584.xyz + vec3(clamp((_643 + _334.z) + ((_348 * uData[2].w) * ((_630 * _603) + _637)), 0.0, 1.0)); + highp vec4 _655 = clamp(vec4(_653.x, _653.y, _653.z, _584.w), vec4(0.0), vec4(1.0)); + highp float _662 = max(_655.w, max(_655.x, max(_655.y, _655.z))); + highp vec4 _663 = _655; + _663.w = _662; + highp vec3 _674 = _663.xyz + vec3(((fract(52.98291778564453125 * fract(dot(vTextureCoord, vec2(0.067110560834407806396484375, 0.005837149918079376220703125)))) - 0.5) * uData[5].x) * _662); + _678 = clamp(vec4(_674.x, _674.y, _674.z, _663.w), vec4(0.0), vec4(1.0)) * _323; + break; + } while(false); + out_var_SV_Target0 = _678; +} + diff --git a/core/gfx/shaders/compiled/msl/liquid_glass.fragment.msl b/core/gfx/shaders/compiled/msl/liquid_glass.fragment.msl new file mode 100644 index 000000000..bce3c888d --- /dev/null +++ b/core/gfx/shaders/compiled/msl/liquid_glass.fragment.msl @@ -0,0 +1,484 @@ +#include +#include + +using namespace metal; + +struct type_Uniforms +{ + float4 uData[55]; +}; + +struct main0_out +{ + float4 out_var_SV_Target0 [[color(0)]]; +}; + +struct main0_in +{ + float4 in_var_COLOR [[user(locn0)]]; + float2 in_var_TEXCOORD0 [[user(locn1)]]; +}; + +fragment main0_out main0(main0_in in [[stage_in]], constant type_Uniforms& Uniforms [[buffer(0)]], texture2d Frost [[texture(0)]], texture2d Sharp [[texture(1)]], sampler FrostSampler [[sampler(0)]], sampler SharpSampler [[sampler(1)]]) +{ + main0_out out = {}; + float4 _678; + do + { + int _90 = int((in.in_var_COLOR.y * 255.0) + 0.5); + int _95 = int((in.in_var_COLOR.z * 255.0) + 0.5); + bool _111 = Uniforms.uData[5].y > 0.0; + float _113; + float _116; + float2 _118; + float2 _120; + float4 _122; + float4 _124; + float _126; + float _128; + float _130; + float _132; + _113 = 1000000000.0; + _116 = 1000000000.0; + _118 = float2(0.0); + _120 = float2(0.0); + _122 = float4(0.0); + _124 = float4(0.0); + _126 = 1000000000.0; + _128 = 1000000000.0; + _130 = 1000000000.0; + _132 = 1000000000.0; + float _117; + float2 _121; + float4 _125; + float _129; + float _133; + float _114; + float2 _119; + float4 _123; + float _127; + float _131; + int _134 = _90; + for (;;) + { + bool _143; + if (_134 < (_90 + _95)) + { + _143 = _134 < 16; + } + else + { + _143 = false; + } + if (_143) + { + int _145 = 3 * _134; + int _146 = 7 + _145; + int _152 = _145 + 9; + float2 _156 = in.in_var_TEXCOORD0 - Uniforms.uData[_146].xy; + float _160 = precise::min(Uniforms.uData[_146].z, Uniforms.uData[_146].w); + float4 _162 = precise::min(Uniforms.uData[_145 + 8], float4(_160)); + float _163 = _156.x; + float _184; + if (_163 > 0.0) + { + float _175; + if (_156.y > 0.0) + { + _175 = _162.z; + } + else + { + _175 = _162.y; + } + _184 = _175; + } + else + { + float _183; + if (_156.y > 0.0) + { + _183 = _162.w; + } + else + { + _183 = _162.x; + } + _184 = _183; + } + float2 _188 = (abs(_156) - Uniforms.uData[_146].zw) + float2(_184); + float _190 = (_163 < 0.0) ? (-1.0) : 1.0; + float _193 = (_156.y < 0.0) ? (-1.0) : 1.0; + float _195 = _188.x; + bool _201; + if (_195 > 0.0) + { + _201 = _188.y > 0.0; + } + else + { + _201 = false; + } + float2 _215; + if (_201) + { + _215 = fast::normalize(_188) * float2(_190, _193); + } + else + { + float2 _214; + if (_195 > _188.y) + { + _214 = float2(_190, 0.0); + } + else + { + _214 = float2(0.0, _193); + } + _215 = _214; + } + float _222 = (precise::min(precise::max(_195, _188.y), 0.0) + length(precise::max(_188, float2(0.0)))) - _184; + bool _223 = _222 < _132; + if (_223) + { + _114 = _116; + _123 = _124; + _131 = _132; + } + else + { + bool _227 = _222 < _130; + _114 = _227 ? _160 : _113; + _123 = select(_122, Uniforms.uData[_152], bool4(_227)); + _131 = _227 ? _222 : _130; + } + _117 = _223 ? _160 : _116; + _125 = select(_124, Uniforms.uData[_152], bool4(_223)); + _133 = _223 ? _222 : _132; + float2 _277; + float _278; + if (Uniforms.uData[_152].w < 0.5) + { + float _243 = (in.in_var_TEXCOORD0.x - Uniforms.uData[_146].x) + Uniforms.uData[_146].z; + float _245 = (Uniforms.uData[_146].x + Uniforms.uData[_146].z) - in.in_var_TEXCOORD0.x; + float _250 = (in.in_var_TEXCOORD0.y - Uniforms.uData[_146].y) + Uniforms.uData[_146].w; + float _252 = (Uniforms.uData[_146].y + Uniforms.uData[_146].w) - in.in_var_TEXCOORD0.y; + float _256 = precise::min(precise::min(_243, _245), precise::min(_250, _252)); + float4 _261 = exp((float4(_256) - float4(_243, _245, _250, _252)) / float4(Uniforms.uData[1].y)); + float _262 = _261.x; + float _263 = _261.y; + float _265 = _261.z; + float _267 = _261.w; + float _268 = ((_262 + _263) + _265) + _267; + _277 = float2(_263 - _262, _267 - _265) / float2(_268); + _278 = (Uniforms.uData[1].y * log(_268)) - _256; + } + else + { + _277 = _215; + _278 = _222; + } + bool _279 = _278 < _128; + if (_279) + { + _119 = _120; + _127 = _128; + } + else + { + bool _283 = _278 < _126; + _119 = select(_118, _277, bool2(_283)); + _127 = _283 ? _278 : _126; + } + _121 = select(_120, _277, bool2(_279)); + _129 = _279 ? _278 : _128; + _113 = _114; + _116 = _117; + _118 = _119; + _120 = _121; + _122 = _123; + _124 = _125; + _126 = _127; + _128 = _129; + _130 = _131; + _132 = _133; + _134++; + continue; + } + else + { + break; + } + } + float _289 = precise::max(Uniforms.uData[1].x, 9.9999997473787516355514526367188e-05); + float _294 = precise::max(_289 - abs(_132 - _130), 0.0) / _289; + float _295 = _294 * _294; + float _296 = _295 * 0.5; + float _301; + if (_130 < _132) + { + _301 = 1.0 - _296; + } + else + { + _301 = _296; + } + float _305 = precise::min(_132, _130) - ((_295 * _289) * 0.25); + float _310 = precise::max(_289 - abs(_128 - _126), 0.0) / _289; + float _311 = _310 * _310; + float _312 = _311 * 0.5; + float _317; + if (_126 < _128) + { + _317 = 1.0 - _312; + } + else + { + _317 = _312; + } + float _323 = fast::clamp(0.5 - _305, 0.0, 1.0); + if (_323 <= 0.0) + { + discard_fragment(); + } + if (in.in_var_COLOR.x < 0.5) + { + _678 = float4(0.0, 0.0, 0.0, _323); + break; + } + float4 _334 = mix(_124, _122, float4(_301)); + float2 _336 = mix(_120, _118, float2(_317)); + float _338 = precise::max(0.0, -(precise::min(_128, _126) - ((_311 * _289) * 0.25))); + float _339 = -_338; + float _342 = exp(_339 / Uniforms.uData[1].z); + float _348 = _334.y; + float _382; + float _383; + float2 _384; + if (_111) + { + float _356 = precise::max(precise::min(Uniforms.uData[5].y * mix(_116, _113, _301), Uniforms.uData[6].w), 1.0); + float _359 = fast::clamp(1.0 - (_338 / _356), 0.0, 1.0); + float _364 = (((Uniforms.uData[5].z * _356) * _348) * _359) * _359; + _382 = _359; + _383 = _364; + _384 = fast::clamp(((in.in_var_TEXCOORD0 - (_336 * _364)) - Uniforms.uData[0].xy) * Uniforms.uData[0].zw, float2(0.0), float2(1.0)); + } + else + { + _382 = 0.0; + _383 = 0.0; + _384 = fast::clamp(((in.in_var_TEXCOORD0 + (_336 * ((Uniforms.uData[2].x * _348) * _342))) - Uniforms.uData[0].xy) * Uniforms.uData[0].zw, float2(0.0), float2(1.0)); + } + float4 _388 = Frost.sample(FrostSampler, _384, level(0.0)); + bool _390 = Uniforms.uData[4].w > 0.5; + float4 _398; + if (_390) + { + _398 = Sharp.sample(SharpSampler, _384, level(0.0)); + } + else + { + _398 = _388; + } + bool _403; + if (_111) + { + _403 = Uniforms.uData[6].y > 0.0; + } + else + { + _403 = false; + } + bool _407; + if (_403) + { + _407 = _383 > 0.5; + } + else + { + _407 = false; + } + float4 _468; + float4 _469; + if (_407) + { + float2 _415 = (_336 * ((_383 * 0.039999999105930328369140625) * Uniforms.uData[6].y)) * Uniforms.uData[0].zw; + float2 _417 = fast::clamp(_384 + _415, float2(0.0), float2(1.0)); + float2 _419 = fast::clamp(_384 - _415, float2(0.0), float2(1.0)); + float4 _423 = Frost.sample(FrostSampler, _417, level(0.0)); + float _428 = _388.w; + float _429 = (_423.x / precise::max(_423.w, 9.9999997473787516355514526367188e-05)) * _428; + float4 _430 = _388; + _430.x = _429; + float4 _434 = Frost.sample(FrostSampler, _419, level(0.0)); + float _439 = (_434.z / precise::max(_434.w, 9.9999997473787516355514526367188e-05)) * _428; + _430.z = _439; + float4 _467; + if (_390) + { + float4 _447 = Sharp.sample(SharpSampler, _417, level(0.0)); + float4 _454 = _398; + _454.x = (_447.x / precise::max(_447.w, 9.9999997473787516355514526367188e-05)) * _398.w; + float4 _458 = Sharp.sample(SharpSampler, _419, level(0.0)); + _454.z = (_458.z / precise::max(_458.w, 9.9999997473787516355514526367188e-05)) * _398.w; + _467 = _454; + } + else + { + float4 _465 = _398; + _465.x = _429; + _465.z = _439; + _467 = _465; + } + _468 = _467; + _469 = _430; + } + else + { + _468 = _398; + _469 = _388; + } + float _477; + if (Uniforms.uData[6].x > 0.5) + { + _477 = 1.0; + } + else + { + _477 = _469.w; + } + float4 _486 = float4(_469.xyz / float3(precise::max(_469.w, 9.9999997473787516355514526367188e-05)), 1.0); + float4 _495 = float4(_468.xyz / float3(precise::max(_468.w, 9.9999997473787516355514526367188e-05)), 1.0); + bool _497 = Uniforms.uData[4].z > 0.5; + float _502; + if (_497) + { + _502 = Uniforms.uData[4].x; + } + else + { + _502 = 0.0; + } + float _511; + if (_111) + { + _511 = _502 * (1.0 - ((_382 * _382) * (1.0 - smoothstep(0.800000011920928955078125, 1.0, _502)))); + } + else + { + _511 = _502; + } + float _514 = 1.0 - _511; + float _522; + if (_111) + { + _522 = _382; + } + else + { + _522 = _342 * _342; + } + float _525 = fast::clamp(((Uniforms.uData[2].y * _348) * _522) * _514, 0.0, 1.0); + float4 _531 = ((_495 * _525) + ((mix(_495, _486, float4(fast::clamp(_334.x, 0.0, 1.0))) * _514) * (1.0 - _525))) + (Uniforms.uData[3] * _511); + float _536 = dot(_336, float2(-0.707106769084930419921875)); + float _537 = precise::max(_536, 0.0); + float _539 = precise::max(-_536, 0.0); + float _542 = abs(_536); + bool _547; + if (_111) + { + _547 = Uniforms.uData[6].z > 0.0; + } + else + { + _547 = false; + } + float _567; + if (_547) + { + _567 = (Uniforms.uData[6].z * (1.0 - smoothstep(0.20000000298023223876953125, 0.699999988079071044921875, _542))) * exp((-precise::max(0.0, -_305)) / (Uniforms.uData[2].z * 2.5)); + } + else + { + _567 = 0.85000002384185791015625 * (1.0 - smoothstep(0.0, Uniforms.uData[2].z * 0.625, -_305)); + } + float _575; + if (_111) + { + _575 = precise::max((Uniforms.uData[5].w * _382) * _382, _567); + } + else + { + _575 = 0.0; + } + float _578 = _477 * (1.0 - _575); + float4 _584 = float4((_531 + (_486 * (1.0 - _531.w))).xyz * _578, _578 + _575); + float _603; + if (_111) + { + float _594; + if (_547) + { + _594 = Uniforms.uData[6].z * smoothstep(0.75, 1.0, _542); + } + else + { + _594 = 0.25; + } + _603 = _594; + } + else + { + _603 = (0.449999988079071044921875 * ((_537 * sqrt(_537)) + ((0.75 * _539) * sqrt(_539)))) + 0.0599999986588954925537109375; + } + float _630; + if (_547) + { + _630 = exp((-precise::max(0.0, -_305)) / (Uniforms.uData[2].z * 1.25)); + } + else + { + float _629; + if (_111) + { + float _617 = -_305; + _629 = smoothstep(Uniforms.uData[2].z * 0.3125, Uniforms.uData[2].z * 0.625, _617) * (1.0 - smoothstep(Uniforms.uData[2].z * 1.5, Uniforms.uData[2].z * 2.0, _617)); + } + else + { + _629 = exp((-precise::max(0.0, -_305)) / Uniforms.uData[2].z); + } + _630 = _629; + } + float _637; + if (_111) + { + _637 = 0.0; + } + else + { + _637 = (0.039999999105930328369140625 * exp(_339 / Uniforms.uData[1].w)) * _537; + } + float _643; + if (_497) + { + _643 = Uniforms.uData[4].y; + } + else + { + _643 = 0.0; + } + float3 _653 = _584.xyz + float3(fast::clamp((_643 + _334.z) + ((_348 * Uniforms.uData[2].w) * ((_630 * _603) + _637)), 0.0, 1.0)); + float4 _655 = fast::clamp(float4(_653.x, _653.y, _653.z, _584.w), float4(0.0), float4(1.0)); + float _662 = precise::max(_655.w, precise::max(_655.x, precise::max(_655.y, _655.z))); + float4 _663 = _655; + _663.w = _662; + float3 _674 = _663.xyz + float3(((fract(52.98291778564453125 * fract(dot(in.in_var_TEXCOORD0, float2(0.067110560834407806396484375, 0.005837149918079376220703125)))) - 0.5) * Uniforms.uData[5].x) * _662); + _678 = fast::clamp(float4(_674.x, _674.y, _674.z, _663.w), float4(0.0), float4(1.0)) * _323; + break; + } while(false); + out.out_var_SV_Target0 = _678; + return out; +} + diff --git a/core/gfx/shaders/compiled/spv/liquid_glass.fragment.spv b/core/gfx/shaders/compiled/spv/liquid_glass.fragment.spv index f9d667b32..4a36459a9 100644 Binary files a/core/gfx/shaders/compiled/spv/liquid_glass.fragment.spv and b/core/gfx/shaders/compiled/spv/liquid_glass.fragment.spv differ diff --git a/core/gfx/shaders/liquid_glass.fragment.hlsl b/core/gfx/shaders/liquid_glass.fragment.hlsl index 38c81fffc..31f1d22f2 100644 --- a/core/gfx/shaders/liquid_glass.fragment.hlsl +++ b/core/gfx/shaders/liquid_glass.fragment.hlsl @@ -1,13 +1,19 @@ -// `liquid_glass.glsl` in HLSL, for fizzy's native backend on Vulkan and D3D12: the same program -// line for line as it and `liquid_glass.metal` — change all three together, and -// `LiquidField.sample` with them. shadercross compiles it to the two forms `LiquidField` embeds: +// Liquid glass (`core/gfx/LiquidField.zig`): rounded boxes run together by a smooth minimum, +// frosted and refracting, in one pass at every pixel. // -// shadercross liquid_glass.fragment.hlsl -o compiled/spv/liquid_glass.fragment.spv -// shadercross liquid_glass.fragment.hlsl -o compiled/dxil/liquid_glass.fragment.dxil +// The one source of the program: written here, in HLSL, and generated from it for every backend +// by `scripts/compile-shaders.sh` — SPIR-V (Vulkan) and DXIL (D3D12) for fizzy's native backend, +// Metal for it on macOS, GLSL ES 3.00 for the web — into `compiled/`, which CI checks against this +// (`.github/workflows/shaders.yml`). Never edit those by hand: change this, and commit what CI +// compiles. `LiquidField.sample` mirrors the field on the CPU, line for line; change it with this. +// +// Each quad's vertex colour says what it draws: r the pass (0 punches the glass's coverage out +// of what is there, 1 adds the glass), g and b the first of its shapes and how many. // // To the interface the native backend's programs take (`program_example.fragment.hlsl`): the // default vertex shader's outputs as inputs, the draw's own texture (the frost) at slot 0, the -// sharp picture at 1, the uniforms as one array. +// sharp picture at 1, the uniforms as one array. On the web the same, by the names +// `core.gfx.programs` gives them (the generator renames them). #define G 7 #define MAX_SHAPES 16 @@ -32,6 +38,8 @@ cbuffer Uniforms : register(b0, space3) float4 uData[G + 3 * MAX_SHAPES]; }; +// Signed distance to a box of half-size `b` at the origin, its corners rounded `r` +// (top-left, top-right, bottom-right, bottom-left; y down), and which way is out. float sdRoundBox(float2 p, float2 b, float4 r, out float2 g) { float rr = p.x > 0.0 ? (p.y > 0.0 ? r.z : r.y) : (p.y > 0.0 ? r.w : r.x); @@ -47,6 +55,8 @@ float sdRoundBox(float2 p, float2 b, float4 r, out float2 g) return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rr; } +// `liquid_glass.fieldAt`: a soft minimum of the distances to the box's four sides, so the way +// out turns smoothly round a corner and never folds along a diagonal. Negative inside. float softBox(float2 p, float4 s, float k, out float2 o) { float4 d = float4(p.x - s.x + s.z, s.x + s.z - p.x, p.y - s.y + s.w, s.y + s.w - p.y); @@ -57,6 +67,7 @@ float softBox(float2 p, float4 s, float k, out float2 o) return k * log(sum) - m; } +// iq's quadratic smooth minimum, and how much of `b` is in it. float smin(float a, float b, float k, out float m) { float h = max(k - abs(a - b), 0.0) / k; @@ -82,8 +93,11 @@ float4 main(PSInput input) : SV_Target0 float4 g4 = uData[4]; float4 g5 = uData[5]; // dither; the lens: its band's share of the shape, its bend, its shade float4 g6 = uData[6]; // opaque window; the lens: dispersion, a drop's diagonal light (0: a window's hairline), the band's cap + // Apple's lens (`glass_look.inApp`): the picture pulled inward from a band near the rim, + // magnified and, past a bend of 1, folded there. Off, the earlier glass: pulled from outside. bool inward = g5.y > 0.0; + // The two nearest shapes, for the outline and for the way out. float d1 = 1e9; float d2 = 1e9; float4 m1 = float4(0.0, 0.0, 0.0, 0.0); float4 m2 = float4(0.0, 0.0, 0.0, 0.0); float h1 = 1e9; float h2 = 1e9; float f1 = 1e9; float f2 = 1e9; float2 o1 = float2(0.0, 0.0); float2 o2 = float2(0.0, 0.0); @@ -122,6 +136,10 @@ float4 main(PSInput input) : SV_Target0 // Level 0 explicitly: the frost has no mips, and an implicit level after `discard` is a // derivative in divergent control flow, which Metal and GLSL let pass and HLSL does not. +// What the glass shows here. Apple's lens: from further in, within the band near the rim — `t` +// 1 at the edge, 0 a band in — by `bend` bands at the edge, falling off as t², so the picture +// magnifies toward the rim and folds where the slope passes 1. The earlier glass: further out +// along the way out, as steep as it is (`seen`). float t = 0.0; float disp = 0.0; float2 uv; @@ -166,6 +184,8 @@ float4 main(PSInput input) : SV_Target0 frost = float4(frost.rgb / max(frost.a, 1e-4), 1.0); sharp = float4(sharp.rgb / max(sharp.a, 1e-4), 1.0); +// The frost at its share of a frost / tint mix, the blur coming in from sharp. The window's +// colour comes in whatever the blur: the opacity is the colour, under clear glass too. float mixv = g4.z > 0.5 ? g4.x : 0.0; // Apple's lens: the window's colour fades out across the band, as an OS window's does // (`glass_look.window`, its feather: strong most of the way, gone at the edge), so the edge stays @@ -173,8 +193,11 @@ float4 main(PSInput input) : SV_Target0 // too (`glass_look.shine_start`, 0.8 to 1). if (inward) mixv *= 1.0 - t * t * (1.0 - smoothstep(0.8, 1.0, mixv)); float4 c = lerp(sharp, frost, b) * (1.0 - mixv); +// The rim's clearer glass over it (`drawClear`): for Apple's lens, the band, so the rim stays +// clear glass while the middle frosts. float a = clamp(g2.y * lens * (inward ? t : steepR * steepR) * (1.0 - mixv), 0.0, 1.0); c = sharp * a + c * (1.0 - sharp.a * a); +// The tint (`addTint`). c += g3 * mixv; // A translucent tint lies over the frost, not over what is behind the window: the glass is // opaque here, and `under` alone says how much of the desktop's material shows through it. @@ -199,6 +222,7 @@ float4 main(PSInput input) : SV_Target0 : 0.85 * (1.0 - smoothstep(0.0, 0.5 * px, -D)); float shade = inward ? max(g5.w * t * t, edge_dark) : 0.0; float4 o = float4(c.rgb * (under * (1.0 - shade)), under * (1.0 - shade) + shade); +// The lift and the rim's light (`drawLift`), brightest facing the top left. float spec = inward ? (diagonal ? g6.z * smoothstep(0.75, 1.0, af) : 0.25) : 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; float line_ = diagonal ? exp(-max(0.0, -D) / px) @@ -209,7 +233,7 @@ float4 main(PSInput input) : SV_Target0 // The glass's own light, added whole. Scaled with the picture, it went wherever the picture // was see-through — over the explorer, which is the window's OS glass — and the glass all but // vanished there, rim and all, where Liquid Glass keeps its bright rim over anything. - o.rgb += float3(clamp(lift + lens * g2.w * lit, 0.0, 1.0)); + o.rgb += (float3)clamp(lift + lens * g2.w * lit, 0.0, 1.0); o = clamp(o, 0.0, 1.0); // Light over a see-through picture carries its own alpha: brighter than its alpha, a pixel is // no colour premultiplied, and the frost captured from this frame next (`rgb / a`) blew it up diff --git a/core/gfx/shaders/liquid_glass.glsl b/core/gfx/shaders/liquid_glass.glsl deleted file mode 100644 index 1b2c605e0..000000000 --- a/core/gfx/shaders/liquid_glass.glsl +++ /dev/null @@ -1,224 +0,0 @@ -// Liquid glass (`core/gfx/LiquidField.zig`): rounded boxes run together by a smooth minimum, -// frosted and refracting, in one pass at every pixel. Written against the program prelude -// (`core/gfx/programs.zig`): VARYING, TEX, FRAG_COLOR, MAX_VEC4. -// -// Mirrors `LiquidField.sample` line for line, as `liquid_glass.metal` and -// `liquid_glass.fragment.hlsl` (compiled to SPIR-V and DXIL) mirror it; change them all together. -// -// Each quad's vertex colour says what it draws: r the pass (0 punches the glass's coverage out -// of what is there, 1 adds the glass), g and b the first of its shapes and how many. - -#define G 7 -#define MAX_SHAPES 16 - -VARYING vec4 vColor; -VARYING vec2 vTextureCoord; - -uniform sampler2D uSampler; // the frost -uniform sampler2D uTex1; // the picture before the blur -uniform vec4 uData[MAX_VEC4]; - -// Signed distance to a box of half-size `b` at the origin, its corners rounded `r` -// (top-left, top-right, bottom-right, bottom-left; y down), and which way is out. -float sdRoundBox(vec2 p, vec2 b, vec4 r, out vec2 g) { - float rr = p.x > 0.0 ? (p.y > 0.0 ? r.z : r.y) : (p.y > 0.0 ? r.w : r.x); - vec2 q = abs(p) - b + rr; - vec2 s = vec2(p.x < 0.0 ? -1.0 : 1.0, p.y < 0.0 ? -1.0 : 1.0); - if (q.x > 0.0 && q.y > 0.0) { - g = normalize(q) * s; - } else if (q.x > q.y) { - g = vec2(s.x, 0.0); - } else { - g = vec2(0.0, s.y); - } - return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rr; -} - -// `liquid_glass.fieldAt`: a soft minimum of the distances to the box's four sides, so the way -// out turns smoothly round a corner and never folds along a diagonal. Negative inside. -float softBox(vec2 p, vec4 s, float k, out vec2 o) { - vec4 d = vec4(p.x - s.x + s.z, s.x + s.z - p.x, p.y - s.y + s.w, s.y + s.w - p.y); - float m = min(min(d.x, d.y), min(d.z, d.w)); - vec4 w = exp((m - d) / k); - float sum = w.x + w.y + w.z + w.w; - o = vec2(w.y - w.x, w.w - w.z) / sum; - return k * log(sum) - m; -} - -// iq's quadratic smooth minimum, and how much of `b` is in it. -float smin(float a, float b, float k, out float m) { - float h = max(k - abs(a - b), 0.0) / k; - m = h * h * 0.5; - if (b < a) m = 1.0 - m; - return min(a, b) - h * h * k * 0.25; -} - -float noise(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void main() { - int first = int(vColor.g * 255.0 + 0.5); - int count = int(vColor.b * 255.0 + 0.5); - vec2 p = vTextureCoord; - vec4 g0 = uData[0]; // the frost: where it starts, and 1 / its size - vec4 g1 = uData[1]; // merge k, softness, refraction depth, light depth - vec4 g2 = uData[2]; // reach, clarity, rim line width, light - vec4 g3 = uData[3]; // tint, premultiplied - vec4 g4 = uData[4]; // mix, lift, has tint, has sharp - vec4 g5 = uData[5]; // dither; the lens: its band's share of the shape, its bend, its shade - vec4 g6 = uData[6]; // opaque window; the lens: dispersion, a drop's diagonal light (0: a window's hairline), the band's cap - // Apple's lens (`glass_look.inApp`): the picture pulled inward from a band near the rim, - // magnified and, past a bend of 1, folded there. Off, the earlier glass: pulled from outside. - bool inward = g5.y > 0.0; - - // The two nearest shapes, for the outline and for the way out. - float d1 = 1e9; float d2 = 1e9; vec4 m1 = vec4(0.0); vec4 m2 = vec4(0.0); - float h1 = 1e9; float h2 = 1e9; - float f1 = 1e9; float f2 = 1e9; vec2 o1 = vec2(0.0); vec2 o2 = vec2(0.0); - for (int i = 0; i < MAX_SHAPES; i++) { - if (i < first) continue; - if (i >= first + count) break; - vec4 s0 = uData[G + 3 * i]; - vec4 s1 = uData[G + 3 * i + 1]; - vec4 s2 = uData[G + 3 * i + 2]; - vec2 g; - float d = sdRoundBox(p - s0.xy, s0.zw, min(s1, vec4(min(s0.z, s0.w))), g); - float hs = min(s0.z, s0.w); - if (d < d1) { d2 = d1; m2 = m1; h2 = h1; d1 = d; m1 = s2; h1 = hs; } else if (d < d2) { d2 = d; m2 = s2; h2 = hs; } - vec2 o; - float f = d; - if (s2.w < 0.5) { - f = softBox(p, s0, g1.y, o); - } else { - o = g; - } - if (f < f1) { f2 = f1; o2 = o1; f1 = f; o1 = o; } else if (f < f2) { f2 = f; o2 = o; } - } - float k = max(g1.x, 0.0001); - float wm; float wf; - float D = smin(d1, d2, k, wm); - float F = smin(f1, f2, k, wf); - float cov = clamp(0.5 - D, 0.0, 1.0); - if (cov <= 0.0) discard; - if (vColor.r < 0.5) { - FRAG_COLOR = vec4(0.0, 0.0, 0.0, cov); - return; - } - - vec4 mat = mix(m1, m2, wm); // blur, lens, light, field - vec2 outv = mix(o1, o2, wf); - float soft = max(0.0, -F); - float steepR = exp(-soft / g1.z); - float steepL = exp(-soft / g1.w); - float b = clamp(mat.x, 0.0, 1.0); - float lens = mat.y; - - // What the glass shows here. Apple's lens: from further in, within the band near the rim — `t` - // 1 at the edge, 0 a band in — by `bend` bands at the edge, falling off as t², so the picture - // magnifies toward the rim and folds where the slope passes 1. The earlier glass: further out - // along the way out, as steep as it is (`seen`). - float t = 0.0; - float disp = 0.0; - vec2 uv; - if (inward) { - float W = max(min(g5.y * mix(h1, h2, wm), g6.w), 1.0); - t = clamp(1.0 - soft / W, 0.0, 1.0); - disp = g5.z * W * lens * t * t; - uv = clamp((p - outv * disp - g0.xy) * g0.zw, 0.0, 1.0); - } else { - uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); - } - vec4 frost = TEX(uSampler, uv); - vec4 sharp = g4.w > 0.5 ? TEX(uTex1, uv) : frost; - // A little colour fringe where the band bends hardest: red from a touch less far in, blue a - // touch further. - // The colour fringe: red and blue from either side, each un-premultiplied by its own alpha and - // put back at the middle's. By the middle's alone, at the edge of a clear part of the window - // the two alphas differ widely, and the fringe came out as red and blue specks. - if (inward && g6.y > 0.0 && disp > 0.5) { - vec2 du = outv * (disp * 0.04 * g6.y) * g0.zw; - vec2 uvr = clamp(uv + du, 0.0, 1.0); - vec2 uvb = clamp(uv - du, 0.0, 1.0); - vec4 frost_r = TEX(uSampler, uvr); - frost.r = frost_r.r / max(frost_r.a, 1e-4) * frost.a; - vec4 frost_b = TEX(uSampler, uvb); - frost.b = frost_b.b / max(frost_b.a, 1e-4) * frost.a; - if (g4.w > 0.5) { - vec4 sharp_r = TEX(uTex1, uvr); - sharp.r = sharp_r.r / max(sharp_r.a, 1e-4) * sharp.a; - vec4 sharp_b = TEX(uTex1, uvb); - sharp.b = sharp_b.b / max(sharp_b.a, 1e-4) * sharp.a; - } else { - sharp.r = frost.r; - sharp.b = frost.b; - } - } - // Worked out over an opaque picture of what the glass covers, then made exactly as - // see-through as that is (`under`): over a translucent window (vibrancy, Acrylic) the - // desktop's material shows through the glass as much as through the window round it. Laid - // over the translucent picture as it was, the rim — where the clear glass lies over the - // frost — came out more opaque than the face, and the material lit the face and not the rim. - // Opaque where the window is (`LiquidField.publishOpaqueWindow`): its alpha is only its shape. - float under = uData[6].x > 0.5 ? 1.0 : frost.a; - frost = vec4(frost.rgb / max(frost.a, 1e-4), 1.0); - sharp = vec4(sharp.rgb / max(sharp.a, 1e-4), 1.0); - - // The frost at its share of a frost / tint mix, the blur coming in from sharp. The window's - // colour comes in whatever the blur: the opacity is the colour, under clear glass too. - float mixv = g4.z > 0.5 ? g4.x : 0.0; - // Apple's lens: the window's colour fades out across the band, as an OS window's does - // (`glass_look.window`, its feather: strong most of the way, gone at the edge), so the edge stays - // the lens bending what is behind it — unless the colour is near whole, when it covers the edge - // too (`glass_look.shine_start`, 0.8 to 1). - if (inward) mixv *= 1.0 - t * t * (1.0 - smoothstep(0.8, 1.0, mixv)); - vec4 c = mix(sharp, frost, b) * (1.0 - mixv); - // The rim's clearer glass over it (`drawClear`): for Apple's lens, the band, so the rim stays - // clear glass while the middle frosts. - float a = clamp(g2.y * lens * (inward ? t : steepR * steepR) * (1.0 - mixv), 0.0, 1.0); - c = sharp * a + c * (1.0 - sharp.a * a); - // The tint (`addTint`). - c += g3 * mixv; - // A translucent tint lies over the frost, not over what is behind the window: the glass is - // opaque here, and `under` alone says how much of the desktop's material shows through it. - c += frost * (1.0 - c.a); - // The way the edge faces, against a light from the top left. - float facing = dot(outv, vec2(-0.70710678, -0.70710678)); - float toward = max(facing, 0.0); - float away = max(-facing, 0.0); - // Apple's lens has two edges, measured beside the OS's (`g6.z`, `glass_look.InApp.diagonal`): - // - a drop's (> 0): lit along the diagonal the light runs, top left and bottom right, about - // three pixels in and only near the diagonal; darkened across the other, top right and - // bottom left; nothing at the sides square to the light; - // - a window's (0): a dark hairline at the very edge and a bright one, two pixels, just inside - // it, the same on every side (the OS window's own border, as Liquid Glass windows have). - // Nothing across the band in either. Shade is the glass's own, not the picture's: a dark layer - // over it, as whole where the picture is see-through as where it is not. `px` is a point, in - // pixels (the rim line's width is 0.8 of one, `LiquidField.rim_line_width`). - float px = g2.z * 1.25; - float af = abs(facing); - bool diagonal = inward && g6.z > 0.0; - float edge_dark = diagonal ? g6.z * (1.0 - smoothstep(0.2, 0.7, af)) * exp(-max(0.0, -D) / (2.0 * px)) - : 0.85 * (1.0 - smoothstep(0.0, 0.5 * px, -D)); - float shade = inward ? max(g5.w * t * t, edge_dark) : 0.0; - vec4 o = vec4(c.rgb * (under * (1.0 - shade)), under * (1.0 - shade) + shade); - // The lift and the rim's light (`drawLift`), brightest facing the top left. - float spec = inward ? (diagonal ? g6.z * smoothstep(0.75, 1.0, af) : 0.25) - : 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; - float line = diagonal ? exp(-max(0.0, -D) / px) - : inward ? smoothstep(0.25 * px, 0.5 * px, -D) * (1.0 - smoothstep(1.2 * px, 1.6 * px, -D)) - : exp(-max(0.0, -D) / g2.z); - float lit = line * spec + (inward ? 0.0 : 0.04 * steepL * toward); - float lift = (g4.z > 0.5 ? g4.y : 0.0) + mat.z; - // The glass's own light, added whole. Scaled with the picture, it went wherever the picture - // was see-through — over the explorer, which is the window's OS glass — and the glass all but - // vanished there, rim and all, where Liquid Glass keeps its bright rim over anything. - o.rgb += vec3(clamp(lift + lens * g2.w * lit, 0.0, 1.0)); - o = clamp(o, 0.0, 1.0); - // Light over a see-through picture carries its own alpha: brighter than its alpha, a pixel is - // no colour premultiplied, and the frost captured from this frame next (`rgb / a`) blew it up - // into bright specks wherever the window is clear — over the explorer. Opaque, alpha is 1. - o.a = max(o.a, max(o.r, max(o.g, o.b))); - o.rgb += (noise(gl_FragCoord.xy) - 0.5) * g5.x * o.a; - FRAG_COLOR = clamp(o, 0.0, 1.0) * cov; -} diff --git a/core/gfx/shaders/liquid_glass.metal b/core/gfx/shaders/liquid_glass.metal deleted file mode 100644 index 994ecd444..000000000 --- a/core/gfx/shaders/liquid_glass.metal +++ /dev/null @@ -1,217 +0,0 @@ -// `liquid_glass.glsl` in Metal, for fizzy's native backend (`backend/`): the same -// program line for line — change it, `liquid_glass.fragment.hlsl` and `LiquidField.sample` -// together. To the interface the native backend's programs take: entry point `main0`, the -// default vertex shader's outputs as inputs, the draw's own texture (the frost) at 0, the sharp -// picture at 1, the uniforms as buffer 0. - -#include -using namespace metal; - -#define G 7 -#define MAX_SHAPES 16 - -struct main0_in -{ - float4 color [[user(locn0)]]; - float2 uv [[user(locn1)]]; -}; - -struct main0_out -{ - float4 color [[color(0)]]; -}; - -static float sdRoundBox(float2 p, float2 b, float4 r, thread float2 &g) -{ - float rr = p.x > 0.0 ? (p.y > 0.0 ? r.z : r.y) : (p.y > 0.0 ? r.w : r.x); - float2 q = abs(p) - b + rr; - float2 s = float2(p.x < 0.0 ? -1.0 : 1.0, p.y < 0.0 ? -1.0 : 1.0); - if (q.x > 0.0 && q.y > 0.0) { - g = normalize(q) * s; - } else if (q.x > q.y) { - g = float2(s.x, 0.0); - } else { - g = float2(0.0, s.y); - } - return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rr; -} - -static float softBox(float2 p, float4 s, float k, thread float2 &o) -{ - float4 d = float4(p.x - s.x + s.z, s.x + s.z - p.x, p.y - s.y + s.w, s.y + s.w - p.y); - float m = min(min(d.x, d.y), min(d.z, d.w)); - float4 w = exp((m - d) / k); - float sum = w.x + w.y + w.z + w.w; - o = float2(w.y - w.x, w.w - w.z) / sum; - return k * log(sum) - m; -} - -static float smin(float a, float b, float k, thread float &m) -{ - float h = max(k - abs(a - b), 0.0) / k; - m = h * h * 0.5; - if (b < a) m = 1.0 - m; - return min(a, b) - h * h * k * 0.25; -} - -static float noise(float2 p) -{ - return fract(52.9829189 * fract(dot(p, float2(0.06711056, 0.00583715)))); -} - -fragment main0_out main0( - main0_in in [[stage_in]], - float4 frag [[position]], - texture2d Frost [[texture(0)]], sampler FrostSampler [[sampler(0)]], - texture2d Sharp [[texture(1)]], sampler SharpSampler [[sampler(1)]], - constant float4 *uData [[buffer(0)]]) -{ - main0_out out = {}; - int first = int(in.color.g * 255.0 + 0.5); - int count = int(in.color.b * 255.0 + 0.5); - float2 p = in.uv; - float4 g0 = uData[0]; - float4 g1 = uData[1]; - float4 g2 = uData[2]; - float4 g3 = uData[3]; - float4 g4 = uData[4]; - float4 g5 = uData[5]; // dither; the lens: its band's share of the shape, its bend, its shade - float4 g6 = uData[6]; // opaque window; the lens: dispersion, a drop's diagonal light (0: a window's hairline), the band's cap - bool inward = g5.y > 0.0; - - float d1 = 1e9; float d2 = 1e9; float4 m1 = float4(0.0); float4 m2 = float4(0.0); - float h1 = 1e9; float h2 = 1e9; - float f1 = 1e9; float f2 = 1e9; float2 o1 = float2(0.0); float2 o2 = float2(0.0); - for (int i = first; i < first + count && i < MAX_SHAPES; i++) { - float4 s0 = uData[G + 3 * i]; - float4 s1 = uData[G + 3 * i + 1]; - float4 s2 = uData[G + 3 * i + 2]; - float2 g; - float d = sdRoundBox(p - s0.xy, s0.zw, min(s1, float4(min(s0.z, s0.w))), g); - float hs = min(s0.z, s0.w); - if (d < d1) { d2 = d1; m2 = m1; h2 = h1; d1 = d; m1 = s2; h1 = hs; } else if (d < d2) { d2 = d; m2 = s2; h2 = hs; } - float2 o; - float f = d; - if (s2.w < 0.5) { - f = softBox(p, s0, g1.y, o); - } else { - o = g; - } - if (f < f1) { f2 = f1; o2 = o1; f1 = f; o1 = o; } else if (f < f2) { f2 = f; o2 = o; } - } - float k = max(g1.x, 0.0001); - float wm; float wf; - float D = smin(d1, d2, k, wm); - float F = smin(f1, f2, k, wf); - float cov = clamp(0.5 - D, 0.0, 1.0); - if (cov <= 0.0) discard_fragment(); - if (in.color.r < 0.5) { - out.color = float4(0.0, 0.0, 0.0, cov); - return out; - } - - float4 mat = mix(m1, m2, wm); - float2 outv = mix(o1, o2, wf); - float soft = max(0.0, -F); - float steepR = exp(-soft / g1.z); - float steepL = exp(-soft / g1.w); - float b = clamp(mat.x, 0.0, 1.0); - float lens = mat.y; - - float t = 0.0; - float disp = 0.0; - float2 uv; - if (inward) { - float W = max(min(g5.y * mix(h1, h2, wm), g6.w), 1.0); - t = clamp(1.0 - soft / W, 0.0, 1.0); - disp = g5.z * W * lens * t * t; - uv = clamp((p - outv * disp - g0.xy) * g0.zw, 0.0, 1.0); - } else { - uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); - } - float4 frost = Frost.sample(FrostSampler, uv); - float4 sharp = g4.w > 0.5 ? Sharp.sample(SharpSampler, uv) : frost; - // The colour fringe: red and blue from either side, each un-premultiplied by its own alpha and - // put back at the middle's. By the middle's alone, at the edge of a clear part of the window - // the two alphas differ widely, and the fringe came out as red and blue specks. - if (inward && g6.y > 0.0 && disp > 0.5) { - float2 du = outv * (disp * 0.04 * g6.y) * g0.zw; - float2 uvr = clamp(uv + du, 0.0, 1.0); - float2 uvb = clamp(uv - du, 0.0, 1.0); - float4 frost_r = Frost.sample(FrostSampler, uvr); - frost.r = frost_r.r / max(frost_r.a, 1e-4) * frost.a; - float4 frost_b = Frost.sample(FrostSampler, uvb); - frost.b = frost_b.b / max(frost_b.a, 1e-4) * frost.a; - if (g4.w > 0.5) { - float4 sharp_r = Sharp.sample(SharpSampler, uvr); - sharp.r = sharp_r.r / max(sharp_r.a, 1e-4) * sharp.a; - float4 sharp_b = Sharp.sample(SharpSampler, uvb); - sharp.b = sharp_b.b / max(sharp_b.a, 1e-4) * sharp.a; - } else { - sharp.r = frost.r; - sharp.b = frost.b; - } - } - // Worked out over an opaque picture of what the glass covers, then made exactly as - // see-through as that is (`under`): over a translucent window (vibrancy, Acrylic) the - // desktop's material shows through the glass as much as through the window round it. Laid - // over the translucent picture as it was, the rim — where the clear glass lies over the - // frost — came out more opaque than the face, and the material lit the face and not the rim. - // Opaque where the window is (`LiquidField.publishOpaqueWindow`): its alpha is only its shape. - float under = uData[6].x > 0.5 ? 1.0 : frost.a; - frost = float4(frost.rgb / max(frost.a, 1e-4), 1.0); - sharp = float4(sharp.rgb / max(sharp.a, 1e-4), 1.0); - - float mixv = g4.z > 0.5 ? g4.x : 0.0; - // Apple's lens: the window's colour fades out across the band, as an OS window's does - // (`glass_look.window`, its feather: strong most of the way, gone at the edge), so the edge stays - // the lens bending what is behind it — unless the colour is near whole, when it covers the edge - // too (`glass_look.shine_start`, 0.8 to 1). - if (inward) mixv *= 1.0 - t * t * (1.0 - smoothstep(0.8, 1.0, mixv)); - float4 c = mix(sharp, frost, b) * (1.0 - mixv); - float a = clamp(g2.y * lens * (inward ? t : steepR * steepR) * (1.0 - mixv), 0.0, 1.0); - c = sharp * a + c * (1.0 - sharp.a * a); - c += g3 * mixv; - // A translucent tint lies over the frost, not over what is behind the window: the glass is - // opaque here, and `under` alone says how much of the desktop's material shows through it. - c += frost * (1.0 - c.a); - // The way the edge faces, against a light from the top left. - float facing = dot(outv, float2(-0.70710678, -0.70710678)); - float toward = max(facing, 0.0); - float away = max(-facing, 0.0); - // Apple's lens has two edges, measured beside the OS's (`g6.z`, `glass_look.InApp.diagonal`): - // - a drop's (> 0): lit along the diagonal the light runs, top left and bottom right, about - // three pixels in and only near the diagonal; darkened across the other, top right and - // bottom left; nothing at the sides square to the light; - // - a window's (0): a dark hairline at the very edge and a bright one, two pixels, just inside - // it, the same on every side (the OS window's own border, as Liquid Glass windows have). - // Nothing across the band in either. Shade is the glass's own, not the picture's: a dark layer - // over it, as whole where the picture is see-through as where it is not. `px` is a point, in - // pixels (the rim line's width is 0.8 of one, `LiquidField.rim_line_width`). - float px = g2.z * 1.25; - float af = abs(facing); - bool diagonal = inward && g6.z > 0.0; - float edge_dark = diagonal ? g6.z * (1.0 - smoothstep(0.2, 0.7, af)) * exp(-max(0.0, -D) / (2.0 * px)) - : 0.85 * (1.0 - smoothstep(0.0, 0.5 * px, -D)); - float shade = inward ? max(g5.w * t * t, edge_dark) : 0.0; - float4 o = float4(c.rgb * (under * (1.0 - shade)), under * (1.0 - shade) + shade); - float spec = inward ? (diagonal ? g6.z * smoothstep(0.75, 1.0, af) : 0.25) - : 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; - float line = diagonal ? exp(-max(0.0, -D) / px) - : inward ? smoothstep(0.25 * px, 0.5 * px, -D) * (1.0 - smoothstep(1.2 * px, 1.6 * px, -D)) - : exp(-max(0.0, -D) / g2.z); - float lit = line * spec + (inward ? 0.0 : 0.04 * steepL * toward); - float lift = (g4.z > 0.5 ? g4.y : 0.0) + mat.z; - // The glass's own light, added whole. Scaled with the picture, it went wherever the picture - // was see-through — over the explorer, which is the window's OS glass — and the glass all but - // vanished there, rim and all, where Liquid Glass keeps its bright rim over anything. - o.rgb += float3(clamp(lift + lens * g2.w * lit, 0.0, 1.0)); - o = clamp(o, 0.0, 1.0); - // Light over a see-through picture carries its own alpha: brighter than its alpha, a pixel is - // no colour premultiplied, and the frost captured from this frame next (`rgb / a`) blew it up - // into bright specks wherever the window is clear — over the explorer. Opaque, alpha is 1. - o.a = max(o.a, max(o.r, max(o.g, o.b))); - o.rgb += (noise(frag.xy) - 0.5) * g5.x * o.a; - out.color = clamp(o, 0.0, 1.0) * cov; - return out; -} diff --git a/core/transport/NativeTransport.zig b/core/transport/NativeTransport.zig index 016422d8c..4e336ee81 100644 --- a/core/transport/NativeTransport.zig +++ b/core/transport/NativeTransport.zig @@ -441,6 +441,15 @@ test "cancel returns at once while the server is still holding the connection" { const Hold = struct { fn run(s: *std.Io.net.Server, held_io: std.Io, release: *std.atomic.Value(bool)) void { const stream = s.accept(held_io) catch return; + // Read the request before holding: a socket closed with bytes still unread is reset, + // not closed, and Zig 0.16 reads a reset on Windows as `error.Unexpected`, dumping a + // stack trace into the test's stderr that reads as a failure. + var in_buf: [1024]u8 = undefined; + var out_buf: [64]u8 = undefined; + var reader = stream.reader(held_io, &in_buf); + var writer = stream.writer(held_io, &out_buf); + var http_server = std.http.Server.init(&reader.interface, &writer.interface); + _ = http_server.receiveHead() catch {}; while (!release.load(.acquire)) std.Io.sleep(held_io, .fromMicroseconds(1000), .awake) catch {}; stream.close(held_io); } diff --git a/core/widgets.zig b/core/widgets.zig index 9c49003d2..189beffde 100644 --- a/core/widgets.zig +++ b/core/widgets.zig @@ -54,6 +54,8 @@ pub const PopupWidget = @import("widgets/menu/Popup.zig"); /// round that is tapped or clicked. Big enough to hit with a finger, small enough not to crowd /// what it sits over, so one size serves touch and mouse alike. pub const round_button_size: f32 = 32; +/// Round buttons in liquid glass, and the viewport bubbles that float over a document. +pub const glass_button = @import("widgets/glass_button.zig"); /// A dropdown whose list is the menus' frosted surface — use this, not `dvui.DropdownWidget`. pub const DropdownWidget = @import("widgets/Dropdown.zig"); @@ -139,6 +141,9 @@ pub const MenuRowOptions = struct { /// The command the row runs, if it names one: the row is then a demo's to point at, as /// `fizzy.command:` (`core.anchor`). command: ?[]const u8 = null, + /// A name of its own for a row that runs no command, for a demo or an agent to aim at + /// (`core.anchor`). + anchor: ?[]const u8 = null, }; /// One menu row, the same everywhere: an icon column, the label straight after it, and the @@ -151,6 +156,7 @@ pub fn menuRow(src: std.builtin.SourceLocation, label: []const u8, opts: MenuRow var mi = menuItem(src, .{ .submenu = opts.submenu }, menuRowOptions(.{ .expand = .horizontal, .id_extra = opts.id_extra })); const ret: ?dvui.Rect.Natural = if (opts.enabled) mi.activeRect() else null; if (opts.command) |id| anchor.mark(mi.data(), "fizzy.command:{s}", .{id}); + if (opts.anchor) |name| anchor.mark(mi.data(), "{s}", .{name}); // Closed before `mi`: it is `mi`'s child, and dvui's widget stack is strictly LIFO. var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .id_extra = opts.id_extra }); diff --git a/core/widgets/BlurBackdrop.zig b/core/widgets/BlurBackdrop.zig index a4af1c896..31b846a6c 100644 --- a/core/widgets/BlurBackdrop.zig +++ b/core/widgets/BlurBackdrop.zig @@ -954,7 +954,8 @@ pub const Pane = struct { /// pane moves, resizes or forms), however many frames pass. For chrome that sits over /// something the caller can describe — a button over a canvas it knows the art, zoom and pan /// of — where a re-read every frame is a blur's worth of work for a picture that has not - /// changed. + /// changed. What the caller cannot describe — the host fading one view's photograph over + /// the next as a region swaps — is the host's to say (`underChanged`), and reads it again too. witness: ?u64 = null, /// Clear glass: what is behind it unblurred — bent at its edge, tinted and lit as frost is — /// for the blur turned off where the glass program draws (`LiquidField`), so liquid glass is @@ -1067,6 +1068,7 @@ fn queuePane(id: dvui.Id, rect: Rect.Physical, corners: dvui.CornerRect, scale: .refraction = pane.refraction, .field = field, .clear = pane.clear, + .kept = pane.witness != null, }; dvui.deferRender(job, FrostJob.draw); } @@ -1106,6 +1108,94 @@ fn selfForm(id: dvui.Id, now: i128) f32 { return std.math.clamp(t, 0, 1); } +/// Fizzy addition: something no witness describes was drawn over `r` (physical) this frame — a +/// region's swap fading the outgoing view's photograph over the incoming one (`anim.CrossFade`). +/// A kept frost (`Pane.witness`) over any of it is read again, this frame and the next: the next +/// for the frame it ends, when what was drawn over is gone. Without it, the glass over a view +/// that had just come in kept the photograph of the view it replaced, faded in its frost for +/// good, since the witness, describing only the view, held still as the photograph went. +/// +/// Through the shared dvui window, so a plugin's glass hears what the host drew. Costs a lookup +/// per kept frost a frame; nothing while no one marks. +pub fn underChanged(r: Rect.Physical) void { + if (dvui.current_window == null) return; + const marks = dvui.dataGetPtrDefault(null, marks_id, marks_key, Marks, .{}); + marks.roll(dvui.currentWindow().frame_time_ns); + marks.now.add(r); +} + +/// Whether `r` is under anything `underChanged` marked this frame or the last. +fn changedUnder(r: Rect.Physical) bool { + const marks = dvui.dataGetPtr(null, marks_id, marks_key, Marks) orelse return false; + marks.roll(dvui.currentWindow().frame_time_ns); + return marks.now.hits(r) or marks.last.hits(r); +} + +const marks_id: dvui.Id = @fromBackingInt(@intCast(0x6669_7a7a_756e_6472)); // "fizzundr" +const marks_key = "_frost_under"; + +/// The rects `underChanged` marked, this frame and the last. In the data store, so it lasts only +/// while someone marks or reads it: unread for a frame, it goes, and a frost reading after that +/// finds nothing. +const Marks = struct { + /// The frame `now` holds. + frame: i128 = 0, + now: Span = .{}, + last: Span = .{}, + + const Span = struct { + rects: [8]Rect.Physical = undefined, + len: u8 = 0, + + /// Past eight, the last one grows to take it in: a frost too many re-reads, never one + /// too few. + fn add(self: *Span, r: Rect.Physical) void { + if (self.len < self.rects.len) { + self.rects[self.len] = r; + self.len += 1; + } else { + self.rects[self.len - 1] = self.rects[self.len - 1].unionWith(r); + } + } + + fn hits(self: *const Span, r: Rect.Physical) bool { + for (self.rects[0..self.len]) |m| if (!m.intersect(r).empty()) return true; + return false; + } + }; + + /// Into frame `t`: what this frame held becomes the last frame's. + fn roll(self: *Marks, t: i128) void { + if (self.frame == t) return; + self.last = self.now; + self.now = .{}; + self.frame = t; + } +}; + +test "a kept frost reads again under a mark, that frame and the next, then not" { + var m: Marks = .{}; + const swap: Rect.Physical = .{ .x = 100, .y = 100, .w = 800, .h = 600 }; + const glass: Rect.Physical = .{ .x = 120, .y = 120, .w = 40, .h = 40 }; + const aside: Rect.Physical = .{ .x = 1000, .y = 100, .w = 40, .h = 40 }; + m.roll(1); + m.now.add(swap); + try std.testing.expect(m.now.hits(glass)); + try std.testing.expect(!m.now.hits(aside)); + // The frame after the swap's last: nothing marked, the last frame's mark still counts. + m.roll(2); + try std.testing.expect(!m.now.hits(glass) and m.last.hits(glass)); + m.roll(3); + try std.testing.expect(!m.now.hits(glass) and !m.last.hits(glass)); +} + +test "marks past the span's room widen its last rect" { + var span: Marks.Span = .{}; + for (0..10) |i| span.add(.{ .x = @floatFromInt(i * 100), .y = 0, .w = 10, .h = 10 }); + try std.testing.expectEqual(@as(u8, 8), span.len); + try std.testing.expect(span.hits(.{ .x = 905, .y = 0, .w = 2, .h = 2 })); +} + /// What `frostPane` hands to the replay. Lives in the data store under the owner's id, so the /// pointer is good until the frame ends. const FrostJob = struct { @@ -1125,6 +1215,9 @@ const FrostJob = struct { field: ?LiquidField = null, /// Clear glass (`Pane.clear`): the picture before the blur. clear: bool = false, + /// Read only when its witness changes (`Pane.witness`), or what is under it changed in a way + /// no witness sees (`underChanged`). + kept: bool = false, fn draw(ctx: ?*anyopaque) void { const self: *FrostJob = @ptrCast(@alignCast(ctx orelse return)); @@ -1138,6 +1231,9 @@ const FrostJob = struct { const prev_alpha = dvui.currentWindow().alpha; dvui.alphaSet(1); defer dvui.alphaSet(prev_alpha); + // Here, not at `queuePane`: a swap's photograph is drawn over the view after the view + // has queued its glass, and is on the target by now. + if (self.kept and changedUnder(self.backdrop.rect)) self.backdrop.markDirty(); // The capture, now that everything below this pane is on the target (again, if it was // replayed into another target already this frame: `deinit`). self.backdrop.deinit(); diff --git a/core/widgets/CanvasWidget.zig b/core/widgets/CanvasWidget.zig index dacf876d7..e3be9f2c1 100644 --- a/core/widgets/CanvasWidget.zig +++ b/core/widgets/CanvasWidget.zig @@ -43,6 +43,21 @@ first_center: bool = true, second_center: bool = true, prev_parent_rect: dvui.Rect = .{}, +/// Fitted to its pane until the person moves the view: a wheel, a pinch, a pan over the canvas. +/// While fitted, a pane that settles at another size (a document moved to a narrower pane +/// beside, a window resized) refits it; once the person has moved it, nothing does, so their +/// place is kept. `fitToView` fits it again; an owner that sets the view itself says whether it +/// left it fitted. +fit: bool = true, +/// The pane size the canvas was last fitted to. +fit_parent: dvui.Size = .{}, +/// `fitToView` asked: the next fit moves the view there rather than jumping (`fit_move`). +fit_animate: bool = false, +/// A fit on its way, from the view as it was: a zoom about the one point on screen the move leaves +/// where it is, so every point of the content moves toward or away from it in step and both axes +/// arrive together (`applyFitMove`). Null when none is; a pan or zoom of the person's ends it. +fit_move: ?FitMove = null, + /// Parent rect captured during the previous `install` call (regardless of whether /// `recenter` ran). Lets us detect "the workspace pane is mid-animation right now" so /// we can skip the pan-slack viewport/origin mutation in `processEvents` — running it @@ -334,6 +349,51 @@ pub fn recenter(self: *CanvasWidget) void { self.prev_parent_rect = parent; } +/// Fit the content to the pane again, moving the view there on the app's motion, and keep it +/// fitted until the person moves the view. +pub fn fitToView(self: *CanvasWidget) void { + self.fit = true; + self.fit_parent = .{}; + self.fit_animate = true; + dvui.refresh(null, @src(), self.id); +} + +const FitMove = struct { from_scale: f32, to_scale: f32, from_center: dvui.Point, to_center: dvui.Point }; +/// How long a fit takes to move the view there, before the app's motion speed. +const fit_move_us: i32 = 450_000; + +/// One frame of a fit on its way (`fit_move`), `t` of the way there. The scale eases on a log +/// scale, and the data point at the box's middle follows from the move's still point: the data +/// point `P`, at `G` from the box's middle, that the start and end views both put in the same +/// place. Every point of the content then moves toward or away from it by one shared factor, a +/// zoom about it, so x and y move in step. (The middle's data point moved in a straight line +/// instead, its screen edges moved at different rates on each axis.) With no zoom to speak of +/// there is no still point: the middle slides in a line. +fn applyFitMove(self: *CanvasWidget, m: FitMove, t: f32) void { + const box = dvui.parentGet().data().rect; + const data = self.init_opts.data_size; + const s = @exp(std.math.lerp(@log(m.from_scale), @log(m.to_scale), t)); + const c: dvui.Point = if (@abs(m.from_scale - m.to_scale) < 1e-3 * m.to_scale) .{ + .x = std.math.lerp(m.from_center.x, m.to_center.x, t), + .y = std.math.lerp(m.from_center.y, m.to_center.y, t), + } else c: { + const k = m.from_scale - m.to_scale; + const p: dvui.Point = .{ + .x = (m.from_center.x * m.from_scale - m.to_center.x * m.to_scale) / k, + .y = (m.from_center.y * m.from_scale - m.to_center.y * m.to_scale) / k, + }; + const g: dvui.Point = .{ .x = (p.x - m.from_center.x) * m.from_scale, .y = (p.y - m.from_center.y) * m.from_scale }; + break :c .{ .x = p.x - g.x / s, .y = p.y - g.y / s }; + }; + self.prev_scale = self.scale; + self.scale = s; + self.scroll_info.virtual_size = .{ .w = data.w * s, .h = data.h * s }; + // As `recenter` does: the scroll position zeroed with the origin, `deinit` adding the slack. + self.scroll_info.viewport.x = 0; + self.scroll_info.viewport.y = 0; + self.origin = .{ .x = c.x * s - box.w / 2, .y = c.y * s - box.h / 2 }; +} + pub fn rescale(self: *CanvasWidget) void { const parent = dvui.parentGet().data().rect; @@ -365,6 +425,7 @@ pub fn install(self: *CanvasWidget, src: std.builtin.SourceLocation, init_opts: self.second_center = true; self.fade_started = false; self.fade_pending = false; + self.fit = true; } // While still in the settle phase, force another recenter whenever the parent rect // changed since last frame: the workspace layout may still be moving (e.g. status bar @@ -397,7 +458,10 @@ pub fn install(self: *CanvasWidget, src: std.builtin.SourceLocation, init_opts: // confirmed stable frame. This freezes the canvas's pan/zoom state during animations // so mid-frame mutations (scroll container bounce-back, scrollbar visibility flips // shrinking the viewport, etc.) don't accumulate drift. - if (self.settled() and !parent_is_stable and self.has_stable_snapshot) { + // Not while fitted: a fitted view is worked out afresh from the pane each frame (`refit`), so + // there is no drift to guard against, and the snapshot froze it at the old fit for as long as + // the pane moved. + if (self.settled() and !parent_is_stable and self.has_stable_snapshot and !self.fit) { self.scroll_info.viewport.x = self.stable_viewport.x; self.scroll_info.viewport.y = self.stable_viewport.y; self.scroll_info.virtual_size = self.stable_virtual_size; @@ -420,11 +484,73 @@ pub fn install(self: *CanvasWidget, src: std.builtin.SourceLocation, init_opts: // for many frames and `center` can stay true — ignore it once the canvas has settled so // reopening the explorer does not recenter an already-panned file. const explicit_center = self.init_opts.center and !self.settled(); - if (size_changed or self.second_center or explicit_center or parent_changed_while_unsettled) { - self.rescale(); - self.recenter(); + // Still fitted, and the pane is at a size it was not fitted to: refit, every frame it changes, + // so a pane being dragged zooms the content with it live. (It waited for the pane to hold + // still, and the content caught up in a jump after the drag.) + const refit = self.fit and self.settled() and + (parent_rect_now.w != self.fit_parent.w or parent_rect_now.h != self.fit_parent.h); + if (size_changed or self.second_center or explicit_center or parent_changed_while_unsettled or refit) { + const only_refit = !(size_changed or self.second_center or explicit_center or parent_changed_while_unsettled); + if (only_refit and self.fit_animate and self.scale > 0) { + // Asked for (`fitToView`): moved there from where the view is. A pane changing size + // refits at once — a resize the fit trailed behind would lag the person's drag. + // Where the view is: the data point at the middle of the box, from the canvas's own + // state, the inverse of where `applyFitMove` puts it. Pans move the scroll position, + // pan slack moves it and the origin by opposite amounts, so their sum places it; + // a fitted view comes out at the data's exact middle. (Read off last frame's screen + // rects it was off by the scroll bar a zoomed view shows, and a view already fitted + // jumped and moved back.) + const box = dvui.parentGet().data().rect; + const from_center: dvui.Point = .{ + .x = (self.origin.x + self.scroll_info.viewport.x + box.w / 2) / self.scale, + .y = (self.origin.y + self.scroll_info.viewport.y + box.h / 2) / self.scale, + }; + const from_scale = self.scale; + self.rescale(); + const to_scale = self.scale; + const data = self.init_opts.data_size; + // Already fitted (within a pixel on screen): nothing to move, and no motion to show. + // The view stays exactly as it is: no recenter, which would reset the scroll. + const there = @abs(to_scale - from_scale) * @max(data.w, data.h) < 1 and + @abs(from_center.x - data.w / 2) * to_scale < 1 and @abs(from_center.y - data.h / 2) * to_scale < 1; + if (there) { + self.fit_move = null; + self.scale = from_scale; + } else { + self.fit_move = .{ .from_scale = from_scale, .to_scale = to_scale, .from_center = from_center, .to_center = .{ .x = data.w / 2, .y = data.h / 2 } }; + self.scale = from_scale; + // A camera move: eased in and out, never past where it is going. The app's + // `settle` arrives early and swings past and back, which on a view read as a + // lunge and an overshoot. + dvui.animation(self.id, "_fit", .{ + .start_val = 0, + .end_val = 1, + .end_time = core.motion.duration(fit_move_us), + .easing = dvui.easing.inOutCubic, + }); + } + } else { + self.fit_move = null; + self.rescale(); + self.recenter(); + } + self.fit_parent = .{ .w = parent_rect_now.w, .h = parent_rect_now.h }; dvui.refresh(null, @src(), self.id); } + self.fit_animate = false; + if (self.fit_move) |m| { + if (!self.fit) { + // The person took the view: it stays where the move had it. + self.fit_move = null; + } else if (dvui.animationGet(self.id, "_fit")) |a| { + self.applyFitMove(m, a.value()); + } else { + // Done (or motion is off): exactly the fitted view. + self.fit_move = null; + self.rescale(); + self.recenter(); + } + } // Wait one frame before starting the fade so the most-stale (frame 1) recenter is // hidden (alpha == 0), then begin fading on the next install — overlapping with any @@ -952,6 +1078,7 @@ pub fn processEvents(self: *CanvasWidget) void { // and resize handle. The scale-around-point math here is identical to the wheel // and old inline pinch paths — only the timing changed. if (self.pending_pinch_zoom != 1.0) { + self.fit = false; const zoom = self.pending_pinch_zoom; const zoomP = self.pending_pinch_zoom_p; const prevP = self.dataFromScreenPoint(zoomP); @@ -965,6 +1092,7 @@ pub fn processEvents(self: *CanvasWidget) void { self.pending_pinch_zoom = 1.0; } if (self.pending_trackpad) { + self.fit = false; const ratio = self.pending_trackpad_ratio; const cursor_phys = self.pending_trackpad_cursor; const prevP = self.dataFromScreenPoint(cursor_phys); @@ -979,6 +1107,7 @@ pub fn processEvents(self: *CanvasWidget) void { self.pending_trackpad = false; } if (self.pending_touch_pan.x != 0 or self.pending_touch_pan.y != 0) { + self.fit = false; const pdx = self.pending_touch_pan.x; const pdy = self.pending_touch_pan.y; self.scroll_info.viewport.x += pdx; @@ -1141,6 +1270,7 @@ pub fn processEvents(self: *CanvasWidget) void { const ddy = -dps.y / rs.s; self.scroll_info.viewport.x += ddx; self.scroll_info.viewport.y += ddy; + self.fit = false; pan_dx += ddx; pan_dy += ddy; pan_motion = true; @@ -1151,6 +1281,9 @@ pub fn processEvents(self: *CanvasWidget) void { } } } else if (me.action == .wheel_y or me.action == .wheel_x) { + // A wheel over the canvas moves the view, as a zoom here or a pan by the + // scroll container: either way the person has placed it. + self.fit = false; switch (self.init_opts.pan_zoom_scheme) { .mouse => { const base: f32 = if (me.mod.matchBind("shift")) 1.005 else 1.005; diff --git a/core/widgets/TreeWidget.zig b/core/widgets/TreeWidget.zig index cdc236063..2c83af172 100644 --- a/core/widgets/TreeWidget.zig +++ b/core/widgets/TreeWidget.zig @@ -487,8 +487,16 @@ pub const Branch = struct { // Reserve the original slot so tree shape does not collapse; row chrome is drawn only // in the floating subwindow below (same for primary and secondary multi-drag rows). + // As tall as the row was, and as wide as the list is now where the row took the + // list's width: held at the row's width when it was lifted, a list that narrowed + // under the drag (its scroll bar coming in for the drop row below the last) was + // pushed wider than itself, and its scroll bars and a pane fitting it chased each + // other for as long as the row was held. self.wd = WidgetData.init(self.wd.src, .{}, wrapOuter(self.options).override(.{ - .min_size_content = drag_min, + .min_size_content = .{ + .w = if (self.options.expandGet().isHorizontal()) 0 else drag_min.w, + .h = drag_min.h, + }, })); self.wd.register(); dvui.parentSet(self.widget()); @@ -504,8 +512,8 @@ pub const Branch = struct { var npt = dp.plus(dvui.dragOffset().plus(.{ .x = 5, .y = 5 })).toNatural(); npt.y += @as(f32, @floatFromInt(stack_i)) * (drag_min.h + row_gap); - // Carried, a row is glass, as a tab carried along its strip is - // (`dialogs.carriedGlass`). (A list whose rows another drag can carry hands them + // Carried, a row is glass, as a tab or a view carried is + // (`dialogs.carriedCard`: the OS's glass where it is offered). (A list whose rows another drag can carry hands them // to it as they are lifted, and reads them back with `carriedOver`: fizzy's file // tree, a file to the app's view drag.) As big as the row was when it was lifted: it is // held where it was grabbed (`dragOffset`), and sized to its name instead, a short @@ -527,7 +535,7 @@ pub const Branch = struct { // window: carried in a float popped out of it, it was clipped away. dvui.clipSet(screens.pixelsFor(.cast(fd.rect))); const frs = fd.borderRectScale(); - dialogs.carriedGlass(fd.id, frs.r, frs.s); + dialogs.carriedCard(fd.id, frs.r, frs.s); } else if (source or self.tree.carried_depth > 0) { // Carried back over the tree: the row stays in its place, faded, and neither it // nor anything inside it takes the drop. diff --git a/core/widgets/glass_button.zig b/core/widgets/glass_button.zig new file mode 100644 index 000000000..69a2a656f --- /dev/null +++ b/core/widgets/glass_button.zig @@ -0,0 +1,141 @@ +//! Round buttons in liquid glass: the controls that float over a document or a pane. +//! +//! Each is a glass bubble (`dialogs.glassBubbles`), the same glass as the demo player's transport +//! and pixi's canvas controls, refracting what is behind it along its own outline, with the +//! floating surfaces' shadow as a ring round it (never a box shadow, which the frost would read +//! back and blur). Hover and press light the glass; a toggled-on button stays glass, its icon in +//! the highlight. With the blur off the glass is the plain dialog fill. +const std = @import("std"); +const dvui = @import("dvui"); +const dialogs = @import("../dialogs.zig"); +const screens = @import("../screens.zig"); +const icon_draw = @import("../gfx/icon.zig"); +const widgets = @import("../widgets.zig"); + +/// The size of a viewport bubble, in points: pixi's canvas controls are this size. +pub const size: f32 = 36; + +/// How lit a button's glass is for its state (`dialogs.Bubble.light`). +pub fn light(btn: *dvui.ButtonWidget) f32 { + return if (btn.pressed()) dialogs.bubble_press else if (btn.hovered()) dialogs.bubble_hover else 0; +} + +/// The wash over a button inside a pane of glass (pixi's edit pill), as a menu's rows take: the +/// highlight when toggled on, else the menus' press and hover colours, else nothing. +pub fn wash(btn: *dvui.ButtonWidget, active: bool) void { + const color: ?dvui.Color = if (active) + highlight(btn) + else if (btn.pressed()) + dialogs.rowPress() + else if (btn.hovered()) + dialogs.rowHover() + else + null; + if (color) |c| fillCircle(btn, c); +} + +/// A toggled-on button's tint. +fn highlight(btn: *dvui.ButtonWidget) dvui.Color { + return dvui.themeGet().color(.highlight, .fill).opacity(if (btn.hovered()) 1 else 0.85); +} + +fn fillCircle(btn: *dvui.ButtonWidget, c: dvui.Color) void { + const rs = btn.data().borderRectScale(); + rs.r.fill(.round(@min(rs.r.w, rs.r.h) / 2), .{ .color = .{ .color = c }, .fade = 1 }); +} + +/// A round button's whole background: its own disc of glass, lit under the pointer. Call after +/// `processEvents`, in place of `drawBackground`, with the button made by `options`. A toggled-on +/// button is still glass: its icon takes the highlight (`iconColor`). +/// +/// `under_witness` is a signature of what lies under the glass (`witness`); the frost is read again only +/// when it changes. Null re-reads it every frame the app draws. +pub fn background(btn: *dvui.ButtonWidget, under_witness: ?u64) void { + const rs = btn.data().borderRectScale(); + dialogs.glassBubbles(btn.data().id, &.{.{ .rect = rs.r, .light = light(btn) }}, rs.s, .{ .witness = under_witness }); +} + +/// The icon's colour on a glass button: the highlight when toggled on, the text colour otherwise; +/// disabled, most of the way to the content fill rather than see-through (at partial alpha a +/// stroked icon's overlapping caps and joins drew darker than its lines). +pub fn iconColor(active: bool, enabled: bool) dvui.Color { + const theme = dvui.themeGet(); + const color = if (active) theme.color(.highlight, .fill) else theme.color(.content, .text); + return if (enabled) color else color.lerp(theme.color(.content, .fill), 0.65); +} + +/// A witness for glass at `r` (physical) over something whose look `under` signs (a canvas's art, +/// zoom and pan; a pane's scroll; 0 for a plain fill): what is under it, where the glass is, and +/// how glass is drawn. +pub fn witness(r: dvui.Rect.Physical, under: u64) u64 { + var h = std.hash.Wyhash.init(under); + h.update(std.mem.asBytes(&.{ r.x, r.y, r.w, r.h })); + h.update(std.mem.asBytes(&.{ dialogs.style().blur, dvui.themeGet().color(.content, .fill) })); + return h.final(); +} + +/// Button options for a round glass button of `diameter` points: no fill, border or shadow of its +/// own; `background` draws them. +pub fn options(diameter: f32, over: dvui.Options) dvui.Options { + const base: dvui.Options = .{ + .min_size_content = .{ .w = diameter, .h = diameter }, + .expand = .none, + .background = false, + .border = .all(0), + .corners = .round(diameter / 2), + .color_border = .transparent, + .box_shadow = null, + .padding = .all(0), + .margin = .{}, + }; + return base.override(over); +} + +pub const BubbleOptions = struct { + /// What it does, as its tooltip says and a screen reader reads it. + label: []const u8, + enabled: bool = true, + active: bool = false, + /// `witness` of what lies under it; null re-reads it every frame. + witness_under: ?u64 = null, + /// How much of the bubble the icon fills. + icon_share: f32 = 0.34, + id_extra: usize = 0, +}; + +/// A viewport bubble: a round glass button floating at `rect` (natural; `size` square, or smaller +/// while it grows in) with `icon` (tvg) in it and a tooltip. Floats over whatever is drawn there, +/// clipped to the screen it is on (the main window, or a float popped out of it). True on the +/// frame it is clicked. +pub fn bubble(src: std.builtin.SourceLocation, rect: dvui.Rect, icon: []const u8, opts: BubbleOptions) bool { + var fw: dvui.FloatingWidget = undefined; + fw.init(src, .{}, .{ + .rect = rect, + .expand = .none, + .background = false, + .id_extra = opts.id_extra, + }); + defer fw.deinit(); + dvui.clipSet(screens.pixelsFor(.cast(fw.data().rect))); + + var btn: dvui.ButtonWidget = undefined; + btn.init(@src(), .{}, options(@min(rect.w, rect.h), .{ .expand = .both, .label = .{ .text = opts.label } })); + defer btn.deinit(); + if (opts.enabled) btn.processEvents(); + const r = btn.data().borderRectScale().r; + background(&btn, if (opts.witness_under) |u| witness(r, u) else null); + + const color = iconColor(opts.active, opts.enabled); + // White, tinted by the text colour: the icon cache tints a flat colour rather than baking it. + icon_draw.icon(@src(), opts.label, icon, .{ .stroke_color = .white, .fill_color = .white }, .{ + .color_text = .{ .color = color }, + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1, .h = 1 }, + .padding = .all(@min(rect.w, rect.h) * opts.icon_share), + }); + + widgets.tooltip(@src(), .{ .active_rect = btn.data().rectScale().r }, "{s}", .{opts.label}, .{}); + return opts.enabled and btn.clicked(); +} diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index f78c504f7..e9cf18e17 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -149,9 +149,15 @@ user's motion settings, timed in demo time. | `fizzy.command:` | a menu row that runs a command | | `fizzy.dialog.close` | the close button in a dialog's header | | `fizzy.dialog::<label>` | a button in a dialog: its footer's, or one drawn with `core.dialogs.bodyButton` (`fizzy.dialog:About Fizzy:Close`) | +| `history.line:<abs path>` | a document's transport line, from its first stop to its last, while the transport shows: `x` is that fraction of its history (`x = 0` before any change) | +| `history.back:<abs path>`, `history.forward:<abs path>` | its undo and redo buttons | | `fizzy.window:main` | the main window | | `fizzy.window:dialog:<title>`, `fizzy.window:float:<title>`, `fizzy.window:menu` | an OS window of the app's own: a dialog's, a popped-out view's, a menu's (a second of the same name gets `:2`) | | `region:<name>` | a layout region (`Main`, `Panel`, …) | +| `picker:<view id>` | a view's card in a place's picker | +| `picker.mode:one`, `picker.mode:many` | the picker's Single / Multiple | +| `picker.action:split`, `:clear`, `:defaults`, `:remove` | the picker's actions, each only while it can be pressed (a dimmed Clear has no name) | +| `picker.split:vertical`, `picker.split:horizontal` | the rows of the picker's Split menu | A demo's files live under its keyframe's `root`, so a path is `demo://tour/src/main.zig`. `src/editor/demos/catalog.zig` has helpers (`catalog.file(s, "src/main.zig")`) that build these. @@ -286,17 +292,29 @@ one. | `type: "text"` | Type over `ms`; `\n` is Enter, `\t` is Tab. | | `command: .{ .id, .args }` | Run a command: with no `args`, as its menu row or shortcut would (one that needs arguments asks for them); with `args`, ZON keyed by its parameters (`".{ .line = 1 }"`), as the palette does once they are given. A script writes `s.command(id)` or `s.commandWith(id, .{ .line = 1 })`. | | `wait: .{ .until, .timeout }` | Hold demo time until `.idle`, `.shown = "tag"` or `.gone = "tag"`; give up after `timeout` ms of wall time. | +| `step: .{ .file, .effect, .motion }` | Apply one step of a document's history to `file` of the keyframe before it, shown being made over `ms`: `effect` is what the step did and `motion` how it was made (optional), both in the file's `owner`'s encoding. Steps land together, with no frame between them, so a seek across an hour of drawing is one call. | Key chords are the app's spelling: the `tape` library carries them as text, and a `Tape.Check` the app passes to `parse`, `load` and `Script` says which it accepts (fizzy's is `automation.Input.check`, the keymap's parser). +**Steps** record what a document became, not the input that made it, so they replay exactly at +any window size and in any layout (`plans/RECORDING_PLAN.md`). A file a step changes names the +plugin that applies it and its encoding: `.{ .path = "sprite.pixi", .text = <the file's bytes>, +.owner = "pixi", .format = 1 }`. A file's `text` is its bytes as on disk, a binary document's +included (ZON escapes them). `Check.steps` says whose steps, in which format, the app can apply; +without it a tape with steps is refused (`error.StepsUnsupported`), since a step nobody applied +would play a document that never changes. The app applies them through its stage +(`Stage.step`). Fizzy does not apply steps yet. + ### The binary form (`.tape`) -`tape.binary` writes and reads the same `Tape` as a few flat tables: a 60-byte header (`FZTP`, -a version, the counts), a string table that holds each distinct string once (anchors repeat -constantly), then fixed-size records — ops, keyframes and their files, captions, chapters — -that point into it by index. Reading is one bounds-checked pass that builds the `Tape` in a +`tape.binary` writes and reads the same `Tape` as a few flat tables: a header (`FZTP`, a +version, the counts), a string table that holds each distinct string once (anchors repeat +constantly), a blob table of the steps' effects and motion, then fixed-size records — ops, +keyframes and their files, captions, chapters — that point into them by index. With +`.{ .deflate = true }` everything after the version and flags is one zlib stream, for a tape that +is sent somewhere. Reading is one bounds-checked pass that builds the `Tape` in a single arena, with every offset, index and enum checked: a damaged or truncated file is an error, never a crash. It is lossless both ways, so a tape converts freely between the forms. @@ -309,6 +327,14 @@ compare (`zig build bench-tape -Doptimize=ReleaseFast`; recording-shaped tapes, | 10,000 (about ten minutes recorded) | 1.5 MB | 17 ms | 353 KB | 0.29 ms | | 100,000 | 15 MB | 179 ms | 3.4 MB | 3.9 ms | +A recording of drawing, plain against deflated (steps shaped as a pixi stroke's effect, with the +pen's path; the same bench, ReleaseFast): + +| Steps | Plain | Plain load | Deflated | Deflated write | Deflated load | +|---:|---:|---:|---:|---:|---:| +| 2,000 (about an hour of drawing) | 275 KB | 0.04 ms | 100 KB | 8.7 ms | 0.86 ms | +| 20,000 | 2.7 MB | 0.25 ms | 873 KB | 87 ms | 7.9 ms | + ## Live tapes A demo cuts to a keyframe and plays at its authored pace. A **live tape** plays on the app as it diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index c9e7dcd57..becd91a15 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -15,6 +15,13 @@ goes beside the code if it has an area, here if not. for function-local structs with `var`s inside generic functions. - **Tests run only from files their root module analyzes** (`tests/AGENTS.md`). `zig build check-tests-reached` names any that don't. +- **On Windows, "test runner failed to respond" with every test passed means a leaked pipe.** + Zig 0.16's Windows spawn makes each child inherit every inheritable handle the build runner + holds, its other children's pipes among them. A long-lived child (a compiler, a sleeper) + spawned beside a test keeps that test's stdout open, so after the test exits the runner waits a + minute for EOF and fails it. CI compiles the tests (`zig build check`) before running them for + this; don't fold the two steps back together, or start anything long-lived inside `zig build + test`. ## The build (Zig 0.17) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 80b811a6d..3ad29197c 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -644,6 +644,11 @@ gets the same, and `DropZones.wheel`/`at`/`draw` give a plugin the same drop for `loadDocumentFromBytes` / `createDocument`, `saveDocument`, `reloadDocument` (optional — Fizzy's document watcher reloads clean open tabs when the file changes on disk), `closeDocument`, `isDirty`, plus opaque document-buffer management for the async load path. + `loadUntitledDocument(title, bytes_path, bytes)` (optional) opens a document that was never + saved from its unsaved bytes, as a crash checkpoint or a restart kept them: encoded under + `Plugin.bytesPath` (the name Save As offers, `untitled.pixi`), which says their format, and + given back its title (`untitled-1`), untitled and dirty. Without it, such a document comes + back only as far as `restoreDocumentState` fills an empty one, and otherwise as a recovery file. - **Document metadata at the pane boundary** — `bindDocumentToPane`, `documentGrouping` / `setDocumentGrouping` / `setDocumentGroupingOnBuffer` (the pane a document is seated in — the app stamps the target grouping on the staging buffer before the load lands, which is what @@ -661,6 +666,26 @@ gets the same, and `DropZones.wheel`/`at`/`draw` give a plugin the same drop for reaches a moment it snapshotted. An owner without them still plays in demos; a moment with its document open just is not snapshotted, and seeks through it replay from the keyframe. `text` implements all three. +- **Steps** (optional) — what a document's history records and replays (`sdk.Step`, + `plans/RECORDING_PLAN.md`). The owner reports each change as it happens with + `host.recordStep(doc.id, step)`, and encodes its `effect` only while `host.recording(doc.id)` + says so: the host keeps who made every step (below), recorded or not. Each is a new history entry (`added`), more of + the entry on top (`joined`, a run of typing), an entry taken back (`undone`, its effect the + reverse) or put back (`redone`), and the history starting over (`restarted`, a reload). Every + step's `effect` applies **forward**, in the owner's own encoding, numbered by the plugin's + `step_format` (bump it when an older build would misread a step); `motion` is how it was made, + when the owner knows. `applyStep(doc, effect, motion, ms)` lands one as a new history entry, + shown being made over `ms` (0: at once), and refuses one that does not fit what the document + holds, leaving it as it was. Nobody recording, nothing is encoded. `text` implements both: + each step is a transaction's edits, removed and inserted bytes, and the selection after. Fill + `top` (the entry on top once the step is done) so Undo can say whose step it takes back. +- **Authors** — who made each step (`sdk.Author`: the person, an agent, a tape; + `plans/SHARED_HISTORY_PLAN.md`). The host keeps it by step id; owners never store one. A plugin + changing a document on someone's behalf acts as them around the commands it runs + (`const outer = host.act(author); defer host.endAct(outer);`), and work it puts off stores + `host.acted()` and runs inside `host.actAgain(stored)`. An owner whose edits join a run (typing) + asks `host.mayMerge(doc.id, top_step)` and starts a new step when it says no, so one hand's + edits never fold into another's. `host.stepAuthor` and `host.topStep` say who made a step. - **Rendering** — `drawDocument(doc)` (the document's content, called through the document's surface wherever a region accepting `document` shows it), `infobarEntries(active_doc)` (icon + text chips Fizzy draws in the infobar; plugins do not draw into the bar). @@ -1169,7 +1194,7 @@ The SDK ships definitions for the services in this ecosystem, in | Service | Provider | What it's for | |---|---|---| -| `"files"` | the application (fizzy: `src/editor/FilesService.zig`) | `createFile` / `createDir` / `rename` / `delete` / `move` on disk, with open documents rewritten or closed in step. Absent on web | +| `"files"` | the application (fizzy: `src/editor/FilesService.zig`) | `createFile` / `createDir` / `rename` / `delete` / `move` on disk, with open documents rewritten or closed in step; `reveal` shows a path just made as a new file (the tree hears `fizzy.files.reveal`), for a plugin that wrote it itself. Absent on web | | `"workbench"` | `workbench` | Only what the thing drawing tabs is the authority on: `currentGrouping` / `newGrouping` (which pane a new document lands in — pass as `host.openFile`'s `grouping`) and `registerBranchDecorator` (draw on every file-tree row) | | `"markdown"` | `markdown` | Render a markdown byte slice into the current dvui parent (native only — absent on web) | | `"wikilink"` | any indexer (e.g. `brain`) | Resolve `[[Note]]` to a file, plus completion candidates and index state | diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index 5177de2c5..334c522a5 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -32,8 +32,11 @@ Each directory is file for file the shape a third-party plugin has, and the temp - **A test build that overwrote a user's installed plugin is theirs to restore.** Say so. Don't copy anything back from the loader's `.load-copy/`. - **An installed dylib refuses to load if its optimize mode differs from the app's** - (`AbiBuildEnvMismatch`). `zig build` defaults to Debug, and store plugins are built + (`AbiOptimizeMismatch`). `zig build` defaults to Debug, and store plugins are built ReleaseFast. +- **A Fizzy built from `main` past an SDK release refuses plugins built against that release** + (`AbiShapeMismatch`): `sdk` feature PRs move the shape and leave `sdk_version` alone. Build + the plugin against the checkout's SDK with `zig build --fork=<fizzy>/sdk`. - **A plugin built from the released SDK tarball ignores local `sdk/` and `core/` edits.** To test an SDK change end to end, point its `build.zig.zon` `.fizzy` dependency at a *relative* path (`../../fizzy/sdk`; absolute paths are rejected). diff --git a/plugins/image/build.zig b/plugins/image/build.zig index 55caa20ba..2e9045a86 100644 --- a/plugins/image/build.zig +++ b/plugins/image/build.zig @@ -11,5 +11,9 @@ pub fn build(b: *std.Build) void { const optimize = b.standardOptimizeOption(.{}); const plugin = fizzy.plugin.create(b, .{ .target = target, .optimize = optimize }); + + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + plugin.module.addImport("icons", dep.module("icons")); + } fizzy.plugin.install(b, plugin.lib, .{}); } diff --git a/plugins/image/build.zig.zon b/plugins/image/build.zig.zon index 2504cff03..cd89af7ec 100644 --- a/plugins/image/build.zig.zon +++ b/plugins/image/build.zig.zon @@ -15,5 +15,10 @@ .fizzy = .{ .path = "../../sdk", }, + .icons = .{ + .url = "https://github.com/fizzyedit/icons/archive/41e7d2162b121e3798438a7da25ff4648b204dc7.tar.gz", + .hash = "icons-0.1.0-iJxA-Z0TNABnFm_tPfx46ZnvojHweT6xON9ay91XZ4Xo", + .lazy = true, + }, }, } diff --git a/plugins/image/plugin.zig b/plugins/image/plugin.zig index a03c3c0a2..8d0cc063f 100644 --- a/plugins/image/plugin.zig +++ b/plugins/image/plugin.zig @@ -68,6 +68,25 @@ pub fn register(host: *sdk.Host) !void { try host.registerPlugin(&plugin); try host.registerFileKind(.{ .owner = &plugin, .kind = fileKind }); + try host.registerCommand(.{ + .id = sdk.Plugin.commandId("image", "fitView"), + .owner = &plugin, + .title = "Fit to View", + .run = cmdFitView, + .isEnabled = cmdFitViewEnabled, + }); +} + +/// `fizzy.fitView` on an image: fitted to its pane, and kept fitted until the person moves it. +fn cmdFitView(_: *anyopaque) anyerror!void { + const handle = sdk.host().activeDoc() orelse return; + if (handle.owner != &plugin) return; + const doc = docFrom(handle) orelse return; + doc.canvas.fitToView(); +} +fn cmdFitViewEnabled(_: *anyopaque) bool { + const handle = sdk.host().activeDoc() orelse return false; + return handle.owner == &plugin; } pub fn pluginPtr() *sdk.Plugin { diff --git a/plugins/image/src/ImageView.zig b/plugins/image/src/ImageView.zig index 04bc9caf8..d51372c39 100644 --- a/plugins/image/src/ImageView.zig +++ b/plugins/image/src/ImageView.zig @@ -3,6 +3,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); const sdk = @import("fizzy_sdk"); +const icons = @import("icons"); const Document = @import("Document.zig"); const CanvasWidget = core.widgets.CanvasWidget; @@ -24,19 +25,39 @@ pub fn draw(doc: *Document) !void { // background, at the app's content opacity, the same as under pixi's canvas). Its edge // shadows are the canvas widget's own. - doc.canvas.install(@src(), .{ - .id = doc.canvas.id, - .data_size = .{ .w = image_rect.w, .h = image_rect.h }, - .pan_zoom_scheme = canvasPanZoomScheme(), - }, .{ .expand = .both, .background = false, .color_fill = .{ .color = .transparent } }); - defer doc.canvas.deinit(); - - drawShadow(&doc.canvas, image_rect); - drawFill(image_rect); - try drawCheckerboard(doc, image_rect); - try drawImage(doc); - drawOutline(&doc.canvas); - doc.canvas.processEvents(); + const view = view: { + doc.canvas.install(@src(), .{ + .id = doc.canvas.id, + .data_size = .{ .w = image_rect.w, .h = image_rect.h }, + .pan_zoom_scheme = canvasPanZoomScheme(), + }, .{ .expand = .both, .background = false, .color_fill = .{ .color = .transparent } }); + defer doc.canvas.deinit(); + + drawShadow(&doc.canvas, image_rect); + drawFill(image_rect); + try drawCheckerboard(doc, image_rect); + try drawImage(doc); + drawOutline(&doc.canvas); + doc.canvas.processEvents(); + break :view doc.canvas.scroll_container.data().contentRectScale().r.toNatural(); + }; + // Outside the canvas: a floating widget made inside it takes the canvas's zoom. + drawFitButton(doc, view); +} + +/// Fit to View as a glass bubble at the viewport's top left, where pixi keeps its canvas controls. +fn drawFitButton(doc: *Document, view: dvui.Rect.Natural) void { + const margin: f32 = 10; + const size = core.widgets.glass_button.size; + if (view.w < size + 2 * margin or view.h < size + 2 * margin) return; + // What is under it: the image, where it is and how big, and which of its frames shows. + var under = std.hash.Wyhash.init(0); + under.update(std.mem.asBytes(&.{ doc.canvas.screen_rect_scale.r.x, doc.canvas.screen_rect_scale.r.y, doc.canvas.screen_rect_scale.s })); + under.update(std.mem.asBytes(&.{ doc.width, doc.height, doc.frame })); + const rect: dvui.Rect = .{ .x = view.x + margin, .y = view.y + margin, .w = size, .h = size }; + if (core.widgets.glass_button.bubble(@src(), rect, icons.tvg.lucide.scan, .{ .label = "Fit to View", .witness_under = under.final() })) { + doc.canvas.fitToView(); + } } fn drawShadow(canvas: *CanvasWidget, image_rect: dvui.Rect) void { diff --git a/plugins/image/static/integration.zig b/plugins/image/static/integration.zig index 1253914b8..74981c730 100644 --- a/plugins/image/static/integration.zig +++ b/plugins/image/static/integration.zig @@ -14,6 +14,7 @@ pub const ModuleImports = struct { core: *std.Build.Module, sdk: *std.Build.Module, proxy_bridge: ?*std.Build.Module = null, + icons: ?*std.Build.Module = null, }; fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { @@ -21,6 +22,7 @@ fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { module.addImport("core", imports.core); module.addImport("fizzy_sdk", imports.sdk); if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); + if (imports.icons) |icons| module.addImport("icons", icons); } /// Static `@import("image")` module for exe / web / tests. diff --git a/plugins/markdown/src/md/render_ast.zig b/plugins/markdown/src/md/render_ast.zig index 1a2b5b02c..6c3d5878b 100644 --- a/plugins/markdown/src/md/render_ast.zig +++ b/plugins/markdown/src/md/render_ast.zig @@ -1949,8 +1949,16 @@ fn renderTopLevel(doc_node: ast.Node, ids: *IdGen, ctx: RenderContext) void { // everything below jumps, and next frame's anchor "corrects" it — the flash that // only happens when scrolling up. Pin the *emitted* height to the table; `record` still // learns the real size from the unconstrained inner box (see `measured` below). - const pin_for_above = known_h > 0 and !width_in_flux and y <= anchor_y; - const pin_h: ?f32 = if (known_h > 0 and !width_in_flux and + // + // Only a virtualized render pins. With no viewport (`drawInline`: a chat bubble, a + // tooltip) every block is laid out every frame, so no block comes back from a spacer and + // there is nothing for a pin to hide. There a pin does harm: an edit re-parses, which + // leaves the edited block with an estimate (biased low) or the deferred height its first + // frame filed, and next frame's pin emits that in place of its real height. The caller + // sees the block shrink by a line and grow back; a chat streaming a reply re-parses on + // every chunk, so its conversation bounced by a line each time. + const pin_for_above = virtualize and known_h > 0 and !width_in_flux and y <= anchor_y; + const pin_h: ?f32 = if (virtualize and known_h > 0 and !width_in_flux and (pin_for_above or (frozen and block_state != .estimated) or pinnable)) known_h else null; diff --git a/plugins/text/plugin.zig b/plugins/text/plugin.zig index 558c02173..5b1ca1eee 100644 --- a/plugins/text/plugin.zig +++ b/plugins/text/plugin.zig @@ -30,6 +30,7 @@ var plugin: sdk.Plugin = .{ .vtable = &vtable, .id = plugin_id, .display_name = plugin_options.name, + .step_format = Document.step_format, }; const vtable: sdk.Plugin.VTable = .{ @@ -40,6 +41,7 @@ const vtable: sdk.Plugin.VTable = .{ .loadDocument = loadDocument, .loadDocumentFromBytes = loadDocumentFromBytes, .createDocument = createDocument, + .loadUntitledDocument = loadUntitledDocument, .setDocumentGroupingOnBuffer = setDocumentGroupingOnBuffer, .documentIdFromBuffer = documentIdFromBuffer, .deinitDocumentBuffer = deinitDocumentBuffer, @@ -67,6 +69,7 @@ const vtable: sdk.Plugin.VTable = .{ .restoreDocumentState = restoreDocumentState, .historyLine = historyLine, .historyGoTo = historyGoTo, + .applyStep = applyStep, .documentFingerprint = documentFingerprint, .isDirty = isDirty, .saveDocument = saveDocument, @@ -281,6 +284,14 @@ fn createDocument(_: *anyopaque, path: []const u8, _: sdk.EditorAPI.NewDocGrid, doc.unsaved = true; doc.pending_focus = true; } +/// A document never saved, back from its unsaved text (a crash, a restart): untitled, and dirty. +fn loadUntitledDocument(_: *anyopaque, title: []const u8, _: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void { + const doc = docBuf(out_doc); + doc.* = try Document.fromBytes(title, bytes); + doc.unsaved = true; + // Clean is a history at its saved id, and this one was never saved. + doc.clean_op_id = std.math.maxInt(u64); +} fn setDocumentGroupingOnBuffer(_: *anyopaque, doc: *anyopaque, grouping: u64) void { docBuf(doc).grouping = grouping; } @@ -403,6 +414,10 @@ fn historyGoTo(_: *anyopaque, handle: DocHandle, id: u64) anyerror!void { const doc = docFrom(handle) orelse return error.DocumentNotFound; try doc.goToStep(id); } +fn applyStep(_: *anyopaque, handle: DocHandle, effect: []const u8, _: []const u8, _: u32) anyerror!void { + const doc = docFrom(handle) orelse return error.DocumentNotFound; + try doc.applyStep(effect); +} fn documentFingerprint(_: *anyopaque, handle: DocHandle) u64 { const doc = docFrom(handle) orelse return 0; return doc.fingerprint(); diff --git a/plugins/text/src/Document.zig b/plugins/text/src/Document.zig index 291adf8aa..40a20eca5 100644 --- a/plugins/text/src/Document.zig +++ b/plugins/text/src/Document.zig @@ -429,6 +429,7 @@ pub fn reloadFromDisk(self: *Document) !void { try tc.encoding.appendLossy(&self.text, gpa, bytes); self.history.deinit(gpa); self.history = .{}; + self.recordStep(.{ .kind = .restarted, .id = 0 }); self.clean_op_id = 0; self.refreshLineCount(); self.sel_start = 0; @@ -446,10 +447,11 @@ pub fn reloadFromDisk(self: *Document) !void { /// contents, selection with the caret's end, scroll, preview, and whether it is dirty /// (`doc_state`). pub fn captureState(self: *const Document, allocator: std.mem.Allocator) ![]u8 { + const sel = self.selection(); return doc_state.encode(allocator, .{ .text = self.text.items, - .anchor = if (self.sel_cursor == self.sel_start) self.sel_end else self.sel_start, - .head = self.sel_cursor, + .anchor = sel.anchor, + .head = sel.head, .dirty = self.isDirty(), .focused = self.editor_focused, .untitled = self.unsaved, @@ -464,11 +466,21 @@ pub fn captureState(self: *const Document, allocator: std.mem.Allocator) ![]u8 { pub fn fingerprint(self: *const Document) u64 { var h = std.hash.Wyhash.init(0x7e47_f1); h.update(self.text.items); - const anchor = if (self.sel_cursor == self.sel_start) self.sel_end else self.sel_start; - h.update(std.mem.asBytes(&[_]u64{ anchor, self.sel_cursor, @intFromBool(self.isDirty()), @backingInt(self.preview_mode) })); + const sel = self.selection(); + h.update(std.mem.asBytes(&[_]u64{ sel.anchor, sel.head, @intFromBool(self.isDirty()), @backingInt(self.preview_mode) })); return h.final(); } +/// The selection as the document has it: one an edit from outside the widget set +/// (`pending_sel`, which the next draw installs, clamped as it does), else the widget's. +pub fn selection(self: *const Document) tc.Range { + if (self.pending_sel) |r| { + const len = self.text.items.len; + return .init(@min(r.anchor, len), @min(r.head, len)); + } + return .init(if (self.sel_cursor == self.sel_start) self.sel_end else self.sel_start, self.sel_cursor); +} + /// Put the document back as `captureState` found it, in place — the buffer replaced as a reload /// replaces it (`reloadFromDisk`), so the next draw lays it out afresh. Undo history starts over. pub fn restoreState(self: *Document, bytes: []const u8) !void { @@ -480,6 +492,7 @@ pub fn restoreState(self: *Document, bytes: []const u8) !void { try self.text.appendSlice(gpa, st.text); self.history.deinit(gpa); self.history = .{}; + self.recordStep(.{ .kind = .restarted, .id = 0 }); // Clean is a history at its saved id; dirty is anything else. self.clean_op_id = if (st.dirty) std.math.maxInt(u64) else self.history.topOpId(); self.refreshLineCount(); @@ -522,7 +535,8 @@ pub fn saveAs(self: *Document, new_path: []const u8) !void { pub fn replaceRange(self: *Document, start: usize, end: usize, new: []const u8) !void { const gpa = sdk.allocator(); const after: tc.Range = .collapsed(start + new.len); - try self.history.pushComplete(gpa, start, self.text.items[start..end], new, .init(start, end), after); + const committed = try self.history.pushComplete(gpa, start, self.text.items[start..end], new, .init(start, end), after); + if (committed) |c| self.recordCommitted(c); try self.text.replaceRange(gpa, start, end - start, new); self.refreshLineCount(); self.sel_start = after.head; @@ -536,6 +550,7 @@ pub fn replaceRange(self: *Document, start: usize, end: usize, new: []const u8) pub fn undo(self: *Document) void { const gpa = sdk.allocator(); const sel = self.history.applyUndo(gpa, &self.text) orelse return; + if (self.history.lastUndone()) |s| self.recordEdits(.undone, s.step, s.edits, s.sel_before, true); self.refreshLineCount(); self.sel_start = @min(sel.start(), self.text.items.len); self.sel_end = @min(sel.end(), self.text.items.len); @@ -547,9 +562,64 @@ pub fn undo(self: *Document) void { pub fn redo(self: *Document) void { const gpa = sdk.allocator(); const sel = self.history.applyRedo(gpa, &self.text) orelse return; + if (self.history.lastApplied()) |s| self.recordEdits(.redone, s.step, s.edits, s.sel_after, false); + self.refreshLineCount(); + self.sel_start = @min(sel.start(), self.text.items.len); + self.sel_end = @min(sel.end(), self.text.items.len); + self.pending_sel = sel; + sdk.host().documentEdited(self.id); +} + +/// The text plugin's step encoding (`sdk.Plugin.step_format`): `tc.Transaction.encodeStep`'s. +pub const step_format = 1; + +/// Report a step of this document's history to whoever records it (`sdk.Host.recordStep`), when +/// anyone does: nothing is encoded otherwise. +fn recordStep(self: *const Document, step: sdk.Step) void { + sdk.host().recordStep(self.id, step); +} + +/// What `history.end` or `pushComplete` did, reported as a step. +pub fn recordCommitted(self: *const Document, c: tc.History.Committed) void { + self.recordEdits(if (c.joined) .joined else .added, c.step, c.edits, c.sel_after, false); +} + +/// `edits` of history step `id` reported as a step, forward or (`reverse`) as their undo, `sel` +/// the selection they leave. +fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []const tc.Transaction.Edit, sel: tc.Range, reverse: bool) void { + // Every step reaches the host, which keeps who made it; its bytes only while it is recorded. + const top: u64 = if (self.history.lastApplied()) |r| r.step else 0; + if (!sdk.host().recording(self.id)) return self.recordStep(.{ .kind = kind, .id = id, .top = top }); + const gpa = sdk.allocator(); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + tc.Transaction.encodeStep(&out.writer, edits, sel, reverse) catch { + std.log.err("text: a step of {s} was not recorded: out of memory", .{self.path}); + return; + }; + self.recordStep(.{ .kind = kind, .id = id, .effect = out.written(), .top = top }); +} + +/// Apply a recorded step (`step_format`) as a new step of this document's history: the edits +/// checked against the text and landed together, the selection where they left it. `error.BadStep` +/// or `error.StepDoesNotApply` leave the document as it was. Lands at once: showing it typed out +/// over a replay's `ms` is the seam for later (`sdk.Plugin.VTable.applyStep`). +pub fn applyStep(self: *Document, effect: []const u8) !void { + const gpa = sdk.allocator(); + const view = try tc.Transaction.StepView.init(effect); + var txn = try tc.Transaction.applyStepTo(gpa, &self.text, view); + const committed = self.history.push(gpa, txn) catch |err| { + // Kept out of the history, the text would not undo: take it back. + txn.applyInverse(gpa, &self.text) catch {}; + txn.deinit(gpa); + return err; + }; + self.recordCommitted(committed); self.refreshLineCount(); + const sel = view.sel; self.sel_start = @min(sel.start(), self.text.items.len); self.sel_end = @min(sel.end(), self.text.items.len); + self.sel_cursor = @min(sel.head, self.text.items.len); self.pending_sel = sel; sdk.host().documentEdited(self.id); } diff --git a/plugins/text/src/TextEditor.zig b/plugins/text/src/TextEditor.zig index 611a03a93..3694ce75e 100644 --- a/plugins/text/src/TextEditor.zig +++ b/plugins/text/src/TextEditor.zig @@ -1830,7 +1830,14 @@ fn editNotifyInserted(ctx: *anyopaque, pos: usize, bytes: []const u8) void { docFromCtx(ctx).history.note(sdk.allocator(), pos, "", bytes); } fn editNotifyEnd(ctx: *anyopaque, sel_after: tc.Range) void { - docFromCtx(ctx).history.end(sdk.allocator(), sel_after); + const doc = docFromCtx(ctx); + // One hand's typing never joins another's run (`sdk.Host.mayMerge`). + const host = sdk.host(); + const may_join = if (doc.history.lastApplied()) |top| host.mayMerge(doc.id, top.step) else true; + if (doc.history.end(sdk.allocator(), sel_after, may_join)) |c| { + doc.recordCommitted(c); + host.documentEdited(doc.id); + } } const max_text_bytes: usize = 64 * 1024 * 1024; diff --git a/plugins/text/src/textcore/History.zig b/plugins/text/src/textcore/History.zig index 77898e135..5f00dbebb 100644 --- a/plugins/text/src/textcore/History.zig +++ b/plugins/text/src/textcore/History.zig @@ -187,14 +187,53 @@ pub fn note( }; } -/// Commit the pending edit, merging it into the current group when VSCode's rule says to. -pub fn end(self: *History, gpa: Allocator, sel_after: Range) void { - var p = self.pending orelse return; +/// What a commit did to the history, for whoever records it (`sdk.Step`). +pub const Committed = struct { + step: u64, + /// It joined the step on top (a run of typing) rather than starting one. + joined: bool, + /// What this commit did: the whole step when it starts one, only the new part when it joins. + /// Valid until the history next changes. + edits: []const Transaction.Edit, + sel_after: Range, +}; + +/// A step as the history holds it: its id, its edits, and the selection either side. Valid until +/// the history next changes. +pub const StepRef = struct { + step: u64, + edits: []const Transaction.Edit, + sel_before: Range, + sel_after: Range, + + fn of(e: *const Entry) StepRef { + return .{ .step = e.step, .edits = e.txn.edits.items, .sel_before = e.txn.sel_before, .sel_after = e.txn.sel_after }; + } +}; + +/// The step `applyRedo` just put back: the last applied. Null when none is. +pub fn lastApplied(self: *const History) ?StepRef { + const items = self.undo_stack.items; + return if (items.len == 0) null else .of(&items[items.len - 1]); +} + +/// The step `applyUndo` just took back: the next to redo. Null when there is none. +pub fn lastUndone(self: *const History) ?StepRef { + const items = self.redo_stack.items; + return if (items.len == 0) null else .of(&items[items.len - 1]); +} + +/// Commit the pending edit, merging it into the current group when VSCode's rule says to and +/// `may_join` allows: false when the step on top is another hand's (`sdk.Host.mayMerge`), so one +/// hand's typing never joins another's run. Returns what it did, or null when there was nothing to +/// commit. +pub fn end(self: *History, gpa: Allocator, sel_after: Range, may_join: bool) ?Committed { + var p = self.pending orelse return null; self.pending = null; if (p.txn.isEmpty()) { p.txn.deinit(gpa); - return; + return null; } p.txn.sel_after = sel_after; @@ -224,12 +263,14 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range) void { self.clearRedo(gpa); - const can_merge = self.group_open and + const can_merge = may_join and self.group_open and self.undo_stack.items.len > 0 and !shouldBreakGroup(prev_kind, kind); + var committed: Committed = undefined; if (can_merge) { var top = &self.undo_stack.items[self.undo_stack.items.len - 1]; + const first_new = top.txn.edits.items.len; // Move the pending edits onto the existing group; the group keeps its original // `sel_before` so undo returns to where the whole run started. for (p.txn.edits.items) |e| { @@ -246,6 +287,7 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range) void { top.kind = kind; top.id = self.next_id; self.next_id += 1; + committed = .{ .step = top.step, .joined = true, .edits = top.txn.edits.items[first_new..], .sel_after = sel_after }; } else { self.undo_stack.append(gpa, .{ .txn = p.txn, @@ -255,16 +297,19 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range) void { }) catch |err| { std.log.err("History.end: push failed: {s}", .{@errorName(err)}); p.txn.deinit(gpa); - return; + return null; }; self.next_id += 1; + const top = &self.undo_stack.items[self.undo_stack.items.len - 1]; + committed = .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = sel_after }; } self.group_open = true; self.version += 1; + return committed; } /// Record one complete edit built outside the begin/note/end flow (the paste command). Always -/// its own undo step. +/// its own undo step. Null when it changed nothing. pub fn pushComplete( self: *History, gpa: Allocator, @@ -273,19 +318,27 @@ pub fn pushComplete( inserted: []const u8, sel_before: Range, sel_after: Range, -) !void { +) !?Committed { var txn: Transaction = .{ .sel_before = sel_before, .sel_after = sel_after }; errdefer txn.deinit(gpa); try txn.append(gpa, pos, removed, inserted); if (txn.isEmpty()) { txn.deinit(gpa); - return; + return null; } + return try self.push(gpa, txn); +} + +/// Take `txn`, already applied, as a step of its own: a recorded step played into the document +/// (`Transaction.applyStepTo`). +pub fn push(self: *History, gpa: Allocator, txn: Transaction) !Committed { self.clearRedo(gpa); try self.undo_stack.append(gpa, .{ .txn = txn, .kind = .other, .id = self.next_id, .step = self.next_id }); self.next_id += 1; self.group_open = false; self.version += 1; + const top = &self.undo_stack.items[self.undo_stack.items.len - 1]; + return .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = top.txn.sel_after }; } fn clearRedo(self: *History, gpa: Allocator) void { @@ -422,7 +475,7 @@ const Harness = struct { try self.text.insertSlice(self.gpa, self.cursor, ch); self.hist.note(self.gpa, self.cursor, "", ch); self.cursor += ch.len; - self.hist.end(self.gpa, .collapsed(self.cursor)); + _ = self.hist.end(self.gpa, .collapsed(self.cursor), true); } } @@ -434,7 +487,7 @@ const Harness = struct { self.hist.note(self.gpa, self.cursor - 1, removed, ""); try self.text.replaceRange(self.gpa, self.cursor - 1, 1, ""); self.cursor -= 1; - self.hist.end(self.gpa, .collapsed(self.cursor)); + _ = self.hist.end(self.gpa, .collapsed(self.cursor), true); } } @@ -533,7 +586,7 @@ test "undo restores the selection, not just an offset" { hist.begin(.init(6, 11)); hist.note(a, 6, "world", "X"); try text.replaceRange(a, 6, 5, "X"); - hist.end(a, .collapsed(7)); + _ = hist.end(a, .collapsed(7), true); const sel = hist.applyUndo(a, &text).?; try t.expectEqualStrings("hello world", text.items); @@ -622,7 +675,7 @@ test "cut-shaped deletion is one undoable step" { h.hist.note(h.gpa, 6, "world", ""); try h.text.replaceRange(h.gpa, 6, 5, ""); h.cursor = 6; - h.hist.end(h.gpa, .collapsed(6)); + _ = h.hist.end(h.gpa, .collapsed(6), true); try t.expectEqualStrings("hello ", h.text.items); _ = h.undo(); @@ -657,3 +710,54 @@ test "the line: steps keep their ids through merges, undo and redo" { try t.expectEqual(@as(?isize, -1), h.hist.stepsTo(0)); try t.expectEqual(@as(?isize, null), h.hist.stepsTo(9999)); } + +test "a commit says whether it started a step or joined one, and what it added" { + const a = t.allocator; + var hist: History = .{}; + defer hist.deinit(a); + + hist.begin(.collapsed(0)); + hist.note(a, 0, "", "a"); + const first = hist.end(a, .collapsed(1), true).?; + try t.expect(!first.joined); + try t.expectEqual(@as(usize, 1), first.edits.len); + + hist.begin(.collapsed(1)); + hist.note(a, 1, "", "b"); + const second = hist.end(a, .collapsed(2), true).?; + // Typing runs on: the same step, and only the new keystroke. + try t.expect(second.joined); + try t.expectEqual(first.step, second.step); + try t.expectEqual(@as(usize, 1), second.edits.len); + try t.expectEqualStrings("b", second.edits[0].inserted); + + // An empty commit commits nothing. + hist.begin(.collapsed(2)); + try t.expect(hist.end(a, .collapsed(2), true) == null); + + var text: std.ArrayList(u8) = .empty; + defer text.deinit(a); + try text.appendSlice(a, "ab"); + _ = hist.applyUndo(a, &text).?; + try t.expectEqual(first.step, hist.lastUndone().?.step); + try t.expectEqual(@as(usize, 2), hist.lastUndone().?.edits.len); + _ = hist.applyRedo(a, &text).?; + try t.expectEqual(first.step, hist.lastApplied().?.step); +} + +test "an edit another hand may not join starts a step of its own" { + const a = t.allocator; + var hist: History = .{}; + defer hist.deinit(a); + + hist.begin(.collapsed(0)); + hist.note(a, 0, "", "a"); + const first = hist.end(a, .collapsed(1), true).?; + hist.begin(.collapsed(1)); + hist.note(a, 1, "", "b"); + // Typing on, which VSCode's rule would join, but the step on top is another hand's. + const second = hist.end(a, .collapsed(2), false).?; + try t.expect(!second.joined); + try t.expect(second.step != first.step); + try t.expectEqual(@as(usize, 2), hist.undo_stack.items.len); +} diff --git a/plugins/text/src/textcore/Transaction.zig b/plugins/text/src/textcore/Transaction.zig index 29f034ba1..f1e66870f 100644 --- a/plugins/text/src/textcore/Transaction.zig +++ b/plugins/text/src/textcore/Transaction.zig @@ -114,6 +114,121 @@ pub fn applyInverse(self: Transaction, gpa: Allocator, text: *std.ArrayList(u8)) } } +// -- as a step -------------------------------------------------------------------------------- + +/// Edits as a recorded step's effect (`sdk.Step`, the text plugin's step format 1): what fizzy +/// keeps of a change to replay it. Every count and offset LEB128, then the bytes: +/// +/// edits · per edit: pos, removed length, removed, inserted length, inserted · selection after: +/// anchor, head +/// +/// `removed` is kept although a forward replay only needs its length: it is what makes a step +/// checkable (`applyStepTo` refuses one whose removed bytes are not there), and what lets a step +/// be reversed. `reverse` writes the edits' undo as a forward step: inverses, newest first, the +/// selection the undo restores in `sel`. +pub fn encodeStep(w: *std.Io.Writer, edits: []const Edit, sel: Range, reverse: bool) std.Io.Writer.Error!void { + try w.writeUleb128(edits.len); + for (0..edits.len) |i| { + const e = edits[if (reverse) edits.len - 1 - i else i]; + const removed = if (reverse) e.inserted else e.removed; + const inserted = if (reverse) e.removed else e.inserted; + try w.writeUleb128(e.pos); + try w.writeUleb128(removed.len); + try w.writeAll(removed); + try w.writeUleb128(inserted.len); + try w.writeAll(inserted); + } + try w.writeUleb128(sel.anchor); + try w.writeUleb128(sel.head); +} + +/// A step `encodeStep` wrote, read back without copying: slices of `bytes`. +pub const StepView = struct { + bytes: []const u8, + edits: usize, + sel: Range, + + pub const Error = error{BadStep}; + + /// Read the step in `bytes`, checking it holds together, or `error.BadStep`. + pub fn init(bytes: []const u8) Error!StepView { + var r: std.Io.Reader = .fixed(bytes); + const n = take(&r) catch return error.BadStep; + for (0..n) |_| { + _ = take(&r) catch return error.BadStep; + for (0..2) |_| { + const len = take(&r) catch return error.BadStep; + r.discardAll(len) catch return error.BadStep; + } + } + const anchor = take(&r) catch return error.BadStep; + const head = take(&r) catch return error.BadStep; + if (r.seek != bytes.len) return error.BadStep; + return .{ .bytes = bytes, .edits = n, .sel = .init(anchor, head) }; + } + + /// Each edit in order: `{ pos, removed, inserted }`, slices of the step's bytes. + pub fn iterator(self: StepView) Iterator { + var r: std.Io.Reader = .fixed(self.bytes); + _ = take(&r) catch unreachable; + return .{ .r = r, .left = self.edits }; + } + + pub const Iterator = struct { + r: std.Io.Reader, + left: usize, + + pub const Part = struct { pos: usize, removed: []const u8, inserted: []const u8 }; + + pub fn next(it: *Iterator) ?Part { + if (it.left == 0) return null; + it.left -= 1; + // `init` checked every length. + const pos = take(&it.r) catch unreachable; + const removed = it.r.take(take(&it.r) catch unreachable) catch unreachable; + const inserted = it.r.take(take(&it.r) catch unreachable) catch unreachable; + return .{ .pos = pos, .removed = removed, .inserted = inserted }; + } + }; + + fn take(r: *std.Io.Reader) !usize { + return r.takeLeb128(usize); + } +}; + +/// Apply a step to `text` forward as one transaction, which it returns (owned) for the history. +/// Every edit must find at its `pos` the bytes it says it removes, or `error.StepDoesNotApply`, +/// and `text` is left as it was. +pub fn applyStepTo(gpa: Allocator, text: *std.ArrayList(u8), step: StepView) !Transaction { + var txn: Transaction = .{ .sel_after = step.sel }; + errdefer txn.deinit(gpa); + var it = step.iterator(); + var first = true; + while (it.next()) |e| { + if (first) txn.sel_before = .collapsed(e.pos); + first = false; + try txn.append(gpa, e.pos, e.removed, e.inserted); + } + // Checked against the text as each edit reaches it, then applied atomically. + var check: usize = 0; + errdefer { + // Undo the edits already applied, newest first. + var j = check; + while (j > 0) { + j -= 1; + const e = txn.edits.items[j]; + text.replaceRange(gpa, e.pos, e.inserted.len, e.removed) catch {}; + } + } + for (txn.edits.items) |e| { + if (e.pos > text.items.len or e.removed.len > text.items.len - e.pos) return error.StepDoesNotApply; + if (!std.mem.eql(u8, text.items[e.pos..][0..e.removed.len], e.removed)) return error.StepDoesNotApply; + try text.replaceRange(gpa, e.pos, e.removed.len, e.inserted); + check += 1; + } + return txn; +} + // -- tests ------------------------------------------------------------------------------------ const t = std.testing; @@ -255,3 +370,86 @@ test "random edit sequences round-trip" { try t.expectEqualStrings(after, text.items); } } + +fn stepBytes(gpa: Allocator, edits: []const Edit, sel: Range, reverse: bool) ![]u8 { + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + try encodeStep(&out.writer, edits, sel, reverse); + return out.toOwnedSlice(); +} + +test "a step replays its edits forward, and its reverse takes them back" { + const a = t.allocator; + var txn: Transaction = .{ .sel_before = .collapsed(5), .sel_after = .collapsed(12) }; + defer txn.deinit(a); + try txn.append(a, 5, "", ", wide"); + try txn.append(a, 0, "hello", "HELLO"); + try txn.append(a, 12, "world", "wörld"); + + var text = try listOf(a, "hello world"); + defer text.deinit(a); + var original = try listOf(a, "hello world"); + defer original.deinit(a); + + const fwd = try stepBytes(a, txn.edits.items, txn.sel_after, false); + defer a.free(fwd); + var applied = try applyStepTo(a, &text, try .init(fwd)); + defer applied.deinit(a); + try t.expectEqualStrings("HELLO, wide wörld", text.items); + try t.expectEqual(txn.sel_after, applied.sel_after); + // What the history keeps is the same edits: it undoes as the original would. + try applied.applyInverse(a, &text); + try t.expectEqualStrings("hello world", text.items); + + try txn.applyForward(a, &original); + const back = try stepBytes(a, txn.edits.items, txn.sel_before, true); + defer a.free(back); + var undone = try applyStepTo(a, &original, try .init(back)); + defer undone.deinit(a); + try t.expectEqualStrings("hello world", original.items); + try t.expectEqual(txn.sel_before, undone.sel_after); +} + +test "a step that does not fit the text is refused, and the text is left as it was" { + const a = t.allocator; + var txn: Transaction = .{}; + defer txn.deinit(a); + try txn.append(a, 0, "ab", "x"); + try txn.append(a, 1, "cd", "y"); + const bytes = try stepBytes(a, txn.edits.items, .collapsed(2), false); + defer a.free(bytes); + + // The first edit fits; the second finds other bytes. Nothing of either stays. + var text = try listOf(a, "abXX"); + defer text.deinit(a); + try t.expectError(error.StepDoesNotApply, applyStepTo(a, &text, try .init(bytes))); + try t.expectEqualStrings("abXX", text.items); + var short = try listOf(a, "a"); + defer short.deinit(a); + try t.expectError(error.StepDoesNotApply, applyStepTo(a, &short, try .init(bytes))); + try t.expectEqualStrings("a", short.items); +} + +test "a damaged step is an error, never a crash" { + const a = t.allocator; + var txn: Transaction = .{}; + defer txn.deinit(a); + try txn.append(a, 3, "abc", "défg"); + const bytes = try stepBytes(a, txn.edits.items, .init(1, 4), false); + defer a.free(bytes); + for (0..bytes.len) |n| try t.expectError(error.BadStep, StepView.init(bytes[0..n])); + + var prng: std.Random.DefaultPrng = .init(0x7e47); + const r = prng.random(); + const copy = try a.dupe(u8, bytes); + defer a.free(copy); + for (0..4000) |_| { + @memcpy(copy, bytes); + copy[r.uintLessThan(usize, copy.len)] ^= @as(u8, 1) << r.int(u3); + const view = StepView.init(copy) catch continue; + var text = try listOf(a, "xyzabcxyz"); + defer text.deinit(a); + var got = applyStepTo(a, &text, view) catch continue; + got.deinit(a); + } +} diff --git a/plugins/workbench/plugin.zig b/plugins/workbench/plugin.zig index 8a6b8bc83..8a7c4b7fd 100644 --- a/plugins/workbench/plugin.zig +++ b/plugins/workbench/plugin.zig @@ -62,13 +62,20 @@ pub fn register(host: *sdk.Host) !void { .draw = drawFiles, }); } - try host.registerSurface(.{ + // Where the app arranges the panes, each is a view of its own (`Workbench.places`), and + // there is no one surface drawing them all. + if (runtime.workbench().places == null) try host.registerSurface(.{ .id = center_workspaces, .owner = &plugin, .title = "Workspace", .keywords = sdk.keywords.ide.main, .draw = drawCenter, }); + if (comptime has_file_tree) try host.subscribe(.{ + .event = "fizzy.files.reveal", + .owner = &plugin, + .handler = revealNewPath, + }); try host.declareEvent(.{ .name = "workbench.pane.closed", .owner = &plugin, @@ -84,6 +91,14 @@ fn deinit(_: *anyopaque) void { files.deinitCaches(); } +/// A path just made, from a plugin's New flow or the app: the tree reveals it, selected, with its +/// inline rename open, once its row exists. +fn revealNewPath(_: *anyopaque, event: sdk.events.Event) void { + const reveal = sdk.events.parse(sdk.events.Path, runtime.host().arena(), event) catch return; + runtime.workbench().setPendingNewFilePath(reveal.path) catch |err| + dvui.log.err("workbench: could not queue {s} to reveal: {t}", .{ reveal.path, err }); +} + fn drawFiles(_: ?*anyopaque) anyerror!dvui.App.Result { try files.draw(); return .ok; diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index b522eabd2..cdc7d6699 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -38,6 +38,12 @@ workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, /// plugin's own directory whenever it changes (`workbench_layout.savePanes`). panes: Panes, panes_dirty: bool = false, +/// Where the panes are arranged when the app arranges them instead of `panes`: each pane a view +/// of the app's, in a place of the app's, beside another by a split of the app's (fizzy's +/// `Workspaces`, behind `FIZZY_WORKSPACES=1`). Null: the panes are this tree's leaves, as before. +/// Filled in by the app before this plugin registers, and instance state for the same +/// two-copies reason as `pending_new_file_path` below. +places: ?Places = null, /// How tall a pane's tab strip was, the last time a pane had one. An empty pane beside others /// holds the same room open, so its card lines up with its neighbours' instead of starting /// higher. @@ -48,7 +54,9 @@ grouping_id_counter: u64 = 0, /// was (a hash of its surface id): the gap a strip opens for it while it is carried over one. carried_tab_w: f32 = 0, carried_tab_key: u64 = 0, -file_tree_data_id: ?dvui.Id = null, +/// The file tree's expanded folders, by branch id. Instance state for the same two-copies reason +/// as `pending_new_file_path` below; fizzy's demos save and restore it with the rest of a scene. +open_branches: std.AutoHashMapUnmanaged(dvui.Id, void) = .empty, /// Branch id of the last root the file tree opened by default. A new root starts expanded once; /// after that its open state is the user's (`files.drawRoot`). Instance state for the same /// two-copies reason as `pending_new_file_path` below. @@ -78,6 +86,23 @@ pending_new_file_path: ?[]u8 = null, /// document yet. Strings are the app's; this only borrows them. loading: std.StringArrayHashMapUnmanaged(Loading) = .empty, +/// The app's half of arranging the panes (`places`). Every pane is named by its grouping; what +/// it is called in the app's layout is the app's business. +pub const Places = struct { + ctx: *anyopaque, + /// Give `grouping`'s pane a place on `side` of the place holding `anchor`'s. False when there + /// is no room, or `anchor`'s pane is in no place yet. + beside: *const fn (ctx: *anyopaque, grouping: u64, anchor: u64, side: Panes.Side) bool, + /// Whether `grouping`'s pane has a place, or will have one without being given it: the one + /// pane the layout's keywords show where no other is. + placed: *const fn (ctx: *anyopaque, grouping: u64) bool, + /// `grouping`'s pane has emptied and is going: its place closes, into the one beside it. + close: *const fn (ctx: *anyopaque, grouping: u64) void, + /// Open an empty place on `side` of the place holding `grouping`'s pane, the pane staying + /// where it is: a pane's only tab dropped on its own edge, as a view alone in its place is. + open: *const fn (ctx: *anyopaque, grouping: u64, side: Panes.Side) bool, +}; + pub const Loading = struct { /// The file being opened: the tab's icon comes from its extension. path: []const u8, @@ -107,6 +132,7 @@ pub fn init(allocator: std.mem.Allocator) Workbench { } pub fn deinit(self: *Workbench) void { + self.open_branches.deinit(self.allocator); self.loading.deinit(self.allocator); self.decorators.deinit(self.allocator); self.clearPendingNewFilePath(); @@ -145,8 +171,20 @@ pub fn paneLeaf(self: *Workbench, grouping: u64) ?Panes.NodeIndex { } /// Give `grouping` a leaf if it has none: the root when the tree is empty, else a split off the -/// current pane's right edge (or off the last pane, when the current one is gone). +/// current pane's right edge (or off the last pane, when the current one is gone). Where the app +/// arranges the panes, a place beside the current one's. fn seatPane(self: *Workbench, grouping: u64) !void { + if (self.places) |p| { + if (p.placed(p.ctx, grouping)) return; + const anchor = if (self.open_workspace_grouping != grouping and self.workspaces.contains(self.open_workspace_grouping)) + self.open_workspace_grouping + else for (self.workspaces.keys()) |g| { + if (g != grouping) break g; + } else return; + // No room yet (the layout has not drawn): the app places it once there is. + _ = p.beside(p.ctx, grouping, anchor, .right); + return; + } if (self.paneLeaf(grouping) != null) return; const root = &self.panes.nodes.items[self.panes.root]; if (root.* == .leaf and root.leaf.tabs.items.len == 0) { @@ -167,6 +205,14 @@ fn seatPane(self: *Workbench, grouping: u64) !void { /// Open a new pane for `grouping` on `side` of `anchor`'s pane — a drop on a pane's edge. The /// workspace is created too, so `pane()` afterwards finds both. pub fn paneBeside(self: *Workbench, grouping: u64, anchor: u64, side: Panes.Side) !void { + if (self.places) |p| { + if (!p.beside(p.ctx, grouping, anchor, side)) return error.NoRoom; + const gop = try self.workspaces.getOrPut(self.allocator, grouping); + if (!gop.found_existing) gop.value_ptr.* = Workspace.init(grouping); + gop.value_ptr.expecting = true; + if (grouping > self.grouping_id_counter) self.grouping_id_counter = grouping; + return; + } if (self.paneLeaf(grouping) != null) return; const anchor_leaf = self.paneLeaf(anchor) orelse return error.NoSuchPane; var buf: [32]u8 = undefined; @@ -183,16 +229,26 @@ pub fn paneBeside(self: *Workbench, grouping: u64, anchor: u64, side: Panes.Side /// Send an emptied pane's leaf sliding shut. The workspace itself leaves once the leaf has /// gone (`workbench_layout.rebuildWorkspaces`). pub fn closePane(self: *Workbench, grouping: u64) void { + if (self.places) |p| return p.close(p.ctx, grouping); const leaf = self.paneLeaf(grouping) orelse return; self.panes.closeLeaf(leaf); } /// Whether `grouping`'s pane is sliding shut (`closePane`). pub fn paneClosing(self: *Workbench, grouping: u64) bool { + // The app's place slides shut on its own; the pane is gone from the moment it closes. + if (self.places != null) return false; const leaf = self.paneLeaf(grouping) orelse return false; return self.panes.leafClosing(leaf); } +/// Whether this is the only pane: the one that shows the home page when empty, and the one that +/// never goes. +pub fn solePane(self: *const Workbench) bool { + if (self.places != null) return self.workspaces.count() <= 1; + return self.panes.nodes.items[self.panes.root] == .leaf; +} + /// At shutdown, after the last frame: `backend` frees what a frame would have (`Workspace.deinitAtQuit`). pub fn deinitWorkspaces(self: *Workbench, backend: dvui.Backend) void { for (self.workspaces.values()) |*workspace| workspace.deinitAtQuit(backend); @@ -209,8 +265,13 @@ pub fn newGroupingID(self: *Workbench) u64 { return self.grouping_id_counter; } -pub fn clearFileTreeDataId(self: *Workbench) void { - self.file_tree_data_id = null; +/// Expand or collapse the tree's folder `branch_id`. +pub fn setBranchOpen(self: *Workbench, branch_id: dvui.Id, open: bool) void { + if (open) { + self.open_branches.put(self.allocator, branch_id, {}) catch {}; + } else { + _ = self.open_branches.remove(branch_id); + } } /// A document closed by the user: its tab leaves every pane. Not called at shutdown, where the @@ -326,6 +387,10 @@ pub fn rebuildWorkspaces(self: *Workbench) !void { return workbench_layout.rebuildWorkspaces(self); } +pub fn forgetPanes(self: *Workbench) void { + workbench_layout.forgetPanes(self); +} + pub fn drawWorkspaces(self: *Workbench, index: usize) !dvui.App.Result { return workbench_layout.drawWorkspaces(self, index); } diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 05e628cda..2976d590a 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -176,7 +176,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result { if (tabs.len > 0) { self.drawTabs(region, tabs, selected); self.offerStrip(region, self.tabCount()); - } else if (!solePane()) { + } else if (!runtime.workbench().solePane()) { // An empty pane in a split looks like its neighbours with nothing open: the tab strip's // room, then the same card, empty — not a bare pane that starts higher than they do. const h = runtime.workbench().tab_strip_h; @@ -722,6 +722,15 @@ pub fn insertTab(self: *Workspace, id: []const u8, index: usize) void { pub fn paneDrop(ctx: ?*anyopaque, drop: sdk.RegionSpec.Drop) bool { const grouping: u64 = @as(u64, @intFromPtr(ctx orelse return false)) - 1; const wb = runtime.workbench(); + // A pane's only tab let go on the pane's own edge goes to that side as the pane, with room + // opening on the other: where the app places the panes, the place splits the way it does for + // a view alone in its place. Moved into a new pane there instead, the one it left closed + // behind it — the same tab and room, by a round trip. + if (wb.places) |p| if (drop.zone == .edge and drop.others.len == 0) { + if (wb.workspaces.getPtr(grouping)) |own| if (own.tabCount() == 1 and own.hasTab(drop.surface_id)) { + return p.open(p.ctx, grouping, paneSide(opposite(drop.zone.edge))); + }; + }; const target = switch (drop.zone) { .center => grouping, .edge => |side| blk: { @@ -777,6 +786,15 @@ fn openHere(self: *Workspace, id: []const u8, path: []const u8, at: ?usize) bool return started; } +fn opposite(side: sdk.RegionSpec.Drop.Side) sdk.RegionSpec.Drop.Side { + return switch (side) { + .left => .right, + .right => .left, + .top => .bottom, + .bottom => .top, + }; +} + fn paneSide(side: sdk.RegionSpec.Drop.Side) core.widgets.DockLayout.Side { return switch (side) { .left => .left, @@ -816,7 +834,7 @@ fn drawCanvas(self: *Workspace, region: sdk.Host.Region, has_tabs: bool) !void { // it. An empty pane beside others — above all one sliding shut after its last tab // closed — is just an empty document pane: the logo and buttons flashing up in it as it // went read as a page opening, not a file closing. - if (!solePane()) return; + if (!runtime.workbench().solePane()) return; const alpha = dvui.alpha(1.0); dvui.alphaSet(1.0); @@ -832,12 +850,6 @@ fn dropSnapshot(self: *Workspace) void { self.snapshot_idle_frames = 0; } -/// Whether this is the workbench's only pane — the one that shows the home page when empty. -fn solePane() bool { - const wb = runtime.workbench(); - return wb.panes.nodes.items[wb.panes.root] == .leaf; -} - pub fn drawHomePage(_: *Workspace) !void { const logo_pixel_size = 32; const logo_width = 3; diff --git a/plugins/workbench/src/files.zig b/plugins/workbench/src/files.zig index 9aabcd2b6..df4ee2784 100644 --- a/plugins/workbench/src/files.zig +++ b/plugins/workbench/src/files.zig @@ -115,14 +115,16 @@ pub fn draw() !void { // closes it; the table answers them all the same way. const folder: ?[]const u8 = runtime.host().folder(); const path = folder orelse { - runtime.workbench().file_tree_data_id = null; runtime.workbench().file_tree_root_opened = null; if (comptime builtin.target.cpu.arch == .wasm32) try drawWebEmpty() else drawNativeEmpty(); return; }; + // Where the tree's per-row stash lives (`dvui.dataSet` under it): made here, each frame it + // draws, so nothing outside the tree has to drop it when the Files view is hidden. + const unique_id = dvui.parentGet().extendId(@src(), 0); const filter_text = try drawFilter(tree); - try drawRoot(path, tree, filter_text); + try drawRoot(path, tree, unique_id, filter_text); } fn drawNativeEmpty() void { @@ -201,7 +203,6 @@ fn drawFilter(tree: *core.widgets.TreeWidget) ![]const u8 { // last frame's draw invalidated while it was still reading them. files.releaseRetired(); - runtime.workbench().file_tree_data_id = dvui.parentGet().extendId(@src(), 0); _ = tree; // Right margin keeps the entry clear of the overlay scrollbar that draws over the pane's right edge. @@ -215,8 +216,7 @@ fn drawFilter(tree: *core.widgets.TreeWidget) ![]const u8 { /// The explorer's one root: the open folder, drawn as an ordinary folder row (see `drawRow`'s /// `root`), then the blank space below the tree. -fn drawRoot(path: []const u8, tree: *core.widgets.TreeWidget, filter_text: []const u8) !void { - const unique_id = runtime.workbench().file_tree_data_id orelse return; +fn drawRoot(path: []const u8, tree: *core.widgets.TreeWidget, unique_id: dvui.Id, filter_text: []const u8) !void { const root_branch_id = tree.data().id.update(path); // A folder the tree hasn't shown yet opens expanded, once. After that its open state is the @@ -224,7 +224,7 @@ fn drawRoot(path: []const u8, tree: *core.widgets.TreeWidget, filter_text: []con const wb = runtime.workbench(); if (wb.file_tree_root_opened != root_branch_id.asUsize()) { wb.file_tree_root_opened = root_branch_id.asUsize(); - runtime.host().setExplorerBranchOpen(root_branch_id, true); + runtime.workbench().setBranchOpen(root_branch_id, true); } carryBack(tree, unique_id); @@ -869,7 +869,7 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr var expanded = false; const expanded_indent: f32 = 14.0; - if (runtime.host().explorerBranchIsOpen(branch_id)) { + if (runtime.workbench().open_branches.contains(branch_id)) { expanded = true; } @@ -974,7 +974,7 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr if (branch.dropInto() and entry.kind == .directory) { try applyFileMove(inner_unique_id, tree, abs_path); // Expand the folder so the dropped item is visible - runtime.host().setExplorerBranchOpen(branch_id, true); + runtime.workbench().setBranchOpen(branch_id, true); } { // Add right click context menu for item options @@ -1161,7 +1161,7 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr // .alpha = 0.15 * t, // }, })) { - runtime.host().setExplorerBranchOpen(branch_id, true); + runtime.workbench().setBranchOpen(branch_id, true); try search( abs_path, tree, @@ -1173,13 +1173,13 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr if (root) |r| r.ranked else null, ); } else { - if (runtime.host().explorerBranchIsOpen(branch_id)) { - runtime.host().setExplorerBranchOpen(branch_id, false); + if (runtime.workbench().open_branches.contains(branch_id)) { + runtime.workbench().setBranchOpen(branch_id, false); } } // Keep open_branches in sync so hover-expand and drop-into expand persist next frame if (branch.expanded) { - runtime.host().setExplorerBranchOpen(branch_id, true); + runtime.workbench().setBranchOpen(branch_id, true); } color_id.* = color_id.* + 1; }, @@ -1292,7 +1292,7 @@ fn appendRowOrder( const abs = core.paths.join(arena, directory, e.name) catch continue; const branch_id = tree_id.update(abs); out.append(arena, .{ .id = branch_id.asUsize(), .path = abs }) catch return; - if (e.kind == .directory and runtime.host().explorerBranchIsOpen(branch_id)) { + if (e.kind == .directory and runtime.workbench().open_branches.contains(branch_id)) { appendRowOrder(arena, tree_id, abs, out); } } @@ -1667,9 +1667,9 @@ fn applyFileMove(unique_id: dvui.Id, tree: *core.widgets.TreeWidget, target_dir: /// "New Folder..." from either context menu (the project row and a folder row run the same /// code): create the folder, then hand the tree its path so the row that appears next frame -/// opens its inline rename editor with the name selected — the same handoff a New Document -/// dialog makes through `EditorAPI.setExplorerNewFilePath`. Both now land on the shared -/// `Workbench` instance, so it no longer matters which copy of this module makes the call. +/// opens its inline rename editor with the name selected — what a plugin's New flow asks for +/// through the `files` service's `reveal`, heard here as `fizzy.files.reveal`. Both land on the +/// shared `Workbench` instance, so it does not matter which copy of this module makes the call. /// A folder that could not be created focuses nothing. pub fn createFolderInteractive(parent: []const u8) void { const arena = dvui.currentWindow().arena(); diff --git a/plugins/workbench/src/workbench_layout.zig b/plugins/workbench/src/workbench_layout.zig index 5648edffa..55d655c74 100644 --- a/plugins/workbench/src/workbench_layout.zig +++ b/plugins/workbench/src/workbench_layout.zig @@ -21,6 +21,8 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { if (!wb.restored) { wb.restored = true; + // Where the app arranges the panes, it brought them back itself — and carries an + // arrangement saved before it did over into its own (fizzy's `Workspaces.carryOver`). loadPanes(wb); for (host.assignedRegionNames()) |region_name| { const grouping = Workspace.groupingOfName(region_name) orelse continue; @@ -50,9 +52,10 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { } // A document nobody holds lands in the pane it was opened toward: the grouping the app - // stamped on it, which is the "open to the side" answer or the current pane. + // stamped on it, which is the "open to the side" answer or the current pane. Where the app + // arranges the panes, it seats them too (fizzy's `Workspaces.seat`). var i: usize = 0; - while (i < host.openDocCount()) : (i += 1) { + while (wb.places == null and i < host.openDocCount()) : (i += 1) { const doc = host.docByIndex(i) orelse continue; const id = try sdk.document.surfaceId(arena, doc.owner.id, doc.owner.documentPath(doc)); var held = false; @@ -92,11 +95,14 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { if (live > 0 or ws.expecting) { // Filled (or about to be) — including one that was on its way out when its // document landed, which is a pane to keep, not a hole to finish closing. - if (wb.paneLeaf(ws.grouping)) |leaf| wb.panes.reopenLeaf(leaf); + if (wb.places == null) if (wb.paneLeaf(ws.grouping)) |leaf| wb.panes.reopenLeaf(leaf); k += 1; continue; } - if (wb.paneLeaf(ws.grouping) != null) { + // The app's place slides shut by itself once it is closed, and the pane goes now. + if (wb.places) |p| { + p.close(p.ctx, ws.grouping); + } else if (wb.paneLeaf(ws.grouping) != null) { wb.closePane(ws.grouping); k += 1; continue; @@ -225,6 +231,14 @@ fn loadPanes(wb: *Workbench) void { } } +/// Delete the saved arrangement: the app has taken it over (`Workbench.places`). +pub fn forgetPanes(wb: *Workbench) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const path = panesPath(wb) orelse return; + defer wb.allocator.free(path); + std.Io.Dir.cwd().deleteFile(dvui.io, path) catch {}; +} + fn panesPath(wb: *Workbench) ?[]u8 { if (comptime builtin.target.cpu.arch == .wasm32) return null; const dir = runtime.host().pluginInstallDir("workbench") orelse return null; diff --git a/readme.md b/readme.md index af713a1c5..ff4f468f7 100644 --- a/readme.md +++ b/readme.md @@ -44,5 +44,8 @@ By default, when you install fizzy, only builtin plugins are included. This give - [prime31](https://github.com/prime31) for all the help. - Any and all contributors +## License - +Fizzy is [MIT](LICENSE), and will stay free. Plugins and applications built on it may be +commercial and closed-source. What that promises, and what it asks, is in +[LICENSING.md](LICENSING.md); the name and logo are covered by [TRADEMARKS.md](TRADEMARKS.md). diff --git a/scripts/check-examples.sh b/scripts/check-examples.sh index 1044a87d5..8ff8b52c6 100755 --- a/scripts/check-examples.sh +++ b/scripts/check-examples.sh @@ -27,13 +27,18 @@ cd "$root" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT -# A directory's files as they are on disk, without build output or version control. On macOS, -# without AppleDouble `._*` files too: bsdtar writes one per file with extended attributes, and -# the backend would take `patches/sdl/._0001-….patch` for a patch. -export COPYFILE_DISABLE=1 +# A directory's files as they are on disk, without build output or version control. +# macOS's bsdtar would add an AppleDouble `._name` entry beside every file carrying xattrs (every +# checkout has com.apple.provenance); unpacked, `backend/patches/sdl/._0001-….patch` fails the +# SDL patch step with NoSections. GNU tar, which CI uses, writes none (see scripts/pack-sdk.sh). +tar_flags=() +if tar --version 2>/dev/null | grep -q bsdtar; then + export COPYFILE_DISABLE=1 + tar_flags=(--no-xattrs --no-mac-metadata) +fi pack() { - tar -C "$1" -cf - --exclude=./.zig-cache --exclude=./zig-out --exclude=./zig-pkg \ - --exclude=./.git --exclude=./.jj --exclude=./.profile . + tar "${tar_flags[@]}" -C "$1" -cf - --exclude=./.zig-cache --exclude=./zig-out \ + --exclude=./zig-pkg --exclude=./.git --exclude=./.jj --exclude=./.profile . } pack "$root" | gzip > "$work/fizzy.tar.gz" diff --git a/scripts/compile-shaders.sh b/scripts/compile-shaders.sh new file mode 100755 index 000000000..7f890b65f --- /dev/null +++ b/scripts/compile-shaders.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Compile every HLSL shader that has compiled forms committed beside it, into OUT (same layout): +# +# X.hlsl → compiled/spv/X.spv SPIR-V (Vulkan) shadercross +# compiled/dxil/X.dxil DXIL (D3D12) shadercross +# compiled/msl/X.msl Metal (SDL_GPU, macOS) shadercross +# compiled/glsl/X.glsl GLSL ES 3.00 (WebGL2) spirv-cross, from the SPIR-V +# +# Only the forms that exist in the repository are made: a shader opts into one by committing it +# (an empty file to begin with). The HLSL is the one source; the rest are generated and checked +# (`.github/workflows/shaders.yml`), never edited by hand. +# +# The GLSL is complete source, for the web backend's programs (`core.gfx.programs`): its inputs +# are named as the web's vertex shader writes them (`vColor`, `vTextureCoord`), its samplers by +# unit (`uSampler` 0, `uTex1` 1), its uniforms `uData`, the names `web/web.js` binds. +# +# Needs `shadercross` and `spirv-cross` on PATH. Exits non-zero if any shader does not compile. +set -euo pipefail +OUT=${1:?usage: compile-shaders.sh OUT} +# Absolute: each shader compiles from its own directory (its `#include`s are relative). +mkdir -p "$OUT"; OUT=$(cd "$OUT" && pwd) +failed=0 +while IFS= read -r hlsl; do + dir=$(dirname "$hlsl"); name=$(basename "$hlsl" .hlsl) + for form in spv dxil msl glsl; do + [ -f "$dir/compiled/$form/$name.$form" ] || continue + dest="$OUT/$dir/compiled/$form/$name.$form" + mkdir -p "$(dirname "$dest")" + case $form in + spv | dxil | msl) + if ! (cd "$dir" && shadercross "$name.hlsl" -o "$dest"); then + echo "::error file=$hlsl::$hlsl does not compile to $form"; failed=1 + fi ;; + glsl) + spv="$OUT/$dir/compiled/spv/$name.spv" + [ -f "$spv" ] || (cd "$dir" && shadercross "$name.hlsl" -o "$spv.tmp" && mv "$spv.tmp" "$spv") + # NaN checks relaxed: HLSL's `min` and `max` came out wrapped in `isnan` tests, a cost the + # web's frame pays at every pixel for nothing the program relies on. + if ! spirv-cross "$spv" --es --version 300 --flatten-ubo --relax-nan-checks \ + --rename-interface-variable in 0 vColor \ + --rename-interface-variable in 1 vTextureCoord \ + --output "$dest.raw"; then + echo "::error file=$hlsl::$hlsl does not cross-compile to GLSL"; failed=1; continue + fi + # The names the web binds: the samplers by unit (spirv-cross names a texture and sampler it + # combines after both), the uniforms `uData` (it names a flattened block for its type). And + # floats high precision by default, as the web's own programs are: a program reads pixel + # positions. + sed -E \ + -e 's/\bSPIRV_Cross_CombinedFrostFrostSampler\b/uSampler/g' \ + -e 's/\bSPIRV_Cross_CombinedSharpSharpSampler\b/uTex1/g' \ + -e 's/\btype_Uniforms\b/uData/g' \ + -e 's/^precision mediump float;$/precision highp float;/' \ + "$dest.raw" > "$dest" + rm "$dest.raw" ;; + esac + done +done < <(git ls-files '*.hlsl') +exit $failed diff --git a/scripts/pack-sdk.sh b/scripts/pack-sdk.sh index c9d17166c..ba6b05c65 100755 --- a/scripts/pack-sdk.sh +++ b/scripts/pack-sdk.sh @@ -38,8 +38,10 @@ mkdir -p "$pkg" # Package root = the sdk/ build surface plus its own src/ (no app-only deps). cp -R sdk/. "$pkg/" -# The one module source that lives outside the package in-repo. +# The one module source that lives outside the package in-repo, and the license every copy of +# it carries. cp -R core "$pkg/core" +cp LICENSE "$pkg/LICENSE" # Drop build/editor-only noise if any leaked into the copy (none expected). rm -rf "$pkg/core/.zig-cache" "$pkg/src/.zig-cache" 2>/dev/null || true @@ -58,12 +60,14 @@ text = open(path).read() paths = re.search(r'\.paths = \.\{\n(.*?)\n \},', text, re.S) if not paths: sys.exit("pack-sdk: no .paths list in the package's build.zig.zon") -if '"core"' not in paths.group(1): - text = text[:paths.end(1)] + '\n "core",' + text[paths.end(1):] +for entry in ("core", "LICENSE"): + if f'"{entry}"' not in paths.group(1): + text = text[:paths.end(1)] + f'\n "{entry}",' + text[paths.end(1):] + paths = re.search(r'\.paths = \.\{\n(.*?)\n \},', text, re.S) text = re.sub(r'\.version = "[^"]*"', f'.version = "{version}"', text, count=1) paths = re.search(r'\.paths = \.\{\n(.*?)\n \},', text, re.S) -if '"core",' not in paths.group(1): - sys.exit("pack-sdk: could not add core to the package's .paths") +if '"core",' not in paths.group(1) or '"LICENSE",' not in paths.group(1): + sys.exit("pack-sdk: could not add core and LICENSE to the package's .paths") open(path, "w").write(text) PY diff --git a/sdk/replay/Player.zig b/sdk/replay/Player.zig index ce0b75488..aff26ea4b 100644 --- a/sdk/replay/Player.zig +++ b/sdk/replay/Player.zig @@ -933,6 +933,7 @@ const sink_vtable: Sequencer.Sink.VTable = .{ .keyframe = keyframe, .holds = holds, .timedOut = timedOut, + .step = step, }; fn from(ctx: *anyopaque) *Player { @@ -969,6 +970,12 @@ fn command(ctx: *anyopaque, cmd: Tape.Command) void { from(ctx).stage.command(cmd.id, cmd.args); } +fn step(ctx: *anyopaque, st: Tape.Step, ms: u32) void { + const self = from(ctx); + // A seek, or a frame run unseen to catch up, lands it at once. + self.stage.step(st, if (self.state == .seeking or self.catching_up) 0 else ms); +} + fn keyframe(ctx: *anyopaque, kf: *const Tape.Keyframe) void { const self = from(ctx); self.input.releaseHeld(); diff --git a/sdk/replay/Stage.zig b/sdk/replay/Stage.zig index 9a4ec9808..0df975936 100644 --- a/sdk/replay/Stage.zig +++ b/sdk/replay/Stage.zig @@ -48,6 +48,12 @@ pub const VTable = struct { /// Replaying fast (a seek): turn animation off so every replayed frame lands where it is /// going, and back on after. fastForward: ?*const fn (ctx: *anyopaque, on: bool) void = null, + /// Apply a step to a file of the keyframe the demo cut to, as its owner encoded it + /// (`Tape.Step`), shown being made over `ms`: 0 while a seek catches up, when it lands at once. + /// An app that sets this puts a keyframe's files in place opened by their `owner`, and says + /// whose steps it can apply (`Tape.Check.steps`); one that leaves it null refuses tapes with + /// steps at load. + step: ?*const fn (ctx: *anyopaque, step: Tape.Step, ms: u32) void = null, /// The app's model now, as something `restore` can put back — or null when this is not a /// moment to keep (work in flight, a popup open, a document whose owner cannot say what it @@ -91,6 +97,9 @@ pub fn commandTitle(self: Stage, id: []const u8) ?[]const u8 { pub fn fastForward(self: Stage, on: bool) void { if (self.vtable.fastForward) |f| f(self.ctx, on); } +pub fn step(self: Stage, st: Tape.Step, ms: u32) void { + if (self.vtable.step) |f| f(self.ctx, st, ms); +} pub fn snapshots(self: Stage) bool { return self.vtable.capture != null and self.vtable.restore != null and self.vtable.release != null; } diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index 3b5658a15..e101069a0 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -23,5 +23,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 2, - .patch = 26, + .patch = 29, }; diff --git a/sdk/src/Author.zig b/sdk/src/Author.zig new file mode 100644 index 000000000..de3d51707 --- /dev/null +++ b/sdk/src/Author.zig @@ -0,0 +1,38 @@ +//! Who made a change to a document: the person, an agent, a tape, or nobody known +//! (`plans/SHARED_HISTORY_PLAN.md`). The host keeps a step's author by the step's id +//! (`Host.recordStep`); owners never store one. +const std = @import("std"); +const Author = @This(); + +kind: Kind, +/// Which one: the plugin acting, and its own id for the session or connection (the agent +/// plugin's connection, chat's session). Two agents both called "Claude" are two authors. +plugin: []const u8 = "", +session: u64 = 0, +/// As the person reads it: "Claude", "the tour". Display only, never identity. +label: []const u8 = "", + +pub const Kind = enum { + /// Whoever is at the keyboard and pointer. Their steps are never stored: a step with no + /// author kept is theirs. + person, + agent, + tape, + /// A step recorded where nobody acts: a missed site, never quietly the person's. + unattributed, +}; + +pub const person: Author = .{ .kind = .person }; +pub const unattributed: Author = .{ .kind = .unattributed }; + +/// The same author: kind, plugin and session. The label is not identity. +pub fn same(a: Author, b: Author) bool { + return a.kind == b.kind and a.session == b.session and std.mem.eql(u8, a.plugin, b.plugin); +} + +test "an author is its kind, plugin and session, not its label" { + const a: Author = .{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }; + try std.testing.expect(a.same(.{ .kind = .agent, .plugin = "agent", .session = 1, .label = "An agent" })); + try std.testing.expect(!a.same(.{ .kind = .agent, .plugin = "agent", .session = 2, .label = "Claude" })); + try std.testing.expect(!a.same(person)); +} diff --git a/sdk/src/EditorAPI.zig b/sdk/src/EditorAPI.zig index ae51b60af..ed07e9559 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -36,6 +36,11 @@ pub const OpenOptions = struct { /// The pane. 0 is the one the user is in. grouping: u64 = 0, mode: OpenMode = .keep, + /// Beside the pane the person is in rather than in it, leaving them that pane and their + /// focus: a document a plugin is working on, seen as it changes while the person stays where + /// they were. An open document goes there now; one still to open, once it has loaded. + /// `grouping` is ignored. + beside: bool = false, }; /// What a context menu is about, for whoever is drawing rows into it. Valid only during that @@ -301,8 +306,8 @@ pub const VTable = struct { /// plugin can address a region it is not drawing right now: a tab dropped on another pane, /// a pane that does not exist yet, a session being restored before anything has drawn. assignSurfaces: *const fn (ctx: *anyopaque, region: []const u8, ids: ?[]const []const u8) anyerror!void, - /// The assignment for a region name, or null if the user never chose. Borrowed from the app; - /// copy before the next assignment write. + /// The assignment for a region name, or null if the user never chose. Borrowed from the app + /// until the next assignment write, which may itself be given these ids: read, edit, write. assignedSurfaces: *const fn (ctx: *anyopaque, region: []const u8) ?[]const []const u8, /// Every region name that has an assignment, arena-allocated. How a plugin finds the panes /// it declared last session before it has declared any this one. @@ -345,9 +350,6 @@ pub const VTable = struct { /// `fizzy.files.changed` can be relied on to be emitted. False with no folder open, /// on a platform with no watcher backend, or when starting one failed. folderWatchActive: *const fn (ctx: *anyopaque) bool, - /// Explorer tree branch expanded state. - explorerBranchIsOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id) bool, - setExplorerBranchOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id, open: bool) void, /// Draw workspace panes (center region); `index` is the root pane (usually 0). drawWorkspaces: *const fn (ctx: *anyopaque, index: usize) anyerror!dvui.App.Result, /// Native open-folder dialog (no-op on web). @@ -371,8 +373,6 @@ pub const VTable = struct { allocUntitledPath: *const fn (ctx: *anyopaque) anyerror![]u8, /// Create and open a new document at `path` (path ownership transfers to fizzy). createDocument: *const fn (ctx: *anyopaque, path: []const u8, grid: NewDocGrid) anyerror!DocHandle, - /// Hint the files tree to scroll/highlight a path just created (e.g. New File dialog). - setExplorerNewFilePath: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, // ---- saving ---- /// How a document's saving stands, as fizzy runs it (`SaveState`): for a tab's or a file @@ -682,14 +682,6 @@ pub fn folderWatchActive(self: EditorAPI) bool { return self.vtable.folderWatchActive(self.ctx); } -pub fn explorerBranchIsOpen(self: EditorAPI, branch_id: dvui.Id) bool { - return self.vtable.explorerBranchIsOpen(self.ctx, branch_id); -} - -pub fn setExplorerBranchOpen(self: EditorAPI, branch_id: dvui.Id, open: bool) void { - self.vtable.setExplorerBranchOpen(self.ctx, branch_id, open); -} - pub fn drawWorkspaces(self: EditorAPI, index: usize) !dvui.App.Result { return self.vtable.drawWorkspaces(self.ctx, index) catch |err| self.failed("drawWorkspaces", err); } @@ -728,10 +720,6 @@ pub fn createDocument(self: EditorAPI, path: []const u8, grid: NewDocGrid) !DocH return self.vtable.createDocument(self.ctx, path, grid) catch |err| self.failed("createDocument", err); } -pub fn setExplorerNewFilePath(self: EditorAPI, path: []const u8) !void { - return self.vtable.setExplorerNewFilePath(self.ctx, path) catch |err| self.failed("setExplorerNewFilePath", err); -} - pub fn saveState(self: EditorAPI, doc_id: u64) SaveState { return self.vtable.saveState(self.ctx, doc_id); } diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 1381723f7..6cdd7996b 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -35,6 +35,8 @@ pub const OpenAction = @import("menus.zig").OpenAction; pub const accounts = @import("accounts.zig"); pub const Command = @import("Command.zig"); pub const Claim = @import("Claim.zig"); +pub const Step = @import("Step.zig"); +pub const Author = @import("Author.zig"); pub const events = @import("events.zig"); /// Per-plugin opaque settings blobs pending a write: plugin id -> serialized zon text, or `null` @@ -277,12 +279,35 @@ activity_muted: bool = false, last_showing_ms: u32 = 0, /// The command whose body is running, if any: the edits made meanwhile are its (`documentEdited`). running_command: ?*const Command = null, +/// While above 0, documents opened or created land beside the pane the person is in +/// (`EditorAPI.OpenOptions.beside`), not in it: an agent's work opens where it will be seen, +/// rather than over the person's document and then moved. Nested scopes count; UI thread only. +open_beside: u32 = 0, /// Counts command runs, so one run that edits several times shows once. command_runs: u64 = 0, activity_run: u64 = 0, /// Documents held by a plugin while it works on them (`Claim`). claims: std.ArrayListUnmanaged(Claim) = .empty, +/// Who records documents' steps (`recordStep`): the app's tracks. Null, nobody does, and owners +/// encode nothing. +recorder: ?Recorder = null, +/// Every author that has acted, interned by identity (`Author.same`); `Acting` and the step map +/// hold indices into it. Strings are copies, so a label outlives the plugin that gave it. +authors: std.ArrayListUnmanaged(Author) = .empty, +/// Who is acting now (`act`): an index into `authors`, and the run it belongs to. Null: nobody. +acting: Acting = .{}, +/// The frame's input author, set by the app where input is consumed (`setInputAuthor`): the +/// person's, or a tape's while one drives. Null outside those places. +input_author: ?u32 = null, +/// Runs started (`act`): one per outermost `act`. +runs: u64 = 0, +/// Who made each step not the person's, per document (`recordStep`). A document gets an entry only +/// once someone other than the person acts on it, so working alone this stays empty. +step_authors: std.AutoHashMapUnmanaged(u64, DocAuthors) = .empty, +/// Steps recorded where nobody acts (`Author.Kind.unattributed`): a missed site. The integration +/// tests assert it stays 0. +unattributed_steps: u64 = 0, next_claim_id: u32 = 1, /// Pluggable language/format support (syntax highlighting, preview panes). language_support: std.ArrayListUnmanaged(LanguageSupport) = .empty, @@ -325,6 +350,14 @@ pub fn deinit(self: *Host) void { for (self.claims.items) |c| self.allocator.free(c.label); self.claims.deinit(self.allocator); + for (self.authors.items) |a| { + self.allocator.free(a.plugin); + self.allocator.free(a.label); + } + self.authors.deinit(self.allocator); + var docs = self.step_authors.valueIterator(); + while (docs.next()) |d| d.steps.deinit(self.allocator); + self.step_authors.deinit(self.allocator); self.plugins.deinit(self.allocator); self.services.deinit(self.allocator); self.selections.deinit(self.allocator); @@ -573,8 +606,8 @@ pub fn assignSurfaces(self: *Host, region_name: []const u8, ids: ?[]const []cons if (self.fizzy_api) |a| return a.assignSurfaces(region_name, ids); } -/// The assignment under `region_name`, or null when the user never chose. Borrowed; copy before -/// the next `assignSurfaces`. +/// The assignment under `region_name`, or null when the user never chose. Borrowed until the next +/// `assignSurfaces`, which may itself be given these ids: read, edit, write. pub fn assignedSurfaces(self: *Host, region_name: []const u8) ?[]const []const u8 { return if (self.fizzy_api) |a| a.assignedSurfaces(region_name) else null; } @@ -606,6 +639,14 @@ pub fn revealPosition(self: *Host, path: []const u8, line: u32, character: u32, return if (self.fizzy_api) |a| a.revealPosition(path, line, character, open_side) else false; } +/// Open documents beside the person until `endOpenBeside` (`open_beside`). +pub fn openBeside(self: *Host) void { + self.open_beside += 1; +} +pub fn endOpenBeside(self: *Host) void { + self.open_beside -|= 1; +} + /// Open a file — see `EditorAPI.OpenOptions`. Returns true when a new load started. pub fn openFile(self: *Host, opts: EditorAPI.OpenOptions) !bool { return if (self.fizzy_api) |a| try a.openFile(opts) else false; @@ -709,14 +750,6 @@ pub fn folderWatchActive(self: *Host) bool { return if (self.fizzy_api) |a| a.folderWatchActive() else false; } -pub fn explorerBranchIsOpen(self: *Host, branch_id: dvui.Id) bool { - return if (self.fizzy_api) |a| a.explorerBranchIsOpen(branch_id) else false; -} - -pub fn setExplorerBranchOpen(self: *Host, branch_id: dvui.Id, open: bool) void { - if (self.fizzy_api) |a| a.setExplorerBranchOpen(branch_id, open); -} - pub fn drawWorkspaces(self: *Host, index: usize) !dvui.App.Result { return if (self.fizzy_api) |a| try a.drawWorkspaces(index) else .ok; } @@ -771,10 +804,6 @@ pub fn createDocument(self: *Host, path: []const u8, grid: EditorAPI.NewDocGrid) return if (self.fizzy_api) |a| try a.createDocument(path, grid) else error.FizzyApiNotInstalled; } -pub fn setExplorerNewFilePath(self: *Host, path: []const u8) !void { - return if (self.fizzy_api) |a| try a.setExplorerNewFilePath(path) else error.FizzyApiNotInstalled; -} - /// How a document's saving stands (`EditorAPI.SaveState`): for an indicator. Not saving and /// never saved when fizzy isn't installed. pub fn saveState(self: *Host, doc_id: u64) EditorAPI.SaveState { @@ -1559,6 +1588,205 @@ fn claimRefusing(self: *Host, c: *const Command) ?Claim { return self.documentClaim(doc.id); } +// ---- recording ------------------------------------------------------------------------------- + +/// What keeps the steps owners report: set by the app, never by a plugin. +pub const Recorder = struct { + ctx: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + /// Whether `doc_id`'s steps are wanted. Asked before every step, so it is cheap. + wants: *const fn (ctx: *anyopaque, doc_id: u64) bool, + /// Keep `step` of `doc_id`. Its bytes are only valid for the call. + step: *const fn (ctx: *anyopaque, doc_id: u64, step: Step) void, + }; +}; + +/// Whether `doc_id`'s steps are recorded. An owner asks before it encodes one, so a document +/// nobody records costs nothing. +pub fn recording(self: *const Host, doc_id: u64) bool { + const r = self.recorder orelse return false; + return r.vtable.wants(r.ctx, doc_id); +} + +/// A step of `doc_id`'s history, from its **owner**, as it happens (`Step`): each entry added or +/// joined, undone or redone, and the history starting over. Every step, recorded or not: the host +/// keeps who made it (`stepAuthor`) and the document's top (`topStep`). An owner encodes `effect` +/// only while `recording(doc_id)`; the recorder hears the step only then. +pub fn recordStep(self: *Host, doc_id: u64, step: Step) void { + self.noteAuthor(doc_id, step); + if (self.recording(doc_id)) { + const r = self.recorder.?; + r.vtable.step(r.ctx, doc_id, step); + } +} + +// ---- authors (`plans/SHARED_HISTORY_PLAN.md`) ------------------------------------------------ + +/// Who is acting, to restore with `endAct` or to store and act as again later (`actAgain`): an +/// index into the host's authors, and the run. +pub const Acting = struct { + author: ?u32 = null, + run: u64 = 0, +}; + +/// One document's authors: who made each step that is not the person's, by step id, and the step +/// on top. +pub const DocAuthors = struct { + steps: std.AutoHashMapUnmanaged(u64, StepAuthor) = .empty, + top: u64 = 0, +}; + +pub const StepAuthor = struct { + /// Index into the host's authors. + author: u32, + /// The run it was made in (`act`): one MCP call of an agent's, one op of a tape's. + run: u64, +}; + +/// Act as `author` until `endAct` with what this returns: the steps recorded meanwhile are +/// theirs. The outermost `act` starts a run; one inside it keeps it. Innermost wins. UI thread +/// only. +pub fn act(self: *Host, who: Author) Acting { + const outer = self.acting; + const index = self.intern(who); + self.acting = .{ + .author = index, + .run = if (outer.author == null) run: { + self.runs += 1; + break :run self.runs; + } else outer.run, + }; + return outer; +} + +/// Act as before: `stored` is what `acted` said when a call was put off (pixi's call waiting out +/// the person's stroke), so its steps are still that author's, in that run. +pub fn actAgain(self: *Host, stored: Acting) Acting { + const outer = self.acting; + self.acting = stored; + return outer; +} + +/// Who is acting now, to store with work done later (`actAgain`). +pub fn acted(self: *const Host) Acting { + return self.acting; +} + +pub fn endAct(self: *Host, outer: Acting) void { + self.acting = outer; +} + +/// Who is acting now: the innermost `act`, else the frame's input author where input is being +/// consumed, else nobody known. +pub fn author(self: *const Host) Author { + const index = self.acting.author orelse self.input_author orelse return .unattributed; + return self.authors.items[index]; +} + +/// The frame's input author (the app's to set, around where input is consumed): the person's, or +/// a tape's while one drives. Null where no input is consumed. +pub fn setInputAuthor(self: *Host, who: ?Author) void { + self.input_author = if (who) |a| self.intern(a) else null; +} + +/// Who made step `id` of `doc_id`: the person when none other is kept. +pub fn stepAuthor(self: *const Host, doc_id: u64, id: u64) Author { + const doc = self.step_authors.getPtr(doc_id) orelse return .person; + const s = doc.steps.get(id) orelse return .person; + return self.authors.items[s.author]; +} + +pub const TopStep = struct { id: u64, author: Author }; + +/// The step on top of `doc_id`'s history and who made it, when the host knows it: once someone +/// other than the person has acted on the document, from the steps its owner reports. +pub fn topStep(self: *const Host, doc_id: u64) ?TopStep { + const doc = self.step_authors.getPtr(doc_id) orelse return null; + if (doc.top == 0) return null; + return .{ .id = doc.top, .author = self.stepAuthor(doc_id, doc.top) }; +} + +/// Whether the acting author may fold an edit into step `id` of `doc_id`: when `id` is theirs (a +/// step with no author kept is the person's). False ends a run of typing: the owner starts a new +/// step, so an agent's keystrokes never join the person's. +pub fn mayMerge(self: *const Host, doc_id: u64, id: u64) bool { + const who = self.author(); + const made = self.stepAuthor(doc_id, id); + // Nobody known acting is a missed site (`unattributed_steps`); it merges as the person would. + if (who.kind == .unattributed) return made.kind == .person; + return who.same(made); +} + +/// Forget `doc_id`'s authors: the document closed. +pub fn forgetDocumentAuthors(self: *Host, doc_id: u64) void { + var entry = self.step_authors.fetchRemove(doc_id) orelse return; + entry.value.steps.deinit(self.allocator); +} + +fn intern(self: *Host, who: Author) u32 { + for (self.authors.items, 0..) |*a, i| { + if (!a.same(who)) continue; + // The label may come later (an agent's name, known once its client says hello). + if (who.label.len > 0 and !std.mem.eql(u8, a.label, who.label)) { + if (self.allocator.dupe(u8, who.label)) |label| { + self.allocator.free(a.label); + a.label = label; + } else |_| {} + } + return @intCast(i); + } + const plugin = self.allocator.dupe(u8, who.plugin) catch return self.internFailed(); + const label = self.allocator.dupe(u8, who.label) catch { + self.allocator.free(plugin); + return self.internFailed(); + }; + self.authors.append(self.allocator, .{ .kind = who.kind, .plugin = plugin, .session = who.session, .label = label }) catch { + self.allocator.free(plugin); + self.allocator.free(label); + return self.internFailed(); + }; + return @intCast(self.authors.items.len - 1); +} + +/// Out of memory interning: nobody known, rather than wrongly the person. Its strings are static, +/// so `deinit` frees nothing it did not allocate: an empty slice frees as a no-op. +fn internFailed(self: *Host) u32 { + for (self.authors.items, 0..) |a, i| if (a.kind == .unattributed and a.plugin.len == 0) return @intCast(i); + self.authors.append(self.allocator, .unattributed) catch {}; + return @intCast(self.authors.items.len -| 1); +} + +/// Keep who made `step`, and `doc_id`'s top (`recordStep`). +fn noteAuthor(self: *Host, doc_id: u64, step: Step) void { + const who = self.author(); + if (who.kind == .unattributed and step.kind != .restarted) self.unattributed_steps += 1; + const theirs = who.kind != .person; + // Working alone, nothing is kept: a document gets an entry the first time someone else acts. + const doc = if (self.step_authors.getPtr(doc_id)) |d| d else if (theirs) blk: { + const gop = self.step_authors.getOrPut(self.allocator, doc_id) catch return; + gop.value_ptr.* = .{}; + break :blk gop.value_ptr; + } else return; + switch (step.kind) { + .restarted => { + doc.steps.clearRetainingCapacity(); + doc.top = 0; + }, + .added => { + if (theirs) { + const index = self.acting.author orelse self.input_author orelse self.internFailed(); + doc.steps.put(self.allocator, step.id, .{ .author = index, .run = self.acting.run }) catch {}; + } + doc.top = if (step.top != 0) step.top else step.id; + }, + // A step joined keeps the author it began with (`mayMerge` decided it could join). + .joined => doc.top = if (step.top != 0) step.top else step.id, + .undone, .redone => doc.top = step.top, + } +} + // ---- activity -------------------------------------------------------------------------------- /// A command that edited a document (`activity`). @@ -1581,7 +1809,14 @@ pub fn documentEdited(self: *Host, doc_id: u64) void { if (self.activity.getLastOrNull()) |last| { if (self.activity_run == self.command_runs and last.doc_id == doc_id) return; } - const title = self.allocator.dupe(u8, std.mem.trimEnd(u8, c.title, "…")) catch return; + const what = std.mem.trimEnd(u8, c.title, "…"); + // An agent's edit says whose ("Claude · Draw"); the person's own, and a demo narrating itself, + // stay plain. + const who = self.author(); + const title = (if (who.kind == .agent and who.label.len > 0) + std.fmt.allocPrint(self.allocator, "{s} · {s}", .{ who.label, what }) + else + self.allocator.dupe(u8, what)) catch return; if (self.activity.items.len == activity_cap) { self.allocator.free(self.activity.items[0].title); _ = self.activity.orderedRemove(0); @@ -2827,3 +3062,118 @@ test "a surface's title can change: the Host keeps the copy, and frees it with t host.unregisterPlugin(&plugin); try testing.expectEqual(@as(u32, 0), host.surface_titles.count()); } + +test "a step is its author's, the person's when nobody else acts, and the top follows the owner" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const doc: u64 = 7; + const claude: Author = .{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }; + + // Working alone: nothing kept. + host.setInputAuthor(.person); + host.recordStep(doc, .{ .kind = .added, .id = 1 }); + try std.testing.expectEqual(@as(usize, 0), host.step_authors.count()); + try std.testing.expect(host.topStep(doc) == null); + + // An agent's call: its step is its own, in one run, and on top. + const outer = host.act(claude); + host.recordStep(doc, .{ .kind = .added, .id = 2 }); + host.recordStep(doc, .{ .kind = .added, .id = 3 }); + host.endAct(outer); + try std.testing.expect(host.stepAuthor(doc, 2).same(claude)); + try std.testing.expectEqualStrings("Claude", host.stepAuthor(doc, 3).label); + try std.testing.expectEqual(host.step_authors.getPtr(doc).?.steps.get(2).?.run, host.step_authors.getPtr(doc).?.steps.get(3).?.run); + try std.testing.expectEqual(@as(u64, 3), host.topStep(doc).?.id); + + // The person's step after it is theirs, and the top. + host.recordStep(doc, .{ .kind = .added, .id = 4 }); + try std.testing.expectEqual(Author.Kind.person, host.stepAuthor(doc, 4).kind); + try std.testing.expectEqual(Author.Kind.person, host.topStep(doc).?.author.kind); + + // The person undoes theirs: the agent's is on top again, as the owner says. + host.recordStep(doc, .{ .kind = .undone, .id = 4, .top = 3 }); + try std.testing.expect(host.topStep(doc).?.author.same(claude)); + + // A history started over knows no authors. + host.recordStep(doc, .{ .kind = .restarted, .id = 0 }); + try std.testing.expectEqual(Author.Kind.person, host.stepAuthor(doc, 2).kind); + try std.testing.expect(host.topStep(doc) == null); + + host.forgetDocumentAuthors(doc); + try std.testing.expectEqual(@as(usize, 0), host.step_authors.count()); + try std.testing.expectEqual(@as(u64, 0), host.unattributed_steps); +} + +test "acting: innermost wins, a run is the outermost act, and work put off acts again" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const claude: Author = .{ .kind = .agent, .plugin = "agent", .session = 1 }; + const tour: Author = .{ .kind = .tape, .plugin = "fizzy", .session = 9, .label = "the tour" }; + + try std.testing.expectEqual(Author.Kind.unattributed, host.author().kind); + host.setInputAuthor(.person); + try std.testing.expectEqual(Author.Kind.person, host.author().kind); + + const a = host.act(claude); + const run = host.acted().run; + const b = host.act(tour); + try std.testing.expect(host.author().same(tour)); + try std.testing.expectEqual(run, host.acted().run); + host.endAct(b); + try std.testing.expect(host.author().same(claude)); + const stored = host.acted(); + host.endAct(a); + try std.testing.expectEqual(Author.Kind.person, host.author().kind); + + // A second call is a second run; the stored one acts again as itself. + const c = host.act(claude); + try std.testing.expect(host.acted().run != run); + host.endAct(c); + const d = host.actAgain(stored); + try std.testing.expectEqual(run, host.acted().run); + host.endAct(d); + + // The same author interned once; a label given later is kept. + _ = host.act(.{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }); + try std.testing.expectEqual(@as(usize, 3), host.authors.items.len); + try std.testing.expectEqualStrings("Claude", host.author().label); +} + +test "mayMerge: only the author's own step, so an agent never joins the person's run of typing" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const doc: u64 = 3; + const claude: Author = .{ .kind = .agent, .plugin = "agent", .session = 1 }; + host.setInputAuthor(.person); + host.recordStep(doc, .{ .kind = .added, .id = 1 }); + try std.testing.expect(host.mayMerge(doc, 1)); + + const outer = host.act(claude); + try std.testing.expect(!host.mayMerge(doc, 1)); + host.recordStep(doc, .{ .kind = .added, .id = 2 }); + try std.testing.expect(host.mayMerge(doc, 2)); + host.endAct(outer); + try std.testing.expect(!host.mayMerge(doc, 2)); + + // Nobody known acting is counted, and merges as the person would. + host.setInputAuthor(null); + try std.testing.expect(host.mayMerge(doc, 1)); + host.recordStep(doc, .{ .kind = .added, .id = 3 }); + try std.testing.expectEqual(@as(u64, 1), host.unattributed_steps); +} + +test "an agent's edit says whose in the activity; the person's stays plain" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const draw: Command = .{ .id = "pixi.draw", .title = "Draw" }; + host.running_command = &draw; + host.setInputAuthor(.person); + host.documentEdited(1); + try std.testing.expectEqualStrings("Draw", host.activity.items[0].title); + + host.command_runs += 1; + const outer = host.act(.{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }); + host.documentEdited(1); + host.endAct(outer); + try std.testing.expectEqualStrings("Claude · Draw", host.activity.items[1].title); +} diff --git a/sdk/src/Plugin.zig b/sdk/src/Plugin.zig index 973d5ef3e..59c501b20 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -36,6 +36,12 @@ display_name: []const u8, /// not share. internal: bool = false, +/// The encoding of the steps this plugin records and applies (`Step`, `VTable.applyStep`), which +/// it numbers: bumped when a step it writes would be misread by the build before. A tape names it +/// beside each file (`Tape.File.format`), and an app plays only the steps of formats the owner +/// reads. 0: records no steps. +step_format: u32 = 0, + /// Names an error this plugin returned. Zig numbers errors per compilation, so `@errorName` in /// the host on a dylib's error reads the host's table and prints an unrelated name. The default /// is evaluated in the binary that builds this struct — the plugin's own — so it reads the @@ -159,6 +165,13 @@ pub const VTable = struct { /// `loadDocument`, but from in-memory bytes (browser file picker). `path` is used /// for extension detection + display name. Synchronous (web has no load worker). loadDocumentFromBytes: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void = null, + /// Open `bytes` as an untitled document named `title` (`untitled-1`), as `createDocument` + /// would have made it with these contents: never saved, and dirty. `bytes` are what + /// `documentBytes` encoded for it under `bytes_path` (`bytesPath`: the name Save As offers, + /// `untitled.pixi`), which says their format. How a crash or a restart brings back a document + /// that was never saved; without it, one comes back only as far as its state restores + /// into an empty document. Synchronous. + loadUntitledDocument: ?*const fn (state: *anyopaque, title: []const u8, bytes_path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void = null, /// Size of the plugin's document type for stack/heap staging buffers (`loadDocument`, etc.). documentStackSize: ?*const fn (state: *anyopaque) usize = null, documentStackAlign: ?*const fn (state: *anyopaque) usize = null, @@ -224,6 +237,12 @@ pub const VTable = struct { /// Undo or redo `doc` until step `id` (`HistoryLine.Entry.id`) is the last one applied; 0 /// goes back before the first. An id not on the line is an error, and nothing moves. historyGoTo: ?*const fn (state: *anyopaque, doc: DocHandle, id: u64) anyerror!void = null, + /// Apply a step this owner recorded (`Step`, `Host.recordStep`) to `doc`, forward, as a new + /// entry on its history: `effect` and `motion` in the owner's `step_format`. `ms` asks for it + /// to be shown being made over that long (a replay at pace); 0 lands it at once (a seek). A + /// step that does not fit what `doc` holds is an error, and `doc` is left as it was. Absent = + /// tapes of this owner's documents cannot be played. + applyStep: ?*const fn (state: *anyopaque, doc: DocHandle, effect: []const u8, motion: []const u8, ms: u32) anyerror!void = null, isDirty: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, /// Register a loaded/created document in the plugin's open-doc map. `file` points at @@ -507,6 +526,17 @@ pub fn loadDocumentFromBytes(self: Plugin, path: []const u8, bytes: []const u8, return false; } +/// `loadUntitledDocument`. Returns whether the plugin handled it (`false` = no hook). +pub fn loadUntitledDocument(self: Plugin, title: []const u8, bytes_path: []const u8, bytes: []const u8, out_doc: *anyopaque) !bool { + const prof = core.profile.begin(self.id, "loadUntitledDocument"); + defer prof.end(); + if (self.vtable.loadUntitledDocument) |f| { + f(self.state, title, bytes_path, bytes, out_doc) catch |err| return self.failed("loadUntitledDocument", err); + return true; + } + return false; +} + pub fn isDirty(self: Plugin, doc: DocHandle) bool { const prof = core.profile.begin(self.id, "isDirty"); defer prof.end(); @@ -538,6 +568,16 @@ pub fn releaseSnapshot(self: Plugin, snapshot: *anyopaque) void { if (self.vtable.releaseSnapshot) |f| f(self.state, snapshot); } +/// The path `doc`'s bytes are encoded under (`documentBytes`), owned by `allocator`: its own, or +/// for one never saved (`untitled-1`, which names no format), the name Save As offers +/// (`untitled.pixi`), so the bytes are what a save would write. +pub fn bytesPath(self: Plugin, doc: DocHandle, allocator: std.mem.Allocator) ![]const u8 { + if (!self.documentHasRecognizedSaveExtension(doc)) { + if (self.documentDefaultSaveAsFilename(doc, allocator)) |name| return name else |_| {} + } + return allocator.dupe(u8, self.documentPath(doc)); +} + /// Both halves at once, on the calling thread: for the few who need the bytes now (a crash /// recovery file, the browser's download). Null as `documentSnapshot`. pub fn documentBytes(self: Plugin, doc: DocHandle, path: []const u8, allocator: std.mem.Allocator) !?[]u8 { @@ -579,6 +619,18 @@ pub fn historyLine(self: Plugin, doc: DocHandle, arena: std.mem.Allocator) ?Hist }; } +/// Apply a recorded step to `doc` (`applyStep`). Returns whether it landed. +pub fn applyStep(self: Plugin, doc: DocHandle, effect: []const u8, motion: []const u8, ms: u32) bool { + const prof = core.profile.begin(self.id, "applyStep"); + defer prof.end(); + const f = self.vtable.applyStep orelse return false; + f(self.state, doc, effect, motion, ms) catch |err| { + std.log.err("{s}: applyStep failed: {s}", .{ self.id, self.errorName(err) }); + return false; + }; + return true; +} + /// Go to step `id` of `doc`'s history (`historyGoTo`). Returns whether it went. pub fn historyGoTo(self: Plugin, doc: DocHandle, id: u64) bool { const f = self.vtable.historyGoTo orelse return false; diff --git a/sdk/src/Step.zig b/sdk/src/Step.zig new file mode 100644 index 000000000..5b0a7b7cd --- /dev/null +++ b/sdk/src/Step.zig @@ -0,0 +1,41 @@ +//! One step of a document's history, as its owner reports it to whoever records (`Host.recordStep`): +//! what the document became, not the input that made it, so it replays exactly at any window size +//! and in any layout (`plans/RECORDING_PLAN.md`). +//! +//! Every step is applied **forward**: a step taken back is reported as its reverse (`undone`), so a +//! replay is the effects in the order they came, and never asks an owner to run one backwards. +//! The bytes are the owner's (`Plugin.step_format` numbers its encoding), applied by its +//! `Plugin.VTable.applyStep`, and only valid for the call: whoever records copies them. +const Self = @This(); + +kind: Kind, +/// The history entry this step is, or joins, or takes back (`Plugin.HistoryLine.Entry.id`). 0 for +/// `restarted`. +id: u64, +/// What the step did, in the owner's encoding, ready to apply forward. Empty for `restarted`. +effect: []const u8 = "", +/// How it was made, when the owner knows (a pen's path and timing, in the document's own +/// coordinates). Empty: a replay shows the effect made the owner's own way. +motion: []const u8 = "", +/// How long it took to make, as the owner saw it (a stroke from press to release). 0: at once. +ms: u32 = 0, +/// The entry on top once this step is done (the last one applied), 0 when none is. The host keeps +/// each document's top from it, so Undo's label can name whose step it would take back without +/// asking for the line. An owner that does not fill it leaves it 0: the host then takes an +/// `added` or `joined` step's own `id` as the top, and knows no top after an undo or redo. +top: u64 = 0, + +pub const Kind = enum { + /// A new entry on the history. + added, + /// More of the entry on top: a keystroke joining a run of typing. `effect` is only what this + /// part did. + joined, + /// Entry `id` was taken back; `effect` is its reverse. + undone, + /// Entry `id` was put back. + redone, + /// The history started over (a reload from disk, a restore): nothing before this applies to + /// what the document is now, and whoever records takes it as it is from here. + restarted, +}; diff --git a/sdk/src/dylib.zig b/sdk/src/dylib.zig index 68b90f9f7..740d885a1 100644 --- a/sdk/src/dylib.zig +++ b/sdk/src/dylib.zig @@ -307,6 +307,22 @@ pub fn fingerprintMatches(plugin_fp: u64) bool { return plugin_fp == abi_fingerprint; } +/// The optimize class a plugin's `abi_fingerprint` was folded with, when its boundary shape is +/// this build's; null when the shape itself differs. A fingerprint is only the shape and the +/// class, so a refused plugin is one or the other, and the host can say which: a plugin built in +/// another mode, or one built against another SDK shape that kept the version number (a host +/// built from `main` after an `sdk` feature PR, before the release). +pub fn optimizeClassOf(plugin_fp: u64) ?[]const u8 { + inline for (.{ "safe", "fast" }) |class| { + const fp = comptime blk: { + @setEvalBranchQuota(1_000_000); + break :blk fingerprint.foldString(sdk_shape_fingerprint, class); + }; + if (plugin_fp == fp) return class; + } + return null; +} + pub fn tripletFromSemver(v: std.SemanticVersion) VersionTriplet { return .{ .major = @intCast(v.major), @@ -458,3 +474,10 @@ test "abi fingerprint is non-zero and self-consistent" { try std.testing.expect(fingerprintMatches(abi_fingerprint)); try std.testing.expect(!fingerprintMatches(abi_fingerprint +% 1)); } + +test "optimizeClassOf tells a mode mismatch from a shape mismatch" { + try std.testing.expectEqualStrings(optimize_safety_class, optimizeClassOf(abi_fingerprint).?); + const other = if (std.mem.eql(u8, optimize_safety_class, "safe")) "fast" else "safe"; + try std.testing.expectEqualStrings(other, optimizeClassOf(fingerprint.foldString(sdk_shape_fingerprint, other)).?); + try std.testing.expectEqual(null, optimizeClassOf(fingerprint.foldString(sdk_shape_fingerprint +% 1, other))); +} diff --git a/sdk/src/events.zig b/sdk/src/events.zig index 3bd1e270e..90d819519 100644 --- a/sdk/src/events.zig +++ b/sdk/src/events.zig @@ -73,6 +73,10 @@ pub fn parse(comptime T: type, arena: std.mem.Allocator, event: Event) !T { // `doc.owner.documentBytes(doc, path, allocator)`. // // `fizzy.files.changed`: files under the project folder changed on disk, `FilesChanged`. +// +// `fizzy.files.reveal`: a path was just made and should be shown the way a new file is: revealed +// in whatever lists the project's files, selected, its name open for editing. `Path`. Emitted by +// fizzy's `files` service (`reveal`), for a plugin's New flow or its own. /// The payload of fizzy's folder and document events. pub const Path = struct { diff --git a/sdk/src/sdk.zig b/sdk/src/sdk.zig index 2e006a3fb..71f97f854 100644 --- a/sdk/src/sdk.zig +++ b/sdk/src/sdk.zig @@ -71,6 +71,10 @@ pub const NativeMenuItem = menus.NativeMenuItem; /// A named action fizzy invokes by id, without knowing what it does. pub const Command = @import("Command.zig"); pub const Claim = @import("Claim.zig"); +/// One step of a document's history, recorded (`Host.recordStep`) and applied (`Plugin.applyStep`). +pub const Step = @import("Step.zig"); +/// Who made a change to a document (`Host.act`, `Host.stepAuthor`). +pub const Author = @import("Author.zig"); /// Events: a plugin announces, any plugin listens (`Host.emit`, `Host.subscribe`). pub const events = @import("events.zig"); pub const menu = @import("menu.zig"); diff --git a/sdk/src/services/automation.zig b/sdk/src/services/automation.zig index 0cdf45c02..f469a8caa 100644 --- a/sdk/src/services/automation.zig +++ b/sdk/src/services/automation.zig @@ -48,10 +48,12 @@ //! //! Optional, like every service: an app with nothing to play tapes on registers none. +const Author = @import("../Author.zig"); + pub const Api = struct { /// Bump whenever this struct's layout changes: `getServiceTyped` refuses a provider whose /// version differs rather than reinterpreting one shape as another across `dlopen`. - pub const service_version: u32 = 5; + pub const service_version: u32 = 6; pub const service_name = "automation"; ctx: *anyopaque, @@ -89,6 +91,8 @@ pub const Api = struct { pub const VTable = struct { play: *const fn (ctx: *anyopaque, bytes: []const u8) Play, + /// `play`, the tape acting for `author` (`Host.act`): an agent's pointer tape is the agent's. + playAs: *const fn (ctx: *anyopaque, bytes: []const u8, author: Author) Play, stop: *const fn (ctx: *anyopaque, ticket: Ticket) void, outcome: *const fn (ctx: *anyopaque, ticket: Ticket) Outcome, settled: *const fn (ctx: *anyopaque) bool, @@ -118,6 +122,12 @@ pub const Api = struct { return self.vtable.play(self.ctx, bytes); } + /// `play`, with what the tape changes made by `author` (an agent playing pointer input on the + /// person's behalf): `play` alone leaves the tape its own author. + pub fn playAs(self: Api, bytes: []const u8, author: Author) Play { + return self.vtable.playAs(self.ctx, bytes, author); + } + /// Stop the tape `ticket` names where it is. Nothing when that tape is not the one playing: /// a caller can stop its own tape, never someone else's. pub fn stop(self: Api, ticket: Ticket) void { diff --git a/sdk/src/services/files.zig b/sdk/src/services/files.zig index 896bd3a24..85f7c50c8 100644 --- a/sdk/src/services/files.zig +++ b/sdk/src/services/files.zig @@ -24,7 +24,7 @@ const std = @import("std"); pub const Api = struct { /// Bump whenever this struct's layout changes: `getServiceTyped` refuses a provider whose /// version differs rather than reinterpreting one shape as another across `dlopen`. - pub const service_version: u32 = 1; + pub const service_version: u32 = 2; pub const service_name = "files"; ctx: *anyopaque, @@ -45,6 +45,11 @@ pub const Api = struct { /// Move `path` into `target_dir`, keeping its basename. False when it is already there — /// a drop onto the folder a file is already in is a no-op, not a failure. move: *const fn (ctx: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool, + /// Show `path`, just made, the way a new file is shown: whatever lists the project's files + /// reveals it, selected, with its name open for editing (fizzy emits `fizzy.files.reveal`). + /// For a path the caller wrote itself rather than through this service, too: the listing + /// is refreshed first, so the row exists to reveal. + reveal: *const fn (ctx: *anyopaque, path: []const u8) void, }; pub fn createFile(self: Api, path: []const u8) !void { @@ -62,4 +67,7 @@ pub const Api = struct { pub fn move(self: Api, path: []const u8, target_dir: []const u8) !bool { return self.vtable.move(self.ctx, path, target_dir); } + pub fn reveal(self: Api, path: []const u8) void { + self.vtable.reveal(self.ctx, path); + } }; diff --git a/sdk/src/version.zig b/sdk/src/version.zig index d4e0a33d7..45ab6b165 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xf49851904ed579b9; +pub const recorded_sdk_shape_fingerprint: u64 = 0x0b0e62b7eac39f6d; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/sdk/tape/Sequencer.zig b/sdk/tape/Sequencer.zig index 7bfbe8e7b..48ef0dc54 100644 --- a/sdk/tape/Sequencer.zig +++ b/sdk/tape/Sequencer.zig @@ -21,6 +21,8 @@ //! path a point per `path_step_ms`, the frames live play would have drawn, so whatever it //! crosses sees the pointer pass — a text field it leaves forgets its click count, as it did //! live — and typing still arrives a keystroke at a time. +//! * **Steps land together.** A `step` is a document's own change, not input: it needs no frame +//! drawn before the next op, so a seek over an hour of drawing applies it all in one call. //! * **Waits hold time still.** A `wait` that does not hold yet stops the clock at the wait until //! it does (or its timeout passes, in wall time), so the ops after it land on an app that has //! caught up — at any speed, on any machine. @@ -88,6 +90,9 @@ pub const Sink = struct { holds: *const fn (ctx: *anyopaque, until: Tape.Until) bool, /// A wait gave up. The sink says so where a person will see it. timedOut: *const fn (ctx: *anyopaque, until: Tape.Until) void, + /// Apply a step to its file, shown being made over `ms` (the op's). Null for a sink that + /// plays only live tapes, which have none (`Tape.validate`). + step: ?*const fn (ctx: *anyopaque, step: Tape.Step, ms: u32) void = null, }; }; @@ -175,8 +180,10 @@ pub fn advance(self: *Sequencer, until: f64, wall_ms: f64, sink: Sink) Progress const next_at: f64 = if (self.cursor < ops.len) @floatFromInt(ops[self.cursor].at) else std.math.inf(f64); // Spans run up to the next op's start: anything still in flight then lands before it. if (self.advanceSpans(@min(until, next_at), sink)) |p| return p; - if (next_at > until) { - self.now = @max(self.now, until); + // Past the last op, `until` may itself be infinite: `nextAt`'s "nothing next", which a + // live driver asks for as it is. Infinity is not past infinity, so the end is its own test. + if (self.cursor >= ops.len or next_at > until) { + if (std.math.isFinite(until)) self.now = @max(self.now, until); return .reached; } self.finishSpans(sink); @@ -200,6 +207,10 @@ pub fn advance(self: *Sequencer, until: f64, wall_ms: f64, sink: Sink) Progress self.typing = .{ .op = self.cursor }; self.cursor += 1; }, + .step => |st| { + self.cursor += 1; + if (sink.vtable.step) |f| f(sink.ctx, st, op.ms); + }, .keyframe => |i| { self.cursor += 1; sink.vtable.keyframe(sink.ctx, &self.tape.keyframes[i]); @@ -429,6 +440,7 @@ const Log = struct { .keyframe = keyframe, .holds = holds, .timedOut = timedOut, + .step = step, } }; } fn from(ctx: *anyopaque) *Log { @@ -470,6 +482,9 @@ const Log = struct { fn timedOut(ctx: *anyopaque, _: Tape.Until) void { from(ctx).print("timeout", .{}); } + fn step(ctx: *anyopaque, st: Tape.Step, ms: u32) void { + from(ctx).print("step {s} {s} {d}", .{ st.file, st.effect, ms }); + } }; const test_keyframes = [_]Tape.Keyframe{ .{ .root = "demo://a" }, .{ .root = "demo://b" } }; @@ -501,6 +516,27 @@ fn playTo(seq: *Sequencer, t: f64, frame_ms: f64, sink: Sink) void { } } +test "asked for what is next with nothing left, it has reached the end" { + // A live driver asks for `nextAt`, which is infinite once the ops are spent — at once, for an + // empty tape (valid live: `Tape.validate`). + var log: Log = .{}; + defer log.deinit(); + const empty: Tape = .{ .name = "empty", .ops = &.{} }; + var seq: Sequencer = .init(&empty); + try testing.expect(seq.done()); + try testing.expectEqual(Progress.reached, seq.advance(seq.nextAt(), 16, log.sink())); + try testing.expectEqual(@as(f64, 0), seq.now); + try testing.expectEqualStrings("", log.out.items); + + const ops = [_]Tape.Op{.{ .at = 40, .do = .{ .key = "mod+s" } }}; + const one: Tape = .{ .name = "one", .ops = &ops }; + seq = .init(&one); + try testing.expectEqual(Progress.yielded, seq.advance(seq.nextAt(), 16, log.sink())); + try testing.expect(seq.done()); + try testing.expectEqual(Progress.reached, seq.advance(seq.nextAt(), 16, log.sink())); + try testing.expectEqual(@as(f64, 40), seq.now); +} + test "a click lands a frame after the glide that brought the pointer to it" { const ops = [_]Tape.Op{ .{ .at = 0, .do = .{ .keyframe = 0 } }, @@ -534,6 +570,35 @@ test "a click lands a frame after the glide that brought the pointer to it" { try testing.expect(seq.done()); } +test "steps land together, with no frame between them, each at its own time" { + const ops = [_]Tape.Op{ + .{ .at = 0, .do = .{ .keyframe = 0 } }, + .{ .at = 100, .ms = 40, .do = .{ .step = .{ .file = "a.pixi", .effect = "one" } } }, + .{ .at = 200, .do = .{ .step = .{ .file = "a.pixi", .effect = "two" } } }, + .{ .at = 300, .ms = 90, .do = .{ .step = .{ .file = "a.pixi", .effect = "three" } } }, + .{ .at = 400, .do = .{ .key = "a" } }, + }; + const tape: Tape = .{ .name = "t", .ops = &ops, .keyframes = &test_keyframes }; + var log: Log = .{}; + defer log.deinit(); + + var seq: Sequencer = .init(&tape); + try testing.expectEqual(Progress.yielded, seq.advance(0, 16, log.sink())); + // Played live, each lands when it is due. + try testing.expectEqual(Progress.reached, seq.advance(150, 16, log.sink())); + try testing.expectEqualStrings("keyframe demo://a\nstep a.pixi one 40\n", log.out.items); + // Sought past, the rest land in the one call, up to the key that needs a frame of its own. + try testing.expectEqual(Progress.yielded, seq.advance(1000, 16, log.sink())); + try testing.expectEqualStrings( + \\keyframe demo://a + \\step a.pixi one 40 + \\step a.pixi two 0 + \\step a.pixi three 90 + \\key a + \\ + , log.out.items); +} + test "a glide covered in one call still passes along its path, a frame's worth at a time" { const ops = [_]Tape.Op{ .{ .at = 0, .do = .{ .keyframe = 0 } }, diff --git a/sdk/tape/Tape.zig b/sdk/tape/Tape.zig index 360251170..634b96c2f 100644 --- a/sdk/tape/Tape.zig +++ b/sdk/tape/Tape.zig @@ -19,6 +19,12 @@ //! app's own spelling (fizzy's: `"mod+shift+p"`, `"enter"`); what a spelling means, and whether //! one is right, is the app's to say (`Check`), so the format carries no keymap of its own. //! +//! **Steps** carry a document's own history rather than input: the effect of one change (the +//! pixels a stroke set, the text a transaction inserted) in its owner's encoding, applied to a +//! file the keyframe declared. Input replays only in a window laid out as it was recorded; an +//! effect replays exactly anywhere. What a step's bytes mean is the owner's, versioned by the file's +//! `format`, and whether the app can apply them is the app's to say (`Check.steps`). +//! //! Everything here is plain data with no pointers into anything but its own strings, so a tape //! round-trips through ZON (`parse`, `write`): the same thing a script builds (`Script`) is what //! a recorder would write and what a web page can fetch and play. @@ -89,6 +95,10 @@ pub const Action = union(enum) { /// Hold demo time until a condition holds. Waits are how a tape stays deterministic over an /// app that does things on its own time (loading a file, animating a pane open). wait: Wait, + /// Apply one step of a document's history to a file of the keyframe before it, shown being + /// made over `Op.ms` (the time it took, as recorded). Lands with no frame drawn between it and + /// the next: an effect needs no layout to land on, so a seek applies thousands a frame. + step: Step, /// Where a `move` or `trace` takes the pointer. pub fn target(self: Action) ?Target { @@ -113,6 +123,20 @@ pub const Command = struct { args: []const u8 = "", }; +/// One step of a document's history, as its owner encoded it. +pub const Step = struct { + /// The file it changes, as the keyframe before it names it (`File.path`). The file names its + /// owner, whose step it is. + file: []const u8, + /// What the step did to the document, in the owner's encoding (`File.format`): exact, so it + /// lands the same at any window size, in any layout. + effect: []const u8, + /// How it was made, when the owner recorded it (a pen's path and timing, in the document's + /// own coordinates), so a replay shows it made as it was. Empty: the owner shows the effect + /// made its own way. + motion: []const u8 = "", +}; + pub const Scroll = struct { x: f32 = 0, y: f32 = 0, @@ -194,6 +218,12 @@ pub const Keyframe = struct { /// put in place at the cut whatever the viewer has chosen, and given back when the demo ends. settings: []const Setting = &.{}, + /// The file at `path`, or null when it has none. + pub fn file(self: Keyframe, path: []const u8) ?*const File { + for (self.files) |*f| if (std.mem.eql(u8, f.path, path)) return f; + return null; + } + /// How the window is arranged at the cut. The stage's to interpret, like the rest. pub const Layout = enum { /// As it is. @@ -220,7 +250,13 @@ pub const Setting = struct { pub const File = struct { /// Relative to the keyframe's `root`: `src/main.zig`. path: []const u8, + /// Its bytes, as on disk: text, or a binary document's (a `.pixi`), which ZON escapes. text: []const u8, + /// The plugin that opens it and applies its steps (`pixi`). Empty: whichever the app would + /// open it with, and the file takes no steps. + owner: []const u8 = "", + /// The owner's encoding of its steps (`Step.effect`), which the owner numbers. + format: u32 = 0, }; // ---- reading ------------------------------------------------------------------------------- @@ -356,7 +392,7 @@ pub fn pointerShown(self: Tape, applied: usize, t: f64, fade_ms: f64) f32 { .move, .trace, .press, .release, .scroll => .pointer, // A keyframe is a fresh start. .keyframe => break, - .wait => continue, + .wait, .step => continue, }; const at: f64 = @floatFromInt(op.at); if (moved_at) |m| { @@ -386,6 +422,10 @@ pub const Error = error{ ChaptersOutOfOrder, /// A live tape (`Check.live`) plays on the app as it is, so it never cuts to a keyframe. KeyframeInLiveTape, + /// A step names no file of the keyframe before it, or one with no owner to apply it. + StepWithoutFile, + /// The app cannot apply a file's steps: no `Check.steps`, or it said no to the owner's format. + StepsUnsupported, }; /// What only the app can say about a tape: whether a key chord is spelled right, in its spelling. @@ -396,6 +436,10 @@ pub const Check = struct { /// test, a macro, an automation client — rather than a demo that cuts to the state it /// declares. It has no keyframe anywhere, where a demo opens on one. live: bool = false, + /// Whether the app applies `owner`'s steps written in `format`. Left out, a tape with steps + /// is refused: unlike a misspelled chord, which shows when it is pressed, a step nobody + /// applies plays a document that never changes and looks like a tape that works. + steps: ?*const fn (owner: []const u8, format: u32) bool = null, }; /// Check what `Sequencer` relies on, once, at load — a tape that fails here would misplay rather @@ -403,6 +447,8 @@ pub const Check = struct { pub fn validate(self: Tape, check: Check) Error!void { if (!check.live and (self.ops.len == 0 or self.ops[0].at != 0 or self.ops[0].do != .keyframe)) return error.NoKeyframeAtStart; var prev: u32 = 0; + // The keyframe the ops are under: a step's file is one of its. + var scene: ?*const Keyframe = null; for (self.ops) |op| { if (op.at < prev) return error.OpsOutOfOrder; prev = op.at; @@ -410,6 +456,13 @@ pub fn validate(self: Tape, check: Check) Error!void { .keyframe => |i| { if (check.live) return error.KeyframeInLiveTape; if (i >= self.keyframes.len) return error.BadKeyframeIndex; + scene = &self.keyframes[i]; + }, + .step => |st| { + const f = (scene orelse return error.StepWithoutFile).file(st.file) orelse return error.StepWithoutFile; + if (f.owner.len == 0) return error.StepWithoutFile; + const ok = check.steps orelse return error.StepsUnsupported; + if (!ok(f.owner, f.format)) return error.StepsUnsupported; }, .key => |k| if (check.key) |ok| { if (!ok(k)) return error.BadChord; @@ -672,3 +725,47 @@ test "a hand-written tape parses with every default left out" { try testing.expect(owned.tape.ops[2].do.wait.until == .idle); try testing.expectEqual(@as(u32, 10_000), owned.tape.ops[2].do.wait.timeout); } + +/// An app that applies `pixi`'s steps in its format 1, and nobody else's. +pub const test_steps: Check = .{ .steps = struct { + fn ok(owner: []const u8, format: u32) bool { + return std.mem.eql(u8, owner, "pixi") and format == 1; + } +}.ok }; + +test "a step changes a file of the keyframe before it, whose owner the app can apply" { + const sprite: [5]u8 = .{ 0x89, 'P', 0, 0xff, '\n' }; + const files = [_]File{ .{ .path = "a.pixi", .text = &sprite, .owner = "pixi", .format = 1 }, .{ .path = "notes.md", .text = "# hi" } }; + const kf = [_]Keyframe{ .{ .root = "demo://s", .files = &files, .open = &.{"a.pixi"} }, .{ .root = "demo://s" } }; + const ops = [_]Op{ + .{ .at = 0, .do = .{ .keyframe = 0 } }, + .{ .at = 100, .ms = 250, .do = .{ .step = .{ .file = "a.pixi", .effect = "\x01\x02", .motion = "\x00" } } }, + .{ .at = 400, .do = .{ .step = .{ .file = "a.pixi", .effect = "\x03" } } }, + }; + const tape: Tape = .{ .name = "s", .ops = &ops, .keyframes = &kf }; + try tape.validate(test_steps); + // An app that says nothing of steps, or no to this owner's format, refuses the tape. + try testing.expectError(error.StepsUnsupported, tape.validate(.{})); + const other_format = [_]File{.{ .path = "a.pixi", .text = &sprite, .owner = "pixi", .format = 2 }}; + try testing.expectError(error.StepsUnsupported, (Tape{ .name = "s", .ops = &ops, .keyframes = &.{.{ .root = "demo://s", .files = &other_format }} }).validate(test_steps)); + // A file that names its owner and takes no steps needs nothing of the app. + try (Tape{ .name = "s", .ops = ops[0..1], .keyframes = &.{.{ .root = "demo://s", .files = &other_format }} }).validate(.{}); + + // Its file must be the keyframe's, and one with an owner to apply it. + const elsewhere = [_]Op{ ops[0], .{ .at = 50, .do = .{ .keyframe = 1 } }, ops[1] }; + try testing.expectError(error.StepWithoutFile, (Tape{ .name = "s", .ops = &elsewhere, .keyframes = &kf }).validate(test_steps)); + const ownerless = [_]Op{ ops[0], .{ .at = 50, .do = .{ .step = .{ .file = "notes.md", .effect = "x" } } } }; + try testing.expectError(error.StepWithoutFile, (Tape{ .name = "s", .ops = &ownerless, .keyframes = &kf }).validate(test_steps)); + // A live tape has no keyframe, so nothing to step. + try testing.expectError(error.StepWithoutFile, (Tape{ .name = "s", .ops = ops[1..] }).validate(.{ .live = true, .steps = test_steps.steps })); + + // Through ZON, the binary file and the steps come back byte for byte. + var out: std.Io.Writer.Allocating = .init(testing.allocator); + defer out.deinit(); + try tape.write(&out.writer); + const source = try testing.allocator.dupeSentinel(u8, out.written(), 0); + defer testing.allocator.free(source); + var back = try parse(testing.allocator, source, test_steps); + defer back.deinit(); + try testing.expectEqualDeep(tape, back.tape); +} diff --git a/sdk/tape/binary.zig b/sdk/tape/binary.zig index d721abad1..fff8e7ada 100644 --- a/sdk/tape/binary.zig +++ b/sdk/tape/binary.zig @@ -10,6 +10,9 @@ //! how many of everything follow; //! * the string table: each distinct string once — every anchor, every typed text, every file — //! as `count + 1` offsets into the bytes that follow them; +//! * the blob table, the same shape: each step's effect and motion, in order and never interned +//! (an effect almost never repeats, and hashing every byte of an hour of them to find out +//! would cost more than it saves); //! * the ops, one fixed-size record each (`op_size` bytes): time, length, kind, and the kind's //! payload as string indices and numbers. Fixed size is what lets a reader index op `i`, or //! binary-search them by time, without decoding the ones before it; @@ -21,15 +24,22 @@ //! slice of that one copy, so there is one allocation for the bytes and one per list, never one //! per string. A truncated or tampered file is an error, never a crash or a read out of bounds. //! +//! **Deflated** (`Options.deflate`, the header's flag `flag_deflated`): after the magic, version +//! and flags come the length of what would otherwise follow them, then one zlib stream of it. For +//! a recording that is shared — its effects are mostly runs of the same few bytes. Reading +//! inflates it into the one copy, exactly that long, then reads as ever. +//! //! **Versions.** `version` is bumped for any change a reader of the old one would misread; a //! reader refuses a version it does not know (`error.UnsupportedVersion`) rather than guess, and //! reads every older one it does. 2 gave a `command` op its arguments, in the record's second -//! string; a version-1 command has none. +//! string; a version-1 command has none. 3 added steps, files' owner and format, the blob table +//! and the deflated form. const std = @import("std"); +const flate = std.compress.flate; const Tape = @import("Tape.zig"); pub const magic = "FZTP".*; -pub const version: u16 = 2; +pub const version: u16 = 3; /// The oldest version `read` still reads. pub const oldest_version: u16 = 1; @@ -52,14 +62,32 @@ pub const Header = struct { captions: u32, clear: u32, chapters: u32, + // Version 3 on. + blobs: u32 = 0, + blob_bytes: u32 = 0, + + /// How many of its fields a tape of version `v` has on disk. + fn fields(v: u16) usize { + return if (v >= 3) std.meta.fieldNames(Header).len else 13; + } }; -/// The header's size on disk: magic, version, flags, then `Header`'s fields. -pub const header_size = 4 + 2 + 2 + 13 * 4; +/// What comes before the header: magic, version, flags. +const prefix_size = 4 + 2 + 2; + +/// The header's flags. +pub const flag_deflated: u16 = 1 << 0; +const known_flags = flag_deflated; +/// The most a deflated tape inflates to: past it, the stream is taken for corrupt rather than +/// allocated (a few bytes of tampered stream can claim gigabytes). +pub const max_inflated = 256 << 20; /// The size of each record on disk. pub const op_size = 36; const keyframe_size = 32; -const file_size = 8; +/// A file's record: path and text, then from version 3 its owner and format. +fn fileSize(v: u16) usize { + return if (v >= 3) 16 else 8; +} const open_size = 4; const setting_size = 12; const caption_size = 32; @@ -67,16 +95,50 @@ const clear_size = 4; const chapter_size = 8; /// An op's kind on disk. Appended to, never reordered. -const Kind = enum(u8) { keyframe, move, press, release, scroll, key, type, command, wait, trace }; +const Kind = enum(u8) { keyframe, move, press, release, scroll, key, type, command, wait, trace, step }; /// A wait's condition on disk. const UntilKind = enum(u8) { idle, shown, gone }; // ---- writing ------------------------------------------------------------------------------- -/// Write `tape` in the binary form. `gpa` is for the string table while it is built. -pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { +pub const Options = struct { + /// One zlib stream after the header's flags: smaller, for a tape that is sent somewhere, and + /// slower to write and read (`zig build bench-tape`). + deflate: bool = false, +}; + +/// Write `tape` in the binary form. `gpa` is for the string table while it is built, and for the +/// uncompressed form while it is deflated. +pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer, opts: Options) !void { + try w.writeAll(&magic); + try w.writeInt(u16, version, .little); + try w.writeInt(u16, if (opts.deflate) flag_deflated else 0, .little); + if (!opts.deflate) return writeBody(gpa, tape, w); + + var body: std.Io.Writer.Allocating = .init(gpa); + defer body.deinit(); + try writeBody(gpa, tape, &body.writer); + // The compressor wants room to write into of its own, whatever `w` has. + var out: std.Io.Writer.Allocating = try .initCapacity(gpa, 64); + defer out.deinit(); + const window = try gpa.alloc(u8, flate.max_window_len); + defer gpa.free(window); + const c = try gpa.create(flate.Compress); + defer gpa.destroy(c); + c.* = try .init(&out.writer, window, .zlib, .default); + try c.writer.writeAll(body.written()); + try c.finish(); + try w.writeInt(u32, count(body.written().len), .little); + try w.writeAll(out.written()); +} + +/// Everything after the flags. +fn writeBody(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { var table: Strings = .{}; defer table.deinit(gpa); + var blobs: std.ArrayList([]const u8) = .empty; + defer blobs.deinit(gpa); + var blob_bytes: usize = 0; // Every string, interned, in the order first met — so the table is deterministic. const name = try table.intern(gpa, tape.name); @@ -93,6 +155,11 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { .shown, .gone => |s| _ = try table.intern(gpa, s), .idle => {}, }, + .step => |st| { + _ = try table.intern(gpa, st.file); + try blobs.appendSlice(gpa, &.{ st.effect, st.motion }); + blob_bytes += st.effect.len + st.motion.len; + }, .keyframe, .press, .release, .scroll => {}, }; var n_files: u32 = 0; @@ -103,6 +170,7 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { for (kf.files) |f| { _ = try table.intern(gpa, f.path); _ = try table.intern(gpa, f.text); + _ = try table.intern(gpa, f.owner); } for (kf.open) |o| _ = try table.intern(gpa, o); for (kf.settings) |s| { @@ -138,27 +206,23 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { .captions = count(tape.captions.len), .clear = n_clear, .chapters = count(tape.chapters.len), + .blobs = count(blobs.items.len), + .blob_bytes = count(blob_bytes), }; - try w.writeAll(&magic); - try w.writeInt(u16, version, .little); - try w.writeInt(u16, 0, .little); inline for (@typeInfo(Header).@"struct".field_names, @typeInfo(Header).@"struct".field_types, @typeInfo(Header).@"struct".field_attrs) |f_name, _, _| try w.writeInt(u32, @field(h, f_name), .little); - var at: u32 = 0; - for (table.list.items) |s| { - try w.writeInt(u32, at, .little); - at += count(s.len); - } - try w.writeInt(u32, at, .little); - for (table.list.items) |s| try w.writeAll(s); - try w.splatByteAll(0, pad4(table.bytes)); + try writeTable(w, table.list.items, table.bytes); + try writeTable(w, blobs.items, blob_bytes); + // The next blob of each step's, in the order the steps were met above. + var blob: u32 = 0; for (tape.ops) |op| { var kind: Kind = undefined; var small: u8 = 0; var a: u32 = 0; var b: u32 = 0; - var xy: [4]f32 = @splat(0); + // The record's last four words: a pointer's floats, bit for bit, or a step's motion. + var xy: [4]u32 = @splat(0); switch (op.do) { .keyframe => |i| { kind = .keyframe; @@ -167,7 +231,7 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { .move, .trace => |m| { kind = if (op.do == .move) .move else .trace; a = table.index(m.tag); - xy = .{ m.x, m.y, m.dx, m.dy }; + xy = .{ @bitCast(m.x), @bitCast(m.y), @bitCast(m.dx), @bitCast(m.dy) }; }, .press, .release => |btn| { kind = if (op.do == .press) .press else .release; @@ -175,7 +239,7 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { }, .scroll => |s| { kind = .scroll; - xy = .{ s.x, s.y, 0, 0 }; + xy = .{ @bitCast(s.x), @bitCast(s.y), 0, 0 }; }, .key => |s| { kind = .key; @@ -190,6 +254,13 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { a = table.index(c.id); b = table.index(c.args); }, + .step => |st| { + kind = .step; + a = table.index(st.file); + b = blob; + xy[0] = blob + 1; + blob += 2; + }, .wait => |wt| { kind = .wait; b = wt.timeout; @@ -213,7 +284,7 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { try w.writeInt(u16, 0, .little); try w.writeInt(u32, a, .little); try w.writeInt(u32, b, .little); - for (xy) |v| try w.writeInt(u32, @bitCast(v), .little); + for (xy) |v| try w.writeInt(u32, v, .little); } var files_at: u32 = 0; @@ -230,8 +301,8 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { settings_at += count(kf.settings.len); } for (tape.keyframes) |kf| for (kf.files) |f| { - try w.writeInt(u32, table.index(f.path), .little); - try w.writeInt(u32, table.index(f.text), .little); + for ([_]u32{ table.index(f.path), table.index(f.text), table.index(f.owner), f.format }) |v| + try w.writeInt(u32, v, .little); }; for (tape.keyframes) |kf| for (kf.open) |o| try w.writeInt(u32, table.index(o), .little); for (tape.keyframes) |kf| for (kf.settings) |s| { @@ -256,11 +327,23 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { } } +/// A table of `list` as `count + 1` offsets into their `bytes` bytes, then the bytes, padded. +fn writeTable(w: *std.Io.Writer, list: []const []const u8, bytes: usize) !void { + var at: u32 = 0; + for (list) |s| { + try w.writeInt(u32, at, .little); + at += count(s.len); + } + try w.writeInt(u32, at, .little); + for (list) |s| try w.writeAll(s); + try w.splatByteAll(0, pad4(bytes)); +} + /// `write` into a new allocation. -pub fn encode(gpa: std.mem.Allocator, tape: Tape) ![]u8 { +pub fn encode(gpa: std.mem.Allocator, tape: Tape, opts: Options) ![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); - write(gpa, tape, &out.writer) catch |err| switch (err) { + write(gpa, tape, &out.writer, opts) catch |err| switch (err) { error.WriteFailed => return error.OutOfMemory, else => |e| return e, }; @@ -324,25 +407,47 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr var arena: std.heap.ArenaAllocator = .init(gpa); errdefer arena.deinit(); const a = arena.allocator(); - const own = try a.dupe(u8, bytes); - var r: Reader = .{ .bytes = own }; - _ = try r.take(magic.len); - const tape_version = try r.int(u16); + var pre: Reader = .{ .bytes = bytes }; + _ = try pre.take(magic.len); + const tape_version = try pre.int(u16); if (tape_version < oldest_version or tape_version > version) return error.UnsupportedVersion; - _ = try r.int(u16); // flags: none yet - var h: Header = undefined; - inline for (@typeInfo(Header).@"struct".field_names, @typeInfo(Header).@"struct".field_types, @typeInfo(Header).@"struct".field_attrs) |f_name, _, _| @field(h, f_name) = try r.int(u32); + const flags = try pre.int(u16); + if (flags & ~known_flags != 0) return error.Corrupt; + // The one copy every string and blob of the tape is a slice of. + const own = if (flags & flag_deflated == 0) try a.dupe(u8, bytes[prefix_size..]) else blk: { + if (tape_version < 3) return error.Corrupt; + const len = try pre.int(u32); + if (len > max_inflated) return error.Corrupt; + var in_buffer: [4096]u8 = undefined; + var in: Endless = .init(bytes[prefix_size + 4 ..], &in_buffer); + const window = try gpa.alloc(u8, flate.max_window_len); + defer gpa.free(window); + var d: flate.Decompress = .init(&in.reader, .zlib, window); + // One byte more than it should come to, so a stream that runs on is caught; a stream cut + // short runs on into the zeros, or stops early, or fails its checksum. + const inflated = d.reader.allocRemaining(a, .limited(@as(usize, len) + 1)) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.ReadFailed, error.StreamTooLong => error.Corrupt, + }; + if (inflated.len != len or d.state != .end) return error.Corrupt; + // std's inflate reads the stream's checksum and leaves it unchecked. + if (std.hash.Adler32.hash(inflated) != d.container_metadata.zlib.adler) return error.Corrupt; + break :blk inflated; + }; + var r: Reader = .{ .bytes = own }; + + var h: Header = std.mem.zeroes(Header); + inline for (@typeInfo(Header).@"struct".field_names, 0..) |f_name, i| { + if (i < Header.fields(tape_version)) @field(h, f_name) = try r.int(u32); + } - // The string table: offsets, then the bytes they index. - const offsets = try r.take(try sizeOf(@as(usize, h.strings) + 1, 4)); - const string_bytes = try r.take(h.string_bytes); - try r.align4(); - const strings: Strings2 = .{ .offsets = offsets, .bytes = string_bytes, .count = h.strings }; - try strings.check(); + const strings = try Table.take(&r, h.strings, h.string_bytes); + const blobs: Table = if (tape_version >= 3) try Table.take(&r, h.blobs, h.blob_bytes) else .empty; const op_bytes = try r.take(try sizeOf(h.ops, op_size)); const kf_bytes = try r.take(try sizeOf(h.keyframes, keyframe_size)); + const file_size = fileSize(tape_version); const file_bytes = try r.take(try sizeOf(h.files, file_size)); const open_bytes = try r.take(try sizeOf(h.open, open_size)); const setting_bytes = try r.take(try sizeOf(h.settings, setting_size)); @@ -360,8 +465,9 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr _ = try f.int(u16); const ia = try f.int(u32); const ib = try f.int(u32); - var xy: [4]f32 = undefined; - for (&xy) |*v| v.* = @bitCast(try f.int(u32)); + var words: [4]u32 = undefined; + for (&words) |*v| v.* = try f.int(u32); + const xy: [4]f32 = @bitCast(words); op.* = .{ .at = at, .ms = ms, .do = switch (kind) { .keyframe => .{ .keyframe = std.math.cast(u16, ia) orelse return error.Corrupt }, .move => .{ .move = .{ .tag = try strings.get(ia), .x = xy[0], .y = xy[1], .dx = xy[2], .dy = xy[3] } }, @@ -380,6 +486,7 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr .shown => .{ .shown = try strings.get(ia) }, .gone => .{ .gone = try strings.get(ia) }, } } }, + .step => .{ .step = .{ .file = try strings.get(ia), .effect = try blobs.get(ib), .motion = try blobs.get(words[0]) } }, } }; } @@ -387,6 +494,10 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr for (files, 0..) |*file, i| { var f: Reader = .{ .bytes = file_bytes[i * file_size ..][0..file_size] }; file.* = .{ .path = try strings.get(try f.int(u32)), .text = try strings.get(try f.int(u32)) }; + if (tape_version >= 3) { + file.owner = try strings.get(try f.int(u32)); + file.format = try f.int(u32); + } } const open = try a.alloc([]const u8, h.open); for (open, 0..) |*o, i| { @@ -457,6 +568,32 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr return .{ .arena = arena, .tape = tape }; } +/// A deflated stream's bytes, then zeros without end. std's inflate (0.16) misreads a stream that +/// ends inside its last four bytes (`tossBitsShort` adds the bits it has used where it should take +/// them away) and asserts past the end of its input — a crash on a tape cut short. Never letting +/// its input end keeps it off that path; the inflated length, known from the header, bounds what a +/// stream cut short can run on into. ZIG017: fixed in 0.17's inflate, so this goes with the move; +/// 0.17 still leaves the checksum unchecked, so `read`'s Adler-32 check stays. +const Endless = struct { + rest: []const u8, + reader: std.Io.Reader, + + fn init(bytes: []const u8, buffer: []u8) Endless { + return .{ .rest = bytes, .reader = .{ .vtable = &.{ .stream = stream }, .buffer = buffer, .seek = 0, .end = 0 } }; + } + + fn stream(r: *std.Io.Reader, w: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize { + const self: *Endless = @alignCast(@fieldParentPtr("reader", r)); + const dest = limit.slice(try w.writableSliceGreedy(1)); + const n = @min(dest.len, self.rest.len); + @memcpy(dest[0..n], self.rest[0..n]); + @memset(dest[n..], 0); + self.rest = self.rest[n..]; + w.advance(dest.len); + return dest.len; + } +}; + /// `n` records of `size` bytes, or `error.Corrupt` when that is more than a tape could hold. fn sizeOf(n: usize, size: usize) ReadError!usize { return std.math.mul(usize, n, size) catch error.Corrupt; @@ -492,18 +629,31 @@ const Reader = struct { } }; -/// The string table as read: offsets into `bytes`, checked once, then indexed. -const Strings2 = struct { +/// A table as read — the strings, or the blobs: offsets into `bytes`, checked once, then indexed. +const Table = struct { offsets: []const u8, bytes: []const u8, count: u32, - fn offset(self: Strings2, i: usize) u32 { + /// No entries: what a tape from before version 3 has of blobs. + const empty: Table = .{ .offsets = &.{ 0, 0, 0, 0 }, .bytes = "", .count = 0 }; + + /// The table at `r`: `n + 1` offsets, then `len` bytes and their padding. + fn take(r: *Reader, n: u32, len: u32) ReadError!Table { + const offsets = try r.take(try sizeOf(@as(usize, n) + 1, 4)); + const bytes = try r.take(len); + try r.align4(); + const t: Table = .{ .offsets = offsets, .bytes = bytes, .count = n }; + try t.check(); + return t; + } + + fn offset(self: Table, i: usize) u32 { return std.mem.readInt(u32, self.offsets[i * 4 ..][0..4], .little); } /// Offsets start at 0, never go backwards, and end at the end of the bytes. - fn check(self: Strings2) ReadError!void { + fn check(self: Table) ReadError!void { var prev: u32 = 0; if (self.offset(0) != 0) return error.Corrupt; for (1..@as(usize, self.count) + 1) |i| { @@ -515,7 +665,7 @@ const Strings2 = struct { } /// String `i`. Empty, it is the empty string a tape's defaults are, as `slice`'s lists. - fn get(self: Strings2, i: u32) ReadError![]const u8 { + fn get(self: Table, i: u32) ReadError![]const u8 { if (i >= self.count) return error.Corrupt; const lo = self.offset(i); const hi = self.offset(@as(usize, i) + 1); @@ -528,47 +678,56 @@ const Strings2 = struct { const testing = std.testing; -/// A tape with something of everything in it. -fn everything() Tape { - const S = struct { - const files = [_]Tape.File{ .{ .path = "a.md", .text = "# A\n" }, .{ .path = "b.md", .text = "" } }; - const settings = [_]Tape.Setting{.{ .owner = "markdown", .key = "default_md_view", .value = ".split" }}; - const keyframes = [_]Tape.Keyframe{ - .{ .root = "demo://t", .files = &files, .open = &.{ "a.md", "b.md" }, .settings = &settings, .layout = .reset }, - .{ .root = "demo://t" }, - }; - const ops = [_]Tape.Op{ - .{ .at = 0, .do = .{ .keyframe = 0 } }, - .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }, - .{ .at = 0, .do = .{ .wait = .{ .until = .{ .shown = "text.end:demo://t/a.md" }, .timeout = 2500 } } }, - .{ .at = 100, .ms = 400, .do = .{ .move = .{ .tag = "text.end:demo://t/a.md", .x = 0.25, .y = 0.75, .dx = -3.5, .dy = 8 } } }, - .{ .at = 500, .do = .{ .press = .right } }, - .{ .at = 560, .do = .{ .release = .right } }, - .{ .at = 600, .do = .{ .scroll = .{ .x = 1, .y = -3 } } }, - .{ .at = 700, .ms = 300, .do = .{ .type = "héllo\n\tworld" } }, - .{ .at = 1100, .do = .{ .key = "mod+k mod+c" } }, - .{ .at = 1200, .do = .{ .command = .{ .id = "fizzy.toggleExplorer" } } }, - .{ .at = 1250, .do = .{ .command = .{ .id = "text.goToLine", .args = ".{ .line = 12, .column = 3 }" } } }, - .{ .at = 1300, .do = .{ .wait = .{ .until = .{ .gone = "fizzy.palette" } } } }, - .{ .at = 1400, .do = .{ .keyframe = 1 } }, - .{ .at = 1500, .ms = 200, .do = .{ .move = .{} } }, - .{ .at = 1700, .ms = 80, .do = .{ .trace = .{ .tag = "pixi.canvas:demo://t/a.pixi", .x = 0.4, .y = 0.6 } } }, - }; - const captions = [_]Tape.Caption{ - .{ .at = 0, .ms = 900, .title = "Hi", .text = "Welcome.", .place = .middle }, - .{ .at = 900, .ms = 600, .text = "Beside it.", .near = "text.end:demo://t/a.md", .clear = &.{ "text.body:demo://t/a.md", "text.preview:demo://t/a.md" } }, - }; - const chapters = [_]Tape.Chapter{ .{ .at = 0, .title = "One" }, .{ .at = 1400, .title = "Two" } }; +const Everything = struct { + const files = [_]Tape.File{ .{ .path = "a.md", .text = "# A\n" }, .{ .path = "b.md", .text = "" } }; + const settings = [_]Tape.Setting{.{ .owner = "markdown", .key = "default_md_view", .value = ".split" }}; + const keyframe = Tape.Keyframe{ .root = "demo://t", .files = &files, .open = &.{ "a.md", "b.md" }, .settings = &settings, .layout = .reset }; + /// A binary document, by its owner, and its steps. + const sprite = [_]Tape.File{.{ .path = "a.pixi", .text = &.{ 0x89, 'P', 0, 0xff, '\n', 0 }, .owner = "pixi", .format = 1 }}; + const ops_v2 = [_]Tape.Op{ + .{ .at = 0, .do = .{ .keyframe = 0 } }, + .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }, + .{ .at = 0, .do = .{ .wait = .{ .until = .{ .shown = "text.end:demo://t/a.md" }, .timeout = 2500 } } }, + .{ .at = 100, .ms = 400, .do = .{ .move = .{ .tag = "text.end:demo://t/a.md", .x = 0.25, .y = 0.75, .dx = -3.5, .dy = 8 } } }, + .{ .at = 500, .do = .{ .press = .right } }, + .{ .at = 560, .do = .{ .release = .right } }, + .{ .at = 600, .do = .{ .scroll = .{ .x = 1, .y = -3 } } }, + .{ .at = 700, .ms = 300, .do = .{ .type = "héllo\n\tworld" } }, + .{ .at = 1100, .do = .{ .key = "mod+k mod+c" } }, + .{ .at = 1200, .do = .{ .command = .{ .id = "fizzy.toggleExplorer" } } }, + .{ .at = 1250, .do = .{ .command = .{ .id = "text.goToLine", .args = ".{ .line = 12, .column = 3 }" } } }, + .{ .at = 1300, .do = .{ .wait = .{ .until = .{ .gone = "fizzy.palette" } } } }, + .{ .at = 1400, .do = .{ .keyframe = 1 } }, + .{ .at = 1500, .ms = 200, .do = .{ .move = .{} } }, + .{ .at = 1700, .ms = 80, .do = .{ .trace = .{ .tag = "pixi.canvas:demo://t/a.pixi", .x = 0.4, .y = 0.6 } } }, }; - return .{ - .name = "t", - .title = "Everything", - .home = "region:Main", - .ops = &S.ops, - .keyframes = &S.keyframes, - .captions = &S.captions, - .chapters = &S.chapters, + const ops = ops_v2 ++ [_]Tape.Op{ + .{ .at = 1800, .ms = 250, .do = .{ .step = .{ .file = "a.pixi", .effect = "\x00\x01\xff", .motion = "\x02\x00" } } }, + // The same effect again is stored again; one with no motion stores an empty one. + .{ .at = 2100, .do = .{ .step = .{ .file = "a.pixi", .effect = "\x00\x01\xff" } } }, }; + const captions = [_]Tape.Caption{ + .{ .at = 0, .ms = 900, .title = "Hi", .text = "Welcome.", .place = .middle }, + .{ .at = 900, .ms = 600, .text = "Beside it.", .near = "text.end:demo://t/a.md", .clear = &.{ "text.body:demo://t/a.md", "text.preview:demo://t/a.md" } }, + }; + const chapters = [_]Tape.Chapter{ .{ .at = 0, .title = "One" }, .{ .at = 1400, .title = "Two" } }; + + fn tape(ops_: []const Tape.Op, keyframes: []const Tape.Keyframe) Tape { + return .{ .name = "t", .title = "Everything", .home = "region:Main", .ops = ops_, .keyframes = keyframes, .captions = &captions, .chapters = &chapters }; + } +}; + +/// A tape with something of everything in it. +fn everything() Tape { + const keyframes = [_]Tape.Keyframe{ Everything.keyframe, .{ .root = "demo://t", .files = &Everything.sprite } }; + return Everything.tape(&Everything.ops, &keyframes); +} + +/// `everything` as version 2 could hold it, before steps: what `testdata/everything-v2.tape` is, +/// written by the version-2 codec. +fn everythingV2() Tape { + const keyframes = [_]Tape.Keyframe{ Everything.keyframe, .{ .root = "demo://t" } }; + return Everything.tape(&Everything.ops_v2, &keyframes); } /// The two tapes say exactly the same, field for field — and write the same ZON. @@ -583,19 +742,24 @@ fn expectSame(want: Tape, got: Tape) !void { try testing.expectEqualStrings(a.written(), b.written()); } -test "a tape round-trips through the binary form, field for field" { - const tape = everything(); - try tape.validate(.{}); - const bytes = try encode(testing.allocator, tape); - defer testing.allocator.free(bytes); - try testing.expect(sniff(bytes)); +const steps_check = Tape.test_steps; - var owned = try read(testing.allocator, bytes, .{}); - defer owned.deinit(); - try expectSame(tape, owned.tape); - // Floats exactly, not near enough. - try testing.expectEqual(@as(f32, -3.5), owned.tape.ops[3].do.move.dx); - try testing.expectEqualStrings("héllo\n\tworld", owned.tape.ops[7].do.type); +test "a tape round-trips through the binary form, field for field, deflated or not" { + const tape = everything(); + try tape.validate(steps_check); + for ([_]bool{ false, true }) |deflate| { + const bytes = try encode(testing.allocator, tape, .{ .deflate = deflate }); + defer testing.allocator.free(bytes); + try testing.expect(sniff(bytes)); + + var owned = try read(testing.allocator, bytes, steps_check); + defer owned.deinit(); + try expectSame(tape, owned.tape); + // Floats exactly, not near enough; bytes as they were. + try testing.expectEqual(@as(f32, -3.5), owned.tape.ops[3].do.move.dx); + try testing.expectEqualStrings("héllo\n\tworld", owned.tape.ops[7].do.type); + try testing.expectEqualSlices(u8, &.{ 0x89, 'P', 0, 0xff, '\n', 0 }, owned.tape.keyframes[1].files[0].text); + } } test "binary and ZON are the same tape, converted either way" { @@ -605,76 +769,115 @@ test "binary and ZON are the same tape, converted either way" { try tape.write(&zon.writer); const source = try testing.allocator.dupeSentinel(u8, zon.written(), 0); defer testing.allocator.free(source); - var from_zon = try Tape.parse(testing.allocator, source, .{}); + var from_zon = try Tape.parse(testing.allocator, source, steps_check); defer from_zon.deinit(); - const bytes = try encode(testing.allocator, from_zon.tape); + const bytes = try encode(testing.allocator, from_zon.tape, .{}); defer testing.allocator.free(bytes); - var back = try read(testing.allocator, bytes, .{}); + var back = try read(testing.allocator, bytes, steps_check); defer back.deinit(); try expectSame(tape, back.tape); } -test "each string is stored once" { +test "each string is stored once, each blob as often as it comes" { const tape = everything(); - const bytes = try encode(testing.allocator, tape); + const bytes = try encode(testing.allocator, tape, .{}); defer testing.allocator.free(bytes); // The anchor is named by two ops, a wait and a caption: once in the file. try testing.expectEqual(@as(usize, 1), std.mem.count(u8, bytes, "text.end:demo://t/a.md")); + try testing.expectEqual(@as(usize, 2), std.mem.count(u8, bytes, "\x00\x01\xff")); +} + +test "a deflated tape is smaller" { + // Steps the shape of a recording's: runs of a few colours. + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const ops = try arena.allocator().alloc(Tape.Op, 201); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 1 } }; + for (ops[1..], 1..) |*op, i| { + const effect = try arena.allocator().alloc(u8, 64); + @memset(effect, @intCast(i % 4)); + op.* = .{ .at = @intCast(i * 100), .do = .{ .step = .{ .file = "a.pixi", .effect = effect } } }; + } + const tape = Everything.tape(ops, &.{ Everything.keyframe, .{ .root = "demo://t", .files = &Everything.sprite } }); + const plain = try encode(testing.allocator, tape, .{}); + defer testing.allocator.free(plain); + const deflated = try encode(testing.allocator, tape, .{ .deflate = true }); + defer testing.allocator.free(deflated); + try testing.expect(deflated.len * 4 < plain.len); } test "a reader holds nothing of the bytes it read" { - const bytes = try encode(testing.allocator, everything()); - var owned = try read(testing.allocator, bytes, .{}); - defer owned.deinit(); - @memset(bytes, 0xaa); - testing.allocator.free(bytes); - try testing.expectEqualStrings("region:Main", owned.tape.home); + for ([_]bool{ false, true }) |deflate| { + const bytes = try encode(testing.allocator, everything(), .{ .deflate = deflate }); + var owned = try read(testing.allocator, bytes, steps_check); + defer owned.deinit(); + @memset(bytes, 0xaa); + testing.allocator.free(bytes); + try testing.expectEqualStrings("region:Main", owned.tape.home); + try testing.expectEqualStrings("\x02\x00", owned.tape.ops[15].do.step.motion); + } } test "what is not a binary tape, or not a whole one, is refused" { - try testing.expectError(error.NotATape, read(testing.allocator, ".{ .name = \"zon\" }", .{})); - - const bytes = try encode(testing.allocator, everything()); - defer testing.allocator.free(bytes); - // Cut short anywhere, it is truncated — or, cut inside the string table, corrupt — and never - // read past its end. - for (0..bytes.len) |n| { - if (read(testing.allocator, bytes[0..n], .{})) |owned| { - var o = owned; - o.deinit(); - return error.TestUnexpectedResult; - } else |err| switch (err) { - error.NotATape, error.Truncated, error.Corrupt => {}, - else => return err, + try testing.expectError(error.NotATape, read(testing.allocator, ".{ .name = \"zon\" }", steps_check)); + + for ([_]bool{ false, true }) |deflate| { + const bytes = try encode(testing.allocator, everything(), .{ .deflate = deflate }); + defer testing.allocator.free(bytes); + // Cut short anywhere, it is truncated — or, cut inside a table or the stream, corrupt — + // and never read past its end. + for (0..bytes.len) |n| { + if (read(testing.allocator, bytes[0..n], steps_check)) |owned| { + var o = owned; + o.deinit(); + return error.TestUnexpectedResult; + } else |err| switch (err) { + error.NotATape, error.Truncated, error.Corrupt => {}, + else => return err, + } } - } - var newer = try testing.allocator.dupe(u8, bytes); - defer testing.allocator.free(newer); - std.mem.writeInt(u16, newer[4..6], version + 1, .little); - try testing.expectError(error.UnsupportedVersion, read(testing.allocator, newer, .{})); + var newer = try testing.allocator.dupe(u8, bytes); + defer testing.allocator.free(newer); + std.mem.writeInt(u16, newer[4..6], version + 1, .little); + try testing.expectError(error.UnsupportedVersion, read(testing.allocator, newer, steps_check)); + // A flag no reader knows is not guessed at. + std.mem.writeInt(u16, newer[4..6], version, .little); + std.mem.writeInt(u16, newer[6..8], 1 << 7, .little); + try testing.expectError(error.Corrupt, read(testing.allocator, newer, steps_check)); + } } test "flipped bytes are an error, never a crash" { - const bytes = try encode(testing.allocator, everything()); - defer testing.allocator.free(bytes); - const copy = try testing.allocator.dupe(u8, bytes); - defer testing.allocator.free(copy); var prng: std.Random.DefaultPrng = .init(0x7a9e); const r = prng.random(); - for (0..4000) |_| { - @memcpy(copy, bytes); - for (0..1 + r.uintLessThan(usize, 4)) |_| copy[r.uintLessThan(usize, copy.len)] ^= @as(u8, 1) << r.int(u3); - if (read(testing.allocator, copy, .{})) |owned| { - var o = owned; - o.deinit(); - } else |_| {} + for ([_]bool{ false, true }) |deflate| { + const bytes = try encode(testing.allocator, everything(), .{ .deflate = deflate }); + defer testing.allocator.free(bytes); + const copy = try testing.allocator.dupe(u8, bytes); + defer testing.allocator.free(copy); + for (0..4000) |_| { + @memcpy(copy, bytes); + for (0..1 + r.uintLessThan(usize, 4)) |_| copy[r.uintLessThan(usize, copy.len)] ^= @as(u8, 1) << r.int(u3); + if (read(testing.allocator, copy, steps_check)) |owned| { + var o = owned; + o.deinit(); + } else |_| {} + } } } +const v2_fixture = @embedFile("testdata/everything-v2.tape"); + +test "a version-2 tape still reads, field for field" { + var owned = try read(testing.allocator, v2_fixture, .{}); + defer owned.deinit(); + try expectSame(everythingV2(), owned.tape); +} + test "a version-1 tape still reads, its commands without arguments" { - const bytes = try encode(testing.allocator, everything()); + const bytes = try testing.allocator.dupe(u8, v2_fixture); defer testing.allocator.free(bytes); std.mem.writeInt(u16, bytes[4..6], 1, .little); var owned = try read(testing.allocator, bytes, .{}); @@ -685,14 +888,19 @@ test "a version-1 tape still reads, its commands without arguments" { std.mem.writeInt(u16, bytes[4..6], 0, .little); try testing.expectError(error.UnsupportedVersion, read(testing.allocator, bytes, .{})); + // Deflating came with version 3. + std.mem.writeInt(u16, bytes[4..6], 2, .little); + std.mem.writeInt(u16, bytes[6..8], flag_deflated, .little); + try testing.expectError(error.Corrupt, read(testing.allocator, bytes, .{})); } -test "a recording's key chords are checked against the app's spelling, as in ZON" { - const bytes = try encode(testing.allocator, everything()); +test "a recording's key chords and steps are checked against the app, as in ZON" { + const bytes = try encode(testing.allocator, everything(), .{}); defer testing.allocator.free(bytes); - try testing.expectError(error.BadChord, read(testing.allocator, bytes, .{ .key = struct { + try testing.expectError(error.BadChord, read(testing.allocator, bytes, .{ .steps = steps_check.steps, .key = struct { fn ok(c: []const u8) bool { return std.mem.indexOfScalar(u8, c, ' ') == null; } }.ok })); + try testing.expectError(error.StepsUnsupported, read(testing.allocator, bytes, .{})); } diff --git a/sdk/tape/testdata/everything-v2.tape b/sdk/tape/testdata/everything-v2.tape new file mode 100644 index 000000000..66a1e105e Binary files /dev/null and b/sdk/tape/testdata/everything-v2.tape differ diff --git a/spikes/shared-globals/host.zig b/spikes/shared-globals/host.zig index 646744f65..a6146829e 100644 --- a/spikes/shared-globals/host.zig +++ b/spikes/shared-globals/host.zig @@ -17,7 +17,7 @@ pub fn main() !void { _ = try core.label("host-drawn"); // host renders 1 widget itself std.debug.print("[host] after host label(): widget_count={d} shape_calls={d}\n", .{ win.widget_count, ft.shape_calls }); - const ext = switch (builtin.os.tag) { + const ext = switch (builtin.target.os.tag) { .macos => "dylib", .windows => "dll", else => "so", diff --git a/spikes/windows-composition/src/main.zig b/spikes/windows-composition/src/main.zig index b7cb45889..17db71704 100644 --- a/spikes/windows-composition/src/main.zig +++ b/spikes/windows-composition/src/main.zig @@ -20,7 +20,7 @@ const Overlay = @import("overlay.zig"); const u = @import("user32.zig"); comptime { - if (builtin.os.tag != .windows) @compileError("the composition spike is Windows only: -Dtarget=x86_64-windows-gnu or aarch64-windows-gnu"); + if (builtin.target.os.tag != .windows) @compileError("the composition spike is Windows only: -Dtarget=x86_64-windows-gnu or aarch64-windows-gnu"); } pub const std_options: std.Options = .{ .log_level = .info }; diff --git a/src/Entry.zig b/src/Entry.zig index 92a8e7b16..001c31a3b 100644 --- a/src/Entry.zig +++ b/src/Entry.zig @@ -11,7 +11,6 @@ const icon = assets.files.@"icon.png"; const fizzy = @import("fizzy.zig"); const workbench = @import("workbench"); -const text = @import("text"); const auto_update = @import("app").update.auto_update; const file_assoc = @import("backend/file_assoc.zig"); const update_notify = @import("app").update.update_notify; diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index df1fb3ee2..19618c326 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -73,6 +73,9 @@ quitting: bool = false, arrange: ?automation.Tape.Keyframe.Layout = null, /// Every plugin's settings as the demo found them, as ZON, to give back at the end. settings_saved: std.ArrayListUnmanaged(SavedSettings) = .empty, +/// Who a live tape plays for (`automation.Api.playAs`): an agent's pointer tape is the agent's. +/// Strings owned here. Null: the live tape is its own author. +live_author: ?sdk.Author = null, const SavedSettings = struct { owner: []u8, @@ -118,7 +121,7 @@ pub fn attach(self: *Demo, editor: *Editor) void { .editor = editor, .player = .init(gpa, stage), .live = .init(stage), - .service = .{ .gpa = gpa, .driver = &self.live, .other = .{ .ctx = self, .driving = demoLoaded }, .snapshots = &self.snapshot, .captures = &self.picture }, + .service = .{ .gpa = gpa, .driver = &self.live, .other = .{ .ctx = self, .driving = demoLoaded }, .on_author = .{ .ctx = self, .set = liveAuthorSet }, .snapshots = &self.snapshot, .captures = &self.picture }, .snapshot = .init(gpa), .picture = .init(gpa), }; @@ -133,6 +136,7 @@ fn demoLoaded(ctx: *anyopaque) bool { } pub fn deinit(self: *Demo) void { + self.setLiveAuthor(null); self.quitting = true; self.snapshot.deinit(); self.picture.deinit(); @@ -354,6 +358,48 @@ const stage_vtable: automation.Stage.VTable = .{ .fingerprint = fingerprint, }; +/// A live tape played by nobody in particular, or a demo with no name. +const tape_author: sdk.Author = .{ .kind = .tape, .plugin = "fizzy", .label = "a tape" }; + +/// Whose frame this is when a tape drives it: the live tape's author (`live_author`, else the +/// tape itself), or the demo being played or sought through. Null when no tape drives: a real +/// press or key stops either before the frame's input is consumed, so the frame is the person's. +pub fn author(self: *const Demo) ?sdk.Author { + if (self.live.playing()) return self.live_author orelse tape_author; + switch (self.player.state) { + .playing, .seeking => {}, + else => return null, + } + const tape = (self.player.owned orelse return tape_author).tape; + return .{ + .kind = .tape, + .plugin = "fizzy", + .session = std.hash.Wyhash.hash(0, tape.name), + .label = if (tape.title.len > 0) tape.title else tape.name, + }; +} + +fn liveAuthorSet(ctx: *anyopaque, who: ?sdk.Author) void { + from(ctx).setLiveAuthor(who); +} + +/// Play the next live tape for `who` (`automation.Api.playAs`); null, for no one in particular. +pub fn setLiveAuthor(self: *Demo, who: ?sdk.Author) void { + const gpa = self.editor.app.gpa; + if (self.live_author) |a| { + gpa.free(a.plugin); + gpa.free(a.label); + } + self.live_author = null; + const a = who orelse return; + const plugin = gpa.dupe(u8, a.plugin) catch return; + const label = gpa.dupe(u8, a.label) catch { + gpa.free(plugin); + return; + }; + self.live_author = .{ .kind = a.kind, .plugin = plugin, .session = a.session, .label = label }; +} + fn from(ctx: *anyopaque) *Demo { return @ptrCast(@alignCast(ctx)); } @@ -449,7 +495,7 @@ fn keyframe(ctx: *anyopaque, kf: *const automation.Tape.Keyframe) void { }; // The tree as if never seen: folders closed, the root opening on its own. - editor.explorer.open_branches.clearRetainingCapacity(); + editor.workbench.open_branches.clearRetainingCapacity(); editor.workbench.file_tree_root_opened = null; editor.setProjectFolder(kf.root) catch |err| dvui.log.err("demo: could not open {s}: {t}", .{ kf.root, err }); editor.app.host.setSelectionFor(sdk.keywords.ide.sidebar, Editor.workbench_files_view); @@ -628,6 +674,9 @@ fn idle(ctx: *anyopaque) bool { /// palette does once they are given; what it returns is not the demo's business. fn command(ctx: *anyopaque, id: []const u8, args: []const u8) void { const host = &from(ctx).editor.app.host; + // A tape's command is the tape's (or, for a live tape played for someone, theirs). + const outer = host.act(from(ctx).author() orelse tape_author); + defer host.endAct(outer); if (args.len == 0) { host.runCommand(id) catch |err| dvui.log.warn("demo: command {s} failed: {t}", .{ id, err }); return; @@ -738,8 +787,8 @@ fn fill(self: *Demo, snap: *Snapshot, root: []const u8, mem: *core.vfs.Mem) !boo } snap.docs = docs.items; - const branches = try a.alloc(dvui.Id, editor.explorer.open_branches.count()); - var it = editor.explorer.open_branches.keyIterator(); + const branches = try a.alloc(dvui.Id, editor.workbench.open_branches.count()); + var it = editor.workbench.open_branches.keyIterator(); var i: usize = 0; while (it.next()) |id| : (i += 1) branches[i] = id.*; snap.branches = branches; @@ -798,8 +847,8 @@ fn restore(ctx: *anyopaque, raw: *anyopaque) bool { if (snap.explorer_closed != editor.explorer.closed) { if (snap.explorer_closed) editor.explorer.close(editor) else editor.explorer.open(editor); } - editor.explorer.open_branches.clearRetainingCapacity(); - for (snap.branches) |id| editor.explorer.open_branches.put(id, {}) catch {}; + editor.workbench.open_branches.clearRetainingCapacity(); + for (snap.branches) |id| editor.workbench.setBranchOpen(id, true); editor.workbench.file_tree_root_opened = snap.root_opened; for (snap.settings) |saved| { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 350fe287c..2c1cca681 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -103,12 +103,15 @@ pub const Host = sdk.Host; /// Workbench: the file-management home — file tree, open/load flow, and the /// workspace/tabs/splits system, plus the per-branch explorer decoration registry. pub const Workbench = workbench_mod.Workbench; +pub const Workspaces = @import("Workspaces.zig"); /// The host runtime — everything an application on fizzy runs unchanged. app: App, /// File-management workbench (per-branch explorer decorations, …) workbench: Workbench, +/// The workbench's panes as the app's own views, when `FIZZY_WORKSPACES=1` asked (`Workspaces`). +workspaces: Workspaces = .{}, /// The first claim id not yet shown beside the person (`showClaimed`). claims_seen: u32 = 1, @@ -625,7 +628,7 @@ pub fn init( fizzy.backend.setTitlebarColor(dvui.currentWindow(), dvui.themeGet().color(.content, .fill).opacity(if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light)); - editor.explorer.* = .init(); + editor.explorer.* = .{}; editor.app.open_files = .empty; try editor.workbench.initDefaultWorkspace(); @@ -667,7 +670,7 @@ pub const view_settings = "fizzy.settings"; pub const workbench_files_view = workbench_mod.view_files; pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { - if (comptime builtin.target.cpu.arch == .wasm32) return; + if (comptime builtin.target.cpu.arch == .wasm32 or !App.loads_plugins) return; const plugins_dir = std.fs.path.join(editor.app.gpa, &.{ config_folder, "plugins" }) catch return; defer editor.app.gpa.free(plugins_dir); @@ -1424,7 +1427,7 @@ pub const UnloadError = error{ NotUnloadable, DirtyDocuments }; /// dvui/render-bridge sync path as `loadUserPlugins`. Caller ensures `id` is not already /// registered. On success the lib is appended to `loaded_plugin_libs`. pub fn loadUserPluginById(editor: *Editor, id: []const u8) !void { - if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable; + if (comptime builtin.target.cpu.arch == .wasm32 or !App.loads_plugins) return error.NotUnloadable; const path = try App.userPluginPath(editor.app.gpa, &editor.app, id); const opened = PluginLoader.open(editor.app.gpa, path, id) catch |err| { editor.userPluginLoadFailed(id, path, err); @@ -1632,7 +1635,7 @@ const WebPluginRequest = struct { /// The page asks for a plugin: `?plugin=<id>` on the URL (`plugins/<id>/<id>.wasm` beside /// the app), or one it remembered from a store install (by its URL). export fn FizzyWebPluginRequest(id_ptr: [*]const u8, id_len: usize, url_ptr: [*]const u8, url_len: usize) void { - if (comptime builtin.target.cpu.arch != .wasm32) return; + if (comptime builtin.target.cpu.arch != .wasm32 or !App.loads_plugins) return; const editor = web_editor orelse return; const id = id_ptr[0..id_len]; // The id reaches this from the page's query string, and with no URL it is interpolated @@ -2027,6 +2030,9 @@ fn storeUninstalled(arena: std.mem.Allocator) []const @import("app").layout.Stat } pub fn postInit(editor: *Editor) !void { + // Documents' tracks, while they are behind a switch (plans/RECORDING_PLAN.md, step 3). + editor.app.tracks = .init(editor.app.gpa); + if (Popout.envSwitch("FIZZY_TRACKS") orelse false) editor.setTracking(true); if (comptime build_opts.has_app_layout) { dvui.log.info("layout: app-supplied", .{}); } else { @@ -2046,6 +2052,12 @@ pub fn postInit(editor: *Editor) !void { } else |_| {} } sdk.installRuntime(&editor.app.gpa, &editor.app.host, null); + // Before the plugins register: the workbench offers its one surface of every pane only where + // the app does not place each pane itself. + if (Workspaces.wanted(editor.app.gpa)) { + editor.workspaces.install(&editor.workbench); + dvui.log.info("layout: workspaces are views (FIZZY_WORKSPACES)", .{}); + } editor.demo.attach(editor); // `FIZZY_DEMO=tour` plays a bundled demo from launch — how a screen recording is made — and @@ -2083,6 +2095,11 @@ pub fn postInit(editor: *Editor) !void { .description = "Files under the project folder changed on disk (coalesced, ignore rules applied); truncated means rescan.", .payload = ".{ .events = .{ .{ .path = \"/abs\", .kind = .created, .object = .file, .old_path = \"\" } }, .truncated = false }", }) catch {}; + editor.app.host.declareEvent(.{ + .name = "fizzy.files.reveal", + .description = "A path was just made: show it as a new file, revealed and selected with its name open for editing.", + .payload = ".{ .path = \"/absolute/path\" }", + }) catch {}; // Publish the shared file set. Same reason as the commands above: `env.ctx` is this // editor's final address. The table answers three questions it can't itself — where the @@ -2114,9 +2131,11 @@ pub fn postInit(editor: *Editor) !void { // hardcoding panes. Web-safe — the draw fns reach the same inline code the // editor tick already runs on wasm. Order = sidebar order. // Every bundled plugin: its dylib beside the exe when there is one, else the copy linked - // in. None of the bundled four ship as dylibs today, so the load "fails" and the static - // copy registers on every run; not worth logging until that changes. The workbench's - // dylib entry takes the workbench state fizzy still holds as `arg_c`. + // in. The build puts the dylibs of fizzy's own (workbench, text, markdown, image) in + // `<exe_dir>/plugins/`, so fizzy's own features load the way a third-party plugin does + // and that path is exercised every run; a plugin with no dylib (archive, devtools, an + // app's own, every one on the web) fails the load quietly and its static copy registers. + // The workbench's dylib entry takes the workbench state fizzy still holds as `arg_c`. // What this build bundles, for anything that needs to know a plugin is compiled in rather // than installed (the store, which must not offer to uninstall one). editor.app.bundled_plugin_ids = comptime blk: { @@ -2368,8 +2387,6 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .openInFileBrowser = fizzyOpenInFileBrowser, .isPathIgnored = fizzyIsPathIgnored, .folderWatchActive = fizzyFolderWatchActive, - .explorerBranchIsOpen = fizzyExplorerBranchIsOpen, - .setExplorerBranchOpen = fizzySetExplorerBranchOpen, .drawWorkspaces = fizzyDrawWorkspaces, .showOpenFolderDialog = fizzyShowOpenFolderDialog, .showOpenFileDialog = fizzyShowOpenFileDialog, @@ -2378,7 +2395,6 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .refresh = fizzyRefresh, .allocUntitledPath = fizzyAllocUntitledPath, .createDocument = fizzyCreateDocument, - .setExplorerNewFilePath = fizzySetExplorerNewFilePath, .saveState = fizzySaveState, .logLine = fizzyLogLine, .panic = fizzyPanic, @@ -2654,17 +2670,6 @@ fn fizzyIsPathIgnored( ) bool { return fizzyCtx(ctx).ignore.isIgnored(project_root, abs_path, name, kind); } -fn fizzyExplorerBranchIsOpen(ctx: *anyopaque, branch_id: dvui.Id) bool { - return fizzyCtx(ctx).explorer.open_branches.contains(branch_id); -} -fn fizzySetExplorerBranchOpen(ctx: *anyopaque, branch_id: dvui.Id, open: bool) void { - const editor = fizzyCtx(ctx); - if (open) { - editor.explorer.open_branches.put(branch_id, {}) catch {}; - } else { - _ = editor.explorer.open_branches.remove(branch_id); - } -} fn fizzyDrawWorkspaces(ctx: *anyopaque, index: usize) anyerror!dvui.App.Result { return fizzyCtx(ctx).workbench.drawWorkspaces(index); } @@ -2712,17 +2717,6 @@ fn fizzyAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 { fn fizzyCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) anyerror!sdk.DocHandle { return fizzyCtx(ctx).newFile(path, grid); } -fn fizzySetExplorerNewFilePath(ctx: *anyopaque, path: []const u8) anyerror!void { - const editor = fizzyCtx(ctx); - try editor.workbench.setPendingNewFilePath(path); - // A plugin reaching this has just written `path` with its own save routine, not through - // `files.createFilePath`, so nothing has dropped the shared listing cache and that cache - // still predates the file. Left stale, the row never appears on the next frame: the match in - // `files.zig` never runs, no inline rename opens, and the dialog still closing over the top - // of it has no row to fly into. This is the only thing standing between "the file exists" - // and "the user can see and rename it". - editor.app.file_table.invalidateAll(); -} fn fizzySaveState(ctx: *anyopaque, doc_id: u64) sdk.EditorAPI.SaveState { return fizzyCtx(ctx).doc_io.state(doc_id); } @@ -2737,6 +2731,8 @@ const DocSurface = struct { focus_inside: bool = false, /// The commands just run on it, shown on its pane. feed: @import("app").ActivityFeed = .{}, + /// Its history as a line at the bottom of the pane: undo and redo show it. + transport: @import("app").HistoryTransport = .{}, }; pub fn insertOpenDoc(editor: *Editor, doc_buf: *anyopaque, owner: *sdk.Plugin, id: u64) !void { @@ -2754,6 +2750,7 @@ pub fn insertOpenDoc(editor: *Editor, doc_buf: *anyopaque, owner: *sdk.Plugin, i if (editor.document_watcher) |*w| { if (editor.app.docById(id)) |doc| w.track(doc); } + if (editor.app.tracks) |*t| if (editor.app.docById(id)) |doc| t.opened(doc); if (editor.app.docById(id)) |doc| { editor.registerDocSurface(doc) catch |err| { dvui.log.err("document surface for {s}: {t}", .{ owner.documentPath(doc), err }); @@ -2952,9 +2949,12 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { if (editor.app.host.activity_muted) ds.feed.clear() else { const now = dvui.frameTimeNS(); ds.feed.take(&editor.app.host, doc.id, now); - // Above the pill, while someone holds the document. + // Above the pill while someone holds the document, and above the transport while it shows. var area = canvas.data().borderRectScale().r; if (claim_pill) |pill| area.h = pill.physical.y - area.y + 6 * dvui.windowRectScale().s; + if (claim_pill == null) if (ds.transport.place(area, now)) |bar| { + area.h = @min(area.h, bar.y - area.y + 6 * dvui.windowRectScale().s); + }; ds.feed.draw(area, @truncate(ds.doc_id), now); } @@ -2978,18 +2978,21 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { }, false); } } - if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)); + // Its history, while undo or redo or the pointer keep it: not while someone else holds it. + if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id)); return .ok; } -pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { - return editor.workbench.activeDoc(); +/// Record documents' tracks, or stop. Documents open now get one; those opened after too. +pub fn setTracking(editor: *Editor, on: bool) void { + const t = &(editor.app.tracks orelse return); + t.on = on; + editor.app.host.recorder = if (on) t.recorder() else null; + if (on) for (editor.app.open_files.values()) |doc| t.opened(doc); } -/// Files sidebar inactive — drop the tree's dvui stash id, which the tree sets again each frame -/// it draws. -pub fn resetFileTreeWhenFilesHidden(editor: *Editor) void { - editor.workbench.clearFileTreeDataId(); +pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { + return editor.workbench.activeDoc(); } /// Draws whichever center provider is active, blur-fading when that changes. @@ -3973,6 +3976,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.app.file_table.pump(); // Saves whose encode finished on their worker: on to their write, or done. editor.doc_io.tick(); + // The document states tracks asked for since last frame, before anything draws. + if (editor.app.tracks) |*t| t.tick(); editor.promoteEditedPreviews(); editor.window_opacity = if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light; @@ -4317,6 +4322,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // layout needed these five calls verbatim, and getting one wrong is a bug an app // author has no way to diagnose. A shape declares regions; it does not run the // frame. + // The frame's input is consumed from here to the palette: whatever it changes is the + // person's, or a tape's while one drives (`Host.setInputAuthor`). Plugins' frame hooks + // outside it act for themselves, or are nobody's. + editor.app.host.setInputAuthor(editor.demo.author() orelse .person); var layout_root = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .background = false }); for (editor.app.host.plugins.items) |plugin| plugin.tickActiveDocument(layout_root.data().id); editor.flushQueuedNativeMenuActions(); @@ -4369,6 +4378,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { .{layout.depth}, ); + editor.app.host.setInputAuthor(null); for (editor.app.host.plugins.items) |plugin| plugin.endFrame(); layout_root.deinit(); @@ -4376,6 +4386,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } { // Plugin keybinds + per-frame overlays (e.g. pixel-art's radial menu) + editor.app.host.setInputAuthor(editor.demo.author() orelse .person); + defer editor.app.host.setInputAuthor(null); // While the palette owns the keyboard, plugin ticks must not run — otherwise a // plugin's `matchBind` (e.g. pixi Export on the same chord as Quick Open) steals // focus before the palette entry can claim it. @@ -4548,6 +4560,7 @@ pub fn setWindowStyle(_: *Editor) void { pub fn rebuildWorkspaces(editor: *Editor) !void { try editor.workbench.rebuildWorkspaces(); + editor.workspaces.sync(); } /// Write every region's extent and assignment to `layout.zon`, by name. One record per region @@ -5204,7 +5217,7 @@ pub fn openFile(editor: *Editor, opts: sdk.EditorAPI.OpenOptions) !bool { else null; - const started = try editor.openPath(opts.path, opts.grouping, .{ .preview = preview, .take_slot_of = slot }); + const started = try editor.openPath(opts.path, opts.grouping, .{ .preview = preview, .take_slot_of = slot, .beside = opts.beside }); if (preview) { if (editor.docFromPath(opts.path)) |doc| editor.setDocumentPreview(doc.id, true); } @@ -5264,9 +5277,11 @@ pub fn openFilePath(editor: *Editor, path_in: []const u8, grouping: u64) !bool { } /// Where an open's placeholder tab goes (see `Openings.begin`). -const Placement = struct { +pub const Placement = struct { preview: bool = false, take_slot_of: ?[]const u8 = null, + /// `OpenOptions.beside`. + beside: bool = false, }; /// Watch this executable while `restart_on_rebuild` is on, and stop when it goes off. Once a frame; @@ -5367,9 +5382,16 @@ fn keepSessionForRestart(editor: *Editor) bool { return true; } -fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement: Placement) !bool { +fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement_in: Placement) !bool { + var placement = placement_in; + placement.beside = placement.beside or editor.app.host.open_beside > 0; // Last session's document, as a restart kept it: from its state, not the disk. - if (comptime restart.supported) if (editor.session) |*kept| if (kept.openKept(editor, path_in, grouping) != null) return true; + // One never saved that it could not bring back has no file to open instead. + if (comptime restart.supported) if (editor.session) |*kept| switch (kept.openKept(editor, path_in, grouping)) { + .opened => return true, + .lost => return false, + .not_kept => {}, + }; const path = try fizzy.core.paths.normalize(editor.app.gpa, path_in); defer editor.app.gpa.free(path); @@ -5377,7 +5399,9 @@ fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement: Plac // Already open? Just focus it. (`docFromPath` also collapses lexical variants, so a doc // opened under a pre-normalization spelling is still found.) if (editor.docFromPath(path)) |doc| { - if (editor.app.open_files.getIndex(doc.id)) |i| { + if (placement.beside) { + editor.workbench.showBeside(doc); + } else if (editor.app.open_files.getIndex(doc.id)) |i| { editor.workbench.setActiveDocIndex(i); } return false; @@ -5397,7 +5421,7 @@ fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement: Plac const started = try editor.doc_io.open(path, grouping); if (started) { if (editor.app.host.pluginForExtension(std.fs.path.extension(path))) |owner| { - editor.openings.begin(editor, path, grouping, placement.preview, owner, placement.take_slot_of); + editor.openings.begin(editor, path, grouping, placement, owner); } } return started; @@ -5438,7 +5462,7 @@ fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement: Plac job.destroy(io); return err; }; - editor.openings.begin(editor, path, grouping, placement.preview, owner, placement.take_slot_of); + editor.openings.begin(editor, path, grouping, placement, owner); return true; } @@ -5679,16 +5703,23 @@ pub fn newFile(editor: *Editor, path: []const u8, grid: sdk.EditorAPI.NewDocGrid const id = owner.documentIdFromBuffer(staging.buf.ptr); try editor.insertOpenDoc(staging.buf.ptr, owner, id); editor.documentOpened(id); - editor.workbench.setActiveDocIndex(editor.app.open_files.count() - 1); + const doc = editor.app.docById(id) orelse return error.FailedToCreateFile; + // Created beside the person (`Host.open_beside`), it lands there at once: never first over + // the document they are in, fitted to that pane, and then moved. + if (editor.app.host.open_beside > 0) { + editor.workbench.showBeside(doc); + } else { + editor.workbench.setActiveDocIndex(editor.app.open_files.count() - 1); + } editor.pending_composite_warmup = true; - return editor.app.docById(id) orelse return error.FailedToCreateFile; + return doc; } /// Dispatch a generic fizzy action to the active document owner's command (`<owner_id>.<action>`). /// No active doc, or an owner that registered no such command, is a clean no-op. This is how the /// fizzy's Edit menu / keybinds reach per-editor actions without naming any plugin. -fn runActiveDocCommand(editor: *Editor, action: []const u8) !void { +pub fn runActiveDocCommand(editor: *Editor, action: []const u8) !void { const doc = editor.activeDoc() orelse return; const id = try std.fmt.allocPrint(editor.app.arena.allocator(), "{s}.{s}", .{ doc.owner.id, action }); try editor.app.host.runCommand(id); @@ -6044,11 +6075,26 @@ pub fn processPendingSaveAs(editor: *Editor) void { } pub fn undo(editor: *Editor) !void { - try editor.runActiveDocCommand("undo"); + try editor.historyStep(.undo); } pub fn redo(editor: *Editor) !void { - try editor.runActiveDocCommand("redo"); + try editor.historyStep(.redo); +} + +/// An undo or a redo of the active document, through its owner's command: its transport shows, +/// and a key held down goes further each press (`HistoryTransport.press`), the command run that +/// many times. Nothing to undo, or a claimed document: the command as ever, and no bar. +fn historyStep(editor: *Editor, action: enum { undo, redo }) !void { + const doc = editor.activeDoc() orelse return; + const name = @tagName(action); + const shows = editor.app.host.documentClaim(doc.id) == null and editor.activeDocCommandEnabled(name); + const ds = if (shows) editor.doc_surfaces.get(doc.id) else null; + const by = if (ds) |d| d.transport.press(dvui.frameTimeNS(), action == .redo) else 1; + for (0..by) |_| { + if (!editor.activeDocCommandEnabled(name)) break; + try editor.runActiveDocCommand(name); + } } pub fn openInFileBrowser(editor: *Editor, path: []const u8) !void { @@ -6127,7 +6173,8 @@ pub fn closeFile(editor: *Editor, index: usize) !void { /// only its image stays for the slide. fn snapshotClosingPane(editor: *Editor, doc: sdk.DocHandle) void { const wb = editor.workbench; - if (wb.panes.nodes.items[wb.panes.root] == .leaf) return; + // Where the app arranges the panes, the place slides shut on its own and draws no picture. + if (wb.places != null or wb.solePane()) return; const id = sdk.document.surfaceId(editor.app.host.arena(), doc.owner.id, doc.owner.documentPath(doc)) catch return; for (wb.workspaces.values()) |*ws| { if (!ws.hasTab(id) or ws.tabCount() != 1) continue; @@ -6190,6 +6237,8 @@ pub fn deinit(editor: *Editor) !void { const demo_was_active = editor.demo.active(); editor.demo.deinit(); editor.black_box.deinit(editor.app.gpa); + editor.app.host.recorder = null; + if (editor.app.tracks) |*t| t.deinit(); // Owned outright rather than cached by dvui, so it has to be released explicitly. editor.app.layout.center_transition.discard(); editor.app.layout.center_prev_id = null; @@ -6229,6 +6278,7 @@ pub fn deinit(editor: *Editor) !void { // Tear workspaces down first, while the plugins whose documents they hold are still alive. editor.workbench.deinitWorkspaces(fizzy.entry().window.backend); + editor.workspaces.deinit(editor.app.gpa); // Drain & join the save-queue worker before tearing anything else down. Any // queued jobs need to finish writing or be dropped before File data is freed. @@ -6306,7 +6356,6 @@ pub fn deinit(editor: *Editor) !void { } editor.app.settings.deinit(editor.app.gpa); - editor.explorer.deinit(); editor.app.gpa.destroy(editor.explorer); PluginStore.deinit(); diff --git a/src/editor/FilesService.zig b/src/editor/FilesService.zig index f7bb00085..096da184f 100644 --- a/src/editor/FilesService.zig +++ b/src/editor/FilesService.zig @@ -40,6 +40,7 @@ const vtable: sdk.services.files.Api.VTable = .{ .rename = rename, .delete = delete, .move = move, + .reveal = reveal, }; /// Create an empty file at `path`. @@ -227,6 +228,19 @@ fn move(ctx: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool return true; } +/// Show `path` the way a new file is shown, through whatever lists the project's files: the file +/// tree hears `fizzy.files.reveal` and reveals it, selected, with its name open for editing. +/// +/// A plugin calling this has usually just written `path` with its own save routine, not through +/// `createFile`, so nothing has dropped the shared listing cache and it still predates the file. +/// Left stale, the row never appears: the tree has nothing to reveal, no rename opens, and a dialog +/// still closing over it has no row to fly into. So the listing goes first. +fn reveal(ctx: *anyopaque, path: []const u8) void { + const self = hostOf(ctx); + if (self.files) |files| files.invalidateAll(); + self.emitZon("fizzy.files.reveal", sdk.events.Path{ .path = path }); +} + /// Whether `child` sits strictly below `ancestor`, comparing whole path components so that /// `src/files2` is not treated as living inside `src/files`. fn isStrictPathDescendant(child: []const u8, ancestor: []const u8) bool { diff --git a/src/editor/KeptDocuments.zig b/src/editor/KeptDocuments.zig index 2c3196b96..b9c79b086 100644 --- a/src/editor/KeptDocuments.zig +++ b/src/editor/KeptDocuments.zig @@ -18,6 +18,11 @@ //! file changed since (an agent's edit, a pull) opens from disk instead; a dirty one always //! comes back as it was, since those edits are the person's. //! +//! A document never saved (`untitled-1`) has no file to fall back on, so its unsaved contents are +//! kept as its owner would save them (`Plugin.bytesPath`: under the name Save As offers), and it +//! comes back from them, untitled, through its owner (`Plugin.loadUntitledDocument`), its state +//! restored over them. It is never opened by its title as a path: no file has that name. +//! //! A new build may not read an old build's state (its format changed): the owner refuses it //! (`restoreDocumentState` fails), the document opens as it is on disk, and any unsaved contents //! are written to a recovery file the person is told about. A document with unsaved changes whose @@ -54,8 +59,11 @@ const Kept = struct { /// `captureDocumentState`, when the owner has the hook. state: ?[]u8, /// Unsaved contents (`documentBytes`), kept only for a dirty document: what a recovery file - /// holds if the new build cannot read `state`. + /// holds if the new build cannot read `state`, and what a document never saved reopens from. unsaved: ?[]u8, + /// A dirty document never saved: the name `unsaved` was encoded under (`Plugin.bytesPath`), + /// which says its format. `path` is then its title, naming no file. + save_as: ?[]const u8 = null, /// A clean document's file as it was when captured, so a restart can tell it changed since. stamp: Stamp = .{}, }; @@ -97,13 +105,17 @@ pub fn capture(editor: *Editor, plugin: ?*sdk.Plugin) CaptureError!KeptDocuments const state = owner.captureDocumentState(doc, gpa); errdefer if (state) |s| gpa.free(s); if (dirty and state == null) return error.UnsavedNotCarried; - const unsaved: ?[]u8 = if (dirty) owner.documentBytes(doc, owner.documentPath(doc), gpa) catch null else null; + // Never saved: no file to name a format, so the bytes are encoded as a save would write + // them (`bytesPath`), and that name is kept to read them back by. + const save_as: ?[]const u8 = if (dirty and !owner.documentHasRecognizedSaveExtension(doc)) try owner.bytesPath(doc, gpa) else null; + errdefer if (save_as) |a| gpa.free(a); + const unsaved: ?[]u8 = if (dirty) owner.documentBytes(doc, save_as orelse owner.documentPath(doc), gpa) catch null else null; errdefer if (unsaved) |u| gpa.free(u); const sid = sdk.document.surfaceId(editor.app.host.arena(), owner.id, path) catch return error.OutOfMemory; if (active) |a| { if (a.id == doc.id) self.active = try gpa.dupe(u8, path); } - // Last: from here `self` owns `path`, `owner_id`, `state` and `unsaved`. + // Last: from here `self` owns `path`, `owner_id`, `state`, `unsaved` and `save_as`. try self.docs.append(gpa, .{ .path = path, .owner = owner_id, @@ -113,6 +125,7 @@ pub fn capture(editor: *Editor, plugin: ?*sdk.Plugin) CaptureError!KeptDocuments .dirty = dirty, .state = state, .unsaved = unsaved, + .save_as = save_as, .stamp = if (dirty) .{} else Stamp.of(path) orelse .{}, }); } @@ -135,24 +148,26 @@ fn freeKept(gpa: std.mem.Allocator, d: Kept) void { gpa.free(d.owner); if (d.state) |s| gpa.free(s); if (d.unsaved) |u| gpa.free(u); + if (d.save_as) |a| gpa.free(a); } /// Bring each document back through `plugin` (the new build), into the tab waiting for it. pub fn reattach(self: *KeptDocuments, editor: *Editor, plugin: *sdk.Plugin) void { var host = &editor.app.host; for (self.docs.items) |d| { - const doc = load(editor, plugin, d) catch |err| { + const loaded = load(editor, plugin, d) catch |err| { dvui.log.err("reload: could not reopen {s}: {t}", .{ d.path, err }); - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); // Its tab waits for nothing now. if (sdk.document.surfaceId(host.arena(), plugin.id, d.path)) |sid| editor.workbench.tabClosed(sid) else |_| {} continue; }; - const restored = if (d.state) |s| plugin.restoreDocumentState(doc, s) else true; + const doc = loaded.doc; + const restored = if (d.state) |s| plugin.restoreDocumentState(doc, s) or loaded.whole else true; if (!restored) { // The new build does not read the old one's state: the document is as on disk. dvui.log.warn("reload: {s} reopened as saved; the new build could not read its state", .{d.path}); - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); // It was loaded empty for the state to fill: read it, or close it when there is // nothing on disk to read (never saved). if (!plugin.reloadDocument(doc)) { @@ -181,31 +196,41 @@ pub fn reattach(self: *KeptDocuments, editor: *Editor, plugin: *sdk.Plugin) void /// recovery files, and the tabs waiting for the documents go. pub fn abandon(self: *KeptDocuments, editor: *Editor, plugin_id: []const u8) void { for (self.docs.items) |d| { - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); const sid = sdk.document.surfaceId(editor.app.host.arena(), plugin_id, d.path) catch continue; editor.workbench.tabClosed(sid); } editor.rebuildWorkspaces() catch {}; } -/// Open `d.path` through `plugin`, now, in its pane. One with state to restore starts empty, since -/// the state fills it (and an untitled one has nothing on disk to read); only one without is read -/// from disk. -fn load(editor: *Editor, plugin: *sdk.Plugin, d: Kept) !sdk.DocHandle { +const Loaded = struct { + doc: sdk.DocHandle, + /// Opened from its unsaved contents (a document never saved), so it is whole before any state + /// is restored: a state the owner refuses costs only what the bytes do not hold (the caret, + /// the scroll). + whole: bool, +}; + +/// Open `d.path` through `plugin`, now, in its pane. One never saved opens from its unsaved bytes +/// when its owner can (`loadUntitledDocument`). Otherwise one with state to restore starts empty, +/// since the state fills it (and an untitled one has nothing on disk to read); only one without is +/// read from disk. +fn load(editor: *Editor, plugin: *sdk.Plugin, d: Kept) !Loaded { return loadInto(editor, plugin, d, d.grouping); } -fn loadInto(editor: *Editor, plugin: *sdk.Plugin, d: Kept, grouping: u64) !sdk.DocHandle { +fn loadInto(editor: *Editor, plugin: *sdk.Plugin, d: Kept, grouping: u64) !Loaded { const gpa = editor.app.gpa; const staging = try sdk.document.allocStaging(plugin, gpa); defer staging.deinit(gpa); - if (d.state != null) { + const whole = if (d.save_as) |as| if (d.unsaved) |u| try plugin.loadUntitledDocument(d.path, as, u, staging.buf.ptr) else false else false; + if (whole) {} else if (d.state != null) { try sdk.document.loadBytesIntoStaging(plugin, d.path, "", staging); } else try sdk.document.loadIntoStaging(plugin, d.path, staging); const id = plugin.documentIdFromBuffer(staging.buf.ptr); plugin.setDocumentGroupingOnBuffer(staging.buf.ptr, grouping); try editor.insertOpenDoc(staging.buf.ptr, plugin, id); - return editor.app.docById(id) orelse error.DocumentNotFound; + return .{ .doc = editor.app.docById(id) orelse return error.DocumentNotFound, .whole = whole }; } /// What `save` writes as `session.zon`, beside a file per document's state and unsaved contents @@ -228,6 +253,8 @@ const Saved = struct { dirty: bool = false, has_state: bool = false, has_unsaved: bool = false, + /// `Kept.save_as`; empty for a document with a file. + save_as: []const u8 = "", size: u64 = 0, mtime_ns: i96 = 0, }; @@ -317,6 +344,7 @@ fn writeFiles(self: *const KeptDocuments, dir: []const u8, generation: u32) ![]u .dirty = d.dirty, .has_state = true, .has_unsaved = d.unsaved != null, + .save_as = d.save_as orelse "", .size = d.stamp.size, .mtime_ns = d.stamp.mtime_ns, }); @@ -388,6 +416,8 @@ fn readKept(gpa: std.mem.Allocator, dir: []const u8, generation: u32, i: usize, const state: ?[]u8 = if (d.has_state) try readBlob(gpa, dir, generation, i, "state") else null; errdefer if (state) |s| gpa.free(s); const unsaved: ?[]u8 = if (d.has_unsaved) try readBlob(gpa, dir, generation, i, "unsaved") else null; + errdefer if (unsaved) |u| gpa.free(u); + const save_as: ?[]const u8 = if (d.save_as.len > 0) try gpa.dupe(u8, d.save_as) else null; return .{ .path = path, .owner = owner, @@ -397,48 +427,62 @@ fn readKept(gpa: std.mem.Allocator, dir: []const u8, generation: u32, i: usize, .dirty = d.dirty, .state = state, .unsaved = unsaved, + .save_as = save_as, .stamp = .{ .size = d.size, .mtime_ns = d.mtime_ns }, }; } +pub const Reopened = enum { + /// The session does not hold it (or a clean document's file changed since): the caller opens + /// it from disk as any launch does. + not_kept, + opened, + /// The session held a document never saved and could not bring it back. Its unsaved contents + /// are in a recovery file; there is no file to open instead. + lost, +}; + /// Open `path` from the session, into pane `grouping`, when the session holds it: from its kept -/// state, restored, with no disk read. Null when it does not (or a clean document's file changed -/// since), and the caller opens it from disk as any launch does. A document the session held but -/// could not bring back is opened from disk too, its unsaved contents kept in a recovery file. -pub fn openKept(self: *KeptDocuments, editor: *Editor, path: []const u8, grouping: u64) ?sdk.DocHandle { +/// state, restored, with no disk read. A document with a file that the session held but could not +/// bring back is opened from disk by the caller (`not_kept`), its unsaved contents kept in a +/// recovery file. +pub fn openKept(self: *KeptDocuments, editor: *Editor, path: []const u8, grouping: u64) Reopened { const i = for (self.docs.items, 0..) |d, i| { if (std.mem.eql(u8, d.path, path)) break i; - } else return null; + } else return .not_kept; const d = self.docs.swapRemove(i); defer freeKept(self.gpa, d); + // A document never saved has no file to open in its place. + const instead: Reopened = if (d.save_as != null) .lost else .not_kept; if (!d.dirty) { - const now = Stamp.of(d.path) orelse return null; - if (now.size != d.stamp.size or now.mtime_ns != d.stamp.mtime_ns) return null; + const now = Stamp.of(d.path) orelse return .not_kept; + if (now.size != d.stamp.size or now.mtime_ns != d.stamp.mtime_ns) return .not_kept; } const plugin = editor.app.host.pluginById(d.owner) orelse { - dvui.log.warn("restart: {s} was open in '{s}', which is not loaded now; it opens from disk", .{ d.path, d.owner }); - if (d.unsaved) |u| recover(editor, d.path, u); - return null; + dvui.log.warn("restart: {s} was open in '{s}', which is not loaded now", .{ d.path, d.owner }); + recover(editor, d); + return instead; }; - const doc = loadInto(editor, plugin, d, grouping) catch |err| { + const loaded = loadInto(editor, plugin, d, grouping) catch |err| { dvui.log.err("restart: could not reopen {s}: {t}", .{ d.path, err }); - if (d.unsaved) |u| recover(editor, d.path, u); - return null; + recover(editor, d); + return instead; }; - const restored = if (d.state) |s| plugin.restoreDocumentState(doc, s) else true; + const doc = loaded.doc; + const restored = if (d.state) |s| plugin.restoreDocumentState(doc, s) or loaded.whole else true; if (!restored) { dvui.log.warn("restart: {s} reopened as saved; this build could not read its state", .{d.path}); - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); // It was loaded empty for the state to fill: the caller opens it from disk instead. editor.rawCloseFileID(doc.id) catch {}; - return null; + return instead; } if (d.preview) editor.setDocumentPreview(doc.id, true); if (d.selected) self.keepSelection(plugin.id, d.path, grouping) catch {}; if (self.active) |a| if (std.mem.eql(u8, a, d.path)) { if (editor.app.open_files.getIndex(doc.id)) |idx| editor.workbench.setActiveDocIndex(idx); }; - return doc; + return .opened; } fn keepSelection(self: *KeptDocuments, owner_id: []const u8, path: []const u8, grouping: u64) !void { @@ -454,7 +498,7 @@ fn keepSelection(self: *KeptDocuments, owner_id: []const u8, path: []const u8, g /// typed is lost, written to recovery files. What is left is the panes' selections. pub fn finishDocuments(self: *KeptDocuments, editor: *Editor) void { for (self.docs.items) |d| { - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); freeKept(self.gpa, d); } self.docs.clearRetainingCapacity(); @@ -467,8 +511,11 @@ pub fn applySelections(self: *KeptDocuments, editor: *Editor) void { } /// Unsaved contents the new build could not take, written beside fizzy's config so nothing typed -/// is lost, and the person told where. -fn recover(editor: *Editor, path: []const u8, unsaved: []const u8) void { +/// is lost, and the person told where. One never saved is named by its title and the extension it +/// was encoded under, so the file opens in its owner. +fn recover(editor: *Editor, d: Kept) void { + const unsaved = d.unsaved orelse return; + const path = d.path; // A page has no folder to keep them in (and no reload to lose them to: a web plugin is a // side module, never swapped). if (comptime builtin.target.cpu.arch == .wasm32) { @@ -479,7 +526,8 @@ fn recover(editor: *Editor, path: []const u8, unsaved: []const u8) void { const dir = std.fs.path.join(gpa, &.{ editor.app.config_folder, "recovered" }) catch return; defer gpa.free(dir); std.Io.Dir.cwd().createDirPath(dvui.io, dir) catch {}; - const name = std.fmt.allocPrint(gpa, "{d}-{s}", .{ std.Io.Clock.real.now(dvui.io).toSeconds(), std.fs.path.basename(path) }) catch return; + const ext = if (d.save_as) |a| std.fs.path.extension(a) else ""; + const name = std.fmt.allocPrint(gpa, "{d}-{s}{s}", .{ std.Io.Clock.real.now(dvui.io).toSeconds(), std.fs.path.basename(path), ext }) catch return; defer gpa.free(name); const out = std.fs.path.join(gpa, &.{ dir, name }) catch return; defer gpa.free(out); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index c8f29d887..4d138bbbd 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -162,6 +162,7 @@ const fizzy_commands = [_]FizzyCommand{ .{ .id = "fizzy.toggleExplorer", .title = "Toggle Explorer", .bind = "explorer", .run = cmdToggleExplorer, .icon = icons.tvg.lucide.@"panel-left" }, .{ .id = "fizzy.resetLayout", .title = "Reset Layout", .bind = null, .run = cmdResetLayout, .icon = icons.tvg.lucide.@"rotate-ccw" }, .{ .id = "fizzy.toggleFullScreen", .title = "Toggle Full Screen", .bind = null, .run = cmdToggleFullScreen, .icon = icons.tvg.lucide.maximize }, + .{ .id = "fizzy.fitView", .title = "Fit to View", .bind = null, .run = cmdFitView, .isEnabled = cmdFitViewEnabled, .icon = icons.tvg.lucide.scan }, .{ .id = "fizzy.deleteSelection", .title = "Delete Selection", .bind = "delete_selection_contents", .run = cmdDeleteSelection, .isEnabled = cmdDeleteSelectionEnabled, .icon = icons.tvg.lucide.@"trash-2" }, .{ .id = "fizzy.accept", .title = "Accept", .bind = "activate", .run = cmdAccept, .isEnabled = cmdAcceptEnabled, .icon = icons.tvg.lucide.check }, .{ .id = "fizzy.cancel", .title = "Cancel", .bind = "cancel", .run = cmdCancel, .isEnabled = cmdCancelEnabled, .icon = icons.tvg.lucide.x }, @@ -175,6 +176,9 @@ const fizzy_commands = [_]FizzyCommand{ .{ .id = "fizzy.restart", .title = "Restart Fizzy", .bind = null, .run = cmdRestart, .isEnabled = canRestart, .icon = icons.tvg.lucide.@"rotate-ccw" }, .{ .id = "fizzy.reportBug", .title = "Report a Bug", .bind = null, .run = cmdReportBug, .icon = icons.tvg.lucide.bug }, .{ .id = "fizzy.capture", .title = "Capture the Last 30 Seconds", .bind = null, .run = cmdCapture, .params = CaptureArgs.params, .runWith = CaptureArgs.bind(cmdCaptureWith), .icon = icons.tvg.lucide.gauge }, + .{ .id = "fizzy.tracks.record", .title = "Tracks: Record…", .bind = null, .run = cmdTracks, .params = TracksRecord.params, .runWith = TracksRecord.bind(cmdTracksRecord), .icon = icons.tvg.lucide.gauge }, + .{ .id = "fizzy.tracks.replay", .title = "Tracks: Replay…", .bind = null, .run = cmdTracks, .params = TracksReplay.params, .runWith = TracksReplay.bind(cmdTracksReplay), .icon = icons.tvg.lucide.gauge }, + .{ .id = "fizzy.tracks", .title = "Tracks: Report", .bind = null, .run = cmdTracks, .params = TracksArgs.params, .runWith = TracksArgs.bind(cmdTracksWith), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.writeTrace", .title = "Write Trace", .bind = null, .run = cmdWriteTrace, .params = TraceArgs.params, .runWith = TraceArgs.bind(cmdWriteTraceTo), .icon = icons.tvg.lucide.gauge }, } ++ open_folder_commands ++ demo_commands ++ automation_commands; @@ -482,6 +486,14 @@ fn cmdCancel(state: *anyopaque) anyerror!void { try editorFromState(state).cancel(); } +/// The active document's view fitted to its pane again (`<owner>.fitView`): an image or a sprite +/// the person has panned and zoomed, back where a new one opens. +fn cmdFitView(state: *anyopaque) anyerror!void { + try editorFromState(state).runActiveDocCommand("fitView"); +} +fn cmdFitViewEnabled(state: *anyopaque) bool { + return editorFromState(state).activeDocCommandEnabled("fitView"); +} fn cmdUndoEnabled(state: *anyopaque) bool { return editorFromState(state).activeDocCommandEnabled("undo"); } @@ -575,6 +587,71 @@ fn cmdCaptureWith(state: *anyopaque, args: CaptureArgs.Args, call: *sdk.Command. try call.returns(written); } +const TracksArgs = sdk.Command.Params(struct {}); +const TracksRecord = sdk.Command.Params(struct { + on: sdk.Command.Arg(bool, .{ .description = "Record documents' tracks, or stop. Open documents start a track now." }), +}); + +const TracksReplay = sdk.Command.Params(struct { + from: sdk.Command.Arg([]const u8, .{ .description = "The open document whose track to replay, by its path." }), + into: sdk.Command.Arg([]const u8, .{ .description = "The open document to replay it into, by its path: one of the same owner, which records none of it." }), + steps: sdk.Command.Arg(u32, .{ .description = "How many of the track's steps; 0 for all of them." }) = .init(0), +}); + +/// Put one open document where another's track was after so many steps: its nearest state, then +/// the steps since (`Tracks.replayInto`). How an agent checks a track replays, and how a person +/// sees one played into a copy. +fn cmdTracksReplay(state: *anyopaque, args: TracksReplay.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + const t = &(editor.app.tracks orelse return call.fail("no tracks in this app", .{})); + const from = editor.docFromPath(args.from) orelse return call.fail("no open document at {s}", .{args.from}); + const into = editor.docFromPath(args.into) orelse return call.fail("no open document at {s}", .{args.into}); + const track = t.trackOf(from.id) orelse return call.fail("{s} has no track", .{args.from}); + const n = if (args.steps == 0) track.steps.items.len else args.steps; + t.replayInto(from.id, into, n) catch |err| return call.fail("the replay stopped: {t}", .{err}); + try call.returns(.{ .steps = @min(n, track.steps.items.len), .checkpoints = track.checkpoints.items.len }); +} + +fn cmdTracksRecord(state: *anyopaque, args: TracksRecord.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + editor.setTracking(args.on); + try call.returns(.{ .on = args.on }); +} + +/// What the documents' tracks hold (`app.Tracks`): whether they record, what every track holds +/// against the cap, how many frames did any of their work, and each track's steps, checkpoints +/// and bytes. Devtools' view of them, and an agent's. +fn cmdTracks(state: *anyopaque) anyerror!void { + const t = &(editorFromState(state).app.tracks orelse return); + dvui.log.info("tracks: {d} open, {d} ended, {d} KB of {d} KB", .{ t.open.count(), t.ended.items.len, t.held >> 10, t.cap >> 10 }); +} + +fn cmdTracksWith(state: *anyopaque, _: TracksArgs.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + const t = &(editor.app.tracks orelse return call.fail("no tracks in this app", .{})); + const arena = editor.app.host.arena(); + const rows = try arena.alloc(TrackRow, t.open.count() + t.ended.items.len); + for (t.open.values(), 0..) |track, i| rows[i] = .of(track, true, editor); + for (t.ended.items, t.open.count()..) |track, i| rows[i] = .of(track, false, editor); + try call.returns(.{ .on = t.on, .held = t.held, .cap = t.cap, .worked = t.worked, .tracks = rows }); +} + +/// A track as `fizzy.tracks` reports it. An ended track's document is gone, so it has no path. +const TrackRow = struct { + doc_id: u64, + path: []const u8, + owner: []const u8, + open: bool, + steps: usize, + checkpoints: usize, + bytes: usize, + + fn of(track: *const @import("app").Tracks.Track, open: bool, editor: *Editor) TrackRow { + const path = if (!open) "" else if (editor.app.docById(track.doc_id)) |d| d.owner.documentPath(d) else ""; + return .{ .doc_id = track.doc_id, .path = path, .owner = track.owner.id, .open = open, .steps = track.steps.items.len, .checkpoints = track.checkpoints.items.len, .bytes = track.size() }; + } +}; + const TraceArgs = sdk.Command.Params(struct { path: sdk.Command.Arg([]const u8, .{ .description = "Where to write the trace (JSON). Empty writes it in the config folder's traces/, named by the time." }) = .init(""), ms: sdk.Command.Arg(u32, .{ .description = "Only the spans that ended in the last this many milliseconds; 0 for every one kept." }) = .init(0), @@ -673,6 +750,7 @@ const vscode_defaults = [_]DefaultBind{ .{ .command = "fizzy.commandPalette", .keys = "mod+shift+p" }, // The platform's own: F11 in VSCode and browsers, ⌃⌘F in every macOS app. .{ .command = "fizzy.toggleFullScreen", .keys = "f11", .keys_mac = "ctrl+cmd+f" }, + .{ .command = "fizzy.fitView", .keys = "mod+0" }, } ++ open_folder_defaults ++ close_defaults; /// The web has no folder to open — a page cannot read a directory tree — so Open Folder does not @@ -720,6 +798,7 @@ pub const document_verbs = [_]DocumentVerb{ .{ .action = "undo", .fizzy_id = "fizzy.undo" }, .{ .action = "redo", .fizzy_id = "fizzy.redo" }, .{ .action = "deleteSelection", .fizzy_id = "fizzy.deleteSelection" }, + .{ .action = "fitView", .fizzy_id = "fizzy.fitView" }, .{ .action = "acceptEdit", .fizzy_id = "fizzy.accept" }, .{ .action = "cancelEdit", .fizzy_id = "fizzy.cancel" }, .{ .action = "transform", .fizzy_id = null }, diff --git a/src/editor/Openings.zig b/src/editor/Openings.zig index 2c725b64c..52838a7e3 100644 --- a/src/editor/Openings.zig +++ b/src/editor/Openings.zig @@ -32,6 +32,8 @@ pub const Opening = struct { preview: bool, /// Why the load failed, once it has. The placeholder shows it until its tab is closed. failed: ?[]u8 = null, + /// Shown beside the person's pane once it lands (`OpenOptions.beside`). + beside: bool = false, /// Frames since the document took the tab. Null while still loading. landed: ?u8 = null, /// Asked to be shown before its pane had a region to show it in (an open at launch, before @@ -73,7 +75,9 @@ pub fn loading(self: *const Openings) bool { /// The load of `path` has started: put its placeholder in front of the user. `owner` is the /// plugin the document will belong to (the tab slot a restored session kept for it is named by /// its surface id); `take_slot_of` is a tab whose place it takes — the preview it replaces. -pub fn begin(self: *Openings, editor: *Editor, path: []const u8, grouping: u64, preview: bool, owner: *sdk.Plugin, take_slot_of: ?[]const u8) void { +pub fn begin(self: *Openings, editor: *Editor, path: []const u8, grouping: u64, placement: Editor.Placement, owner: *sdk.Plugin) void { + const preview = placement.preview; + const take_slot_of = placement.take_slot_of; if (self.entries.contains(path)) return; const o = self.gpa.create(Opening) catch return; o.* = .{ @@ -85,6 +89,7 @@ pub fn begin(self: *Openings, editor: *Editor, path: []const u8, grouping: u64, .surface_id = undefined, .grouping = grouping, .preview = preview, + .beside = placement.beside, }; o.surface_id = std.mem.concat(self.gpa, u8, &.{ surface_prefix, path }) catch { self.gpa.free(o.path); @@ -162,6 +167,7 @@ pub fn land(self: *Openings, editor: *Editor, path: []const u8, doc: sdk.DocHand } } else |_| {} if (o.preview) editor.setDocumentPreview(doc.id, true); + if (o.beside) wb.showBeside(doc); _ = wb.loading.swapRemove(o.surface_id); o.landed = 0; host.refresh(); diff --git a/src/editor/SettingsTree.zig b/src/editor/SettingsTree.zig index c37e21718..33d302eda 100644 --- a/src/editor/SettingsTree.zig +++ b/src/editor/SettingsTree.zig @@ -46,7 +46,7 @@ const fizzy_branch_title = @import("app").AppInfo.current.display_name; /// made a collapsed plugin flash open on first draw. Second, `Branch` rewrites its own /// `_expanded` dvui data every frame, so force-expanding during a search would destroy the user's /// hand-picked state with nothing left to restore; a separate map (the same thing the file tree -/// does with `Explorer.open_branches`) lets a search pass `expanded = true` freely as long as it +/// does with `Workbench.open_branches`) lets a search pass `expanded = true` freely as long as it /// doesn't *write back*. A path key also survives rows being reordered by relevance. var open_branches: std.AutoHashMapUnmanaged(u64, void) = .empty; diff --git a/src/editor/Workspaces.zig b/src/editor/Workspaces.zig new file mode 100644 index 000000000..b8da92f82 --- /dev/null +++ b/src/editor/Workspaces.zig @@ -0,0 +1,397 @@ +//! The workbench's document panes as views of the app's own, behind `FIZZY_WORKSPACES=1`: each +//! pane is a surface, `fizzy.workspace.<grouping>`, that sits in a place like any view — Main +//! shows the first by its keywords, a split of a place holds the next, a float can hold one, and +//! the picker and the view drag move them. What a pane draws is the workbench's own pane +//! (`Workspace.draw`): its tabs, its document slot, its home page. What is gone is the +//! workbench's arrangement of them (`Workbench.panes`): a pane opened beside another is a split +//! of the app's (`Region.splitOn`), and one emptied closes its place as any emptied view's does. +//! +//! The first step of `plans/WORKSPACES_PLAN.md`'s workspaces (step 2): documents still go into +//! panes through the workbench, and its `panes.zon` is not read while this is on. +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const fizzy = @import("../fizzy.zig"); +const sdk = fizzy.sdk; +const app = @import("app"); +const Editor = @import("Editor.zig"); +const Workbench = Editor.Workbench; +const Layout = app.layout.Layout; + +const Workspaces = @This(); + +/// Each pane's view, by its grouping. +views: std.AutoArrayHashMapUnmanaged(u64, *View) = .empty, +/// Whether last session's layout has been squared with the panes that came back, once the +/// layout has drawn (`settle`). +settled: bool = false, + +const View = struct { + editor: *Editor, + grouping: u64, + /// The surface id, `fizzy.workspace.<grouping>`. + id: []u8, +}; + +pub const id_prefix = "fizzy.workspace."; + +/// Whether `FIZZY_WORKSPACES=1` asked for this. Never on the web, which has no environment. +pub fn wanted(gpa: std.mem.Allocator) bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + const v = std.process.Environ.getAlloc(fizzy.core.platform.processEnviron(), gpa, "FIZZY_WORKSPACES") catch return false; + defer gpa.free(v); + return std.mem.eql(u8, v, "1"); +} + +/// Hand the workbench the app's places to arrange its panes in. Before the workbench registers, +/// so it knows not to offer its one surface of every pane, and with `self` at its final address. +pub fn install(self: *Workspaces, wb: *Workbench) void { + wb.places = .{ .ctx = self, .beside = beside, .placed = placed, .close = close, .open = open }; + // A document carried beside a view, rather than into a pane, opens a workspace there. + self.editorOf().app.layout.documents = .{ .ctx = self, .house = house, .alone = alone }; +} + +pub fn deinit(self: *Workspaces, gpa: std.mem.Allocator) void { + for (self.views.values()) |v| { + gpa.free(v.id); + gpa.destroy(v); + } + self.views.deinit(gpa); +} + +fn editorOf(self: *Workspaces) *Editor { + return @alignCast(@fieldParentPtr("workspaces", self)); +} + +/// The view id of `grouping`'s pane, in `buf`. +fn viewId(buf: []u8, grouping: u64) []const u8 { + return std.fmt.bufPrint(buf, id_prefix ++ "{d}", .{grouping}) catch id_prefix; +} + +/// Bring the views in line with the workbench's panes, once a frame after it has rebuilt them +/// and before anything draws: a view for every pane, none for a pane that has gone, each titled +/// by the document its pane shows. A pane that has no place yet — opened before the layout had +/// drawn, so there was no room to split — is given one beside the first. +pub fn sync(self: *Workspaces) void { + const editor = self.editorOf(); + const wb = &editor.workbench; + if (wb.places == null) return; + const gpa = editor.app.gpa; + const host = &editor.app.host; + + self.seat(); + + var i: usize = 0; + while (i < self.views.count()) { + const g = self.views.keys()[i]; + if (wb.workspaces.contains(g)) { + i += 1; + continue; + } + const v = self.views.values()[i]; + host.unregisterSurface(v.id); + gpa.free(v.id); + gpa.destroy(v); + self.views.orderedRemoveAt(i); + } + + for (wb.workspaces.keys()) |g| { + if (self.views.contains(g)) continue; + self.register(g) catch |err| dvui.log.err("workspaces: no view for pane {d}: {t}", .{ g, err }); + } + + for (wb.workspaces.values()) |*ws| { + const v = self.views.get(ws.grouping) orelse continue; + const title = blk: { + const doc = ws.active orelse break :blk "Workspace"; + const id = sdk.document.surfaceId(host.arena(), doc.owner.id, doc.owner.documentPath(doc)) catch break :blk "Workspace"; + break :blk if (host.surfaceById(id)) |s| s.title else "Workspace"; + }; + const s = host.surfaceById(v.id) orelse continue; + if (!std.mem.eql(u8, s.title, title)) host.setSurfaceTitle(v.id, title) catch {}; + } + + // Every pane but one is somewhere it was put; the one is where the keywords show it. More + // than one with nowhere to be would all be Main's, and Main shows one. + if (editor.app.layout.regions.items.len == 0) return; + if (!self.settled) { + self.settled = true; + self.settle(); + } + var first: ?u64 = null; + for (wb.workspaces.keys()) |g| { + if (self.assigned(g)) continue; + if (first == null) { + first = g; + continue; + } + _ = beside(self, g, first.?, .right); + } +} + +/// Every open document in a workspace: one that no pane holds — its load has just landed, or +/// its plugin came back with it — goes into the workspace it was opened toward, the grouping the +/// app stamped on it ("Open to the Side"'s, or the active workspace's). A tab under another +/// owner's id for the same file (a plugin installed since last session) is replaced where it was. +fn seat(self: *Workspaces) void { + const editor = self.editorOf(); + const wb = &editor.workbench; + const host = &editor.app.host; + var i: usize = 0; + while (i < host.openDocCount()) : (i += 1) { + const doc = host.docByIndex(i) orelse continue; + const path = doc.owner.documentPath(doc); + const id = sdk.document.surfaceId(host.arena(), doc.owner.id, path) catch continue; + const held = for (wb.workspaces.values()) |*ws| { + if (ws.hasTab(id)) break true; + } else false; + if (held) continue; + var stale_in: ?u64 = null; + for (wb.workspaces.values()) |*ws| { + if (ws.removeTabsForPath(path)) stale_in = ws.grouping; + } + // Grouping 0 is the one the person is in, once the first workspace has gone. + const want = doc.owner.documentGrouping(doc); + const toward = if (want == 0 and !wb.workspaces.contains(0)) wb.open_workspace_grouping else want; + const ws = wb.pane(stale_in orelse toward) catch continue; + ws.addTab(id, false); + } +} + +/// Square last session's layout with the panes that came back, the first frame there are places +/// to read. A place whose workspace did not come back (it held no document at quit, or none that +/// could open) closes, as it would have had it emptied. And an arrangement the workbench saved +/// before the app arranged its panes (`panes.zon`) becomes the app's: its tree, split for split, +/// out of the place the first workspace is in, each pane's workspace in its half. Then the file +/// goes, the arrangement being the layout's to keep from here on. +fn settle(self: *Workspaces) void { + const editor = self.editorOf(); + const wb = &editor.workbench; + var l = layoutOf(editor); + + var gone: std.ArrayListUnmanaged([]const u8) = .empty; + var any_placed = false; + var it = editor.app.layout.assignments.valueIterator(); + while (it.next()) |ids| for (ids.*) |id| { + if (!std.mem.startsWith(u8, id, id_prefix)) continue; + const g = std.fmt.parseInt(u64, id[id_prefix.len..], 10) catch continue; + if (wb.workspaces.contains(g)) { + any_placed = true; + } else gone.append(l.arena, l.arena.dupe(u8, id) catch continue) catch {}; + }; + for (gone.items) |id| _ = Layout.ViewDrag.withdraw(&l, id); + + const panes = &wb.panes; + const root = panes.nodes.items[panes.root]; + // Placed already, the layout is the app's; a single pane is no arrangement. + if (!any_placed and root == .split) carry: { + const first = firstLive(wb, panes.root) orelse break :carry; + var buf: [64]u8 = undefined; + const place = Layout.ViewDrag.holderOf(&l, viewId(&buf, first)) orelse break :carry; + const size = Layout.ViewDrag.placeSize(l.state, place) orelse break :carry; + carryOver(self, &l, panes.root, place, size); + } + wb.forgetPanes(); +} + +/// Lay `node` of the workbench's old tree out in place `name`, `size` big: a pane's workspace is +/// put there, and a split splits it, the second half as big a share as it had. +fn carryOver(self: *Workspaces, l: *Layout, node: Workbench.Panes.NodeIndex, name: []const u8, size: dvui.Size) void { + const wb = &self.editorOf().workbench; + switch (wb.panes.nodes.items[node]) { + .free => {}, + .leaf => { + const g = firstLive(wb, node) orelse return; + var buf: [64]u8 = undefined; + const id = l.arena.dupe(u8, viewId(&buf, g)) catch return; + l.state.assign(l.gpa, name, &.{id}) catch {}; + }, + .split => |sp| { + if (firstLive(wb, sp.first) == null) return carryOver(self, l, sp.second, name, size); + if (firstLive(wb, sp.second) == null) return carryOver(self, l, sp.first, name, size); + const across = sp.dir == .horizontal; + const share = 1 - std.math.clamp(sp.ratio, 0, 1); + const owned = l.arena.dupe(u8, name) catch return; + const new = Layout.Region.splitOnSized(l, owned, if (across) .right else .bottom, size, share) orelse + return carryOver(self, l, sp.first, name, size); + const second = l.arena.dupe(u8, new) catch return; + var a = size; + var b = size; + if (across) { + a.w = size.w * (1 - share); + b.w = size.w * share; + } else { + a.h = size.h * (1 - share); + b.h = size.h * share; + } + carryOver(self, l, sp.first, owned, a); + carryOver(self, l, sp.second, second, b); + }, + } +} + +/// The first pane under `node` of the old tree that came back, by its grouping. +fn firstLive(wb: *Workbench, node: Workbench.Panes.NodeIndex) ?u64 { + switch (wb.panes.nodes.items[node]) { + .free => return null, + .leaf => |leaf| { + for (leaf.tabs.items) |t| { + const g = Editor.Workspace.groupingOfName(t) orelse continue; + if (wb.workspaces.contains(g)) return g; + } + return null; + }, + .split => |sp| return firstLive(wb, sp.first) orelse firstLive(wb, sp.second), + } +} + +fn register(self: *Workspaces, grouping: u64) !void { + const editor = self.editorOf(); + const gpa = editor.app.gpa; + const v = try gpa.create(View); + errdefer gpa.destroy(v); + var buf: [64]u8 = undefined; + v.* = .{ .editor = editor, .grouping = grouping, .id = try gpa.dupe(u8, viewId(&buf, grouping)) }; + errdefer gpa.free(v.id); + try self.views.put(gpa, grouping, v); + errdefer _ = self.views.orderedRemove(grouping); + try editor.app.host.registerSurface(.{ + .id = v.id, + .title = "Workspace", + .keywords = sdk.keywords.ide.main, + .ctx = v, + .draw = drawView, + }); +} + +fn drawView(ctx: ?*anyopaque) anyerror!dvui.App.Result { + const v: *View = @ptrCast(@alignCast(ctx.?)); + const ws = v.editor.workbench.workspaces.getPtr(v.grouping) orelse return .ok; + return ws.draw(); +} + +/// Whether some place's list names `grouping`'s view: put there by a split, a drop or a session. +fn assigned(self: *Workspaces, grouping: u64) bool { + const editor = self.editorOf(); + var buf: [64]u8 = undefined; + const id = viewId(&buf, grouping); + var it = editor.app.layout.assignments.valueIterator(); + while (it.next()) |ids| for (ids.*) |x| if (std.mem.eql(u8, x, id)) return true; + return false; +} + +fn layoutOf(editor: *Editor) Layout { + return Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, editor.app.host.arena()); +} + +fn beside(ctx: *anyopaque, grouping: u64, anchor: u64, side: Workbench.Panes.Side) bool { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + const editor = self.editorOf(); + var l = layoutOf(editor); + var abuf: [64]u8 = undefined; + const place = Layout.ViewDrag.holderOf(&l, viewId(&abuf, anchor)) orelse return false; + const new = Layout.splitOn(&l, place, switch (side) { + .left => .left, + .right => .right, + .top => .top, + .bottom => .bottom, + }) orelse return false; + var buf: [64]u8 = undefined; + const id = l.arena.dupe(u8, viewId(&buf, grouping)) catch return false; + l.state.assign(l.gpa, new, &.{id}) catch return false; + l.state.markDirty(); + return true; +} + +/// A new workspace holding the documents `ids` (`Layout.State.documents`), for the layout to +/// place as it lands any view: each moved out of the pane it was in, or opened in it when it is +/// not open yet, the first shown. Its view is registered at once, so the place it lands in can +/// select it this frame. Null, and no workspace, when none went in. +fn house(ctx: *anyopaque, ids: []const []const u8) ?[]const u8 { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + const editor = self.editorOf(); + const wb = &editor.workbench; + const host = &editor.app.host; + const g = wb.newGroupingID(); + const gop = wb.workspaces.getOrPut(wb.allocator, g) catch return null; + if (!gop.found_existing) gop.value_ptr.* = Editor.Workspace.init(g); + // Empty until a load lands, not emptied: the rebuild keeps it. + gop.value_ptr.expecting = true; + + var landed = false; + // The first last, so it is the one shown: a document shows as it goes in. + var i = ids.len; + while (i > 0) { + i -= 1; + const id = ids[i]; + if (host.surfaceById(id) == null) { + const path = sdk.document.pathOfSurfaceId(id) orelse continue; + landed = (host.openFile(.{ .path = path, .grouping = g }) catch false) or landed; + continue; + } + for (wb.workspaces.values()) |*ws| if (ws.grouping != g) ws.removeTab(id); + const ws = wb.workspaces.getPtr(g) orelse return null; + ws.addTab(id, true); + landed = true; + } + if (!landed) { + if (wb.workspaces.getPtr(g)) |ws| ws.deinit(); + _ = wb.workspaces.orderedRemove(g); + return null; + } + self.register(g) catch return null; + return self.views.get(g).?.id; +} + +fn open(ctx: *anyopaque, grouping: u64, side: Workbench.Panes.Side) bool { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + var l = layoutOf(self.editorOf()); + var buf: [64]u8 = undefined; + const place = Layout.ViewDrag.holderOf(&l, viewId(&buf, grouping)) orelse return false; + return Layout.splitOn(&l, place, switch (side) { + .left => .left, + .right => .right, + .top => .top, + .bottom => .bottom, + }) != null; +} + +/// The view of the workspace whose only tab is document `id`, if one is. +fn alone(ctx: *anyopaque, id: []const u8) ?[]const u8 { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + for (self.editorOf().workbench.workspaces.values()) |*ws| { + if (ws.tabCount() != 1 or !ws.hasTab(id)) continue; + const v = self.views.get(ws.grouping) orelse return null; + return v.id; + } + return null; +} + +fn placed(ctx: *anyopaque, grouping: u64) bool { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + if (self.assigned(grouping)) return true; + // Unput, it is shown by the keywords only while no other pane is. + for (self.editorOf().workbench.workspaces.keys()) |g| { + if (g != grouping and !self.assigned(g)) return false; + } + return true; +} + +fn close(ctx: *anyopaque, grouping: u64) void { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + const editor = self.editorOf(); + var l = layoutOf(editor); + var buf: [64]u8 = undefined; + const id = l.arena.dupe(u8, viewId(&buf, grouping)) catch return; + if (Layout.ViewDrag.withdraw(&l, id)) return; + // A place that cannot close keeps the room, but not a list naming a view that is going. + for (editor.app.host.assignedRegionNames()) |name| { + const ids = editor.app.host.assignedSurfaces(name) orelse continue; + var kept: std.ArrayListUnmanaged([]const u8) = .empty; + var found = false; + for (ids) |x| { + if (std.mem.eql(u8, x, id)) found = true else kept.append(l.arena, x) catch return; + } + if (found) l.state.assign(l.gpa, l.arena.dupe(u8, name) catch return, kept.items) catch {}; + } +} diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 32ed14bc4..9bbe3f280 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -27,22 +27,10 @@ scroll_info: dvui.ScrollInfo = .{ }, rect: dvui.Rect = .{}, rect_screen: dvui.Rect.Physical = .{}, -open_branches: std.AutoHashMap(dvui.Id, void) = undefined, animations_ratio: f32 = 0.5, closed: bool = false, collapse_btn_anim_started: bool = false, -pub fn init() Explorer { - return .{ - .open_branches = .init(fizzy.entry().allocator), - }; -} - -pub fn deinit(self: *Explorer) void { - // TODO: Free memory - self.open_branches.deinit(); -} - pub fn close(explorer: *Explorer, editor: *fizzy.Editor) void { explorer.closed = true; if (editor.regionFor(fizzy.sdk.keywords.ide.sidebar)) |r| r.close(); @@ -132,11 +120,6 @@ pub fn draw( // place actually shows, which is the only reading that can never say that. const shown = f.selected(keywords); - if (comptime workbench.has_file_tree) { - const showing_files = if (shown) |s| std.mem.eql(u8, s.id, fizzy.Editor.workbench_files_view) else false; - if (!showing_files) editor.resetFileTreeWhenFilesHidden(); - } - if (shown) |surface| { // Through the layout's swap: choosing another view on the rail blurs from one body to // the next, as every region does. The body's own box, so the capture of the outgoing @@ -170,11 +153,9 @@ pub fn draw( } fn drawCollapseButton(explorer: *Explorer, editor: *fizzy.Editor) void { - // Styled to match the floating Edit pill (see `Workspace.drawEditPill`): circular - // background, same content.fill / content.text color pair, same drop shadow. - const button_size: f32 = fizzy.core.widgets.round_button_size; - const btn_radius: f32 = button_size / 2; - const margin: f32 = 8; + // A glass bubble, as the controls over a document are (`core.widgets.glass_button`). + const button_size = fizzy.core.widgets.glass_button.size; + const margin: f32 = 12; const wr = dvui.windowRect(); const r = editor.regionFor(fizzy.sdk.keywords.ide.sidebar) orelse return; @@ -196,57 +177,10 @@ fn drawCollapseButton(explorer: *Explorer, editor: *fizzy.Editor) void { const sized = button_size * s; if (sized < 0.5) return; - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{ .mouse_events = true }, .{ - .rect = .{ - .x = wr.w - margin - sized, - .y = wr.h - margin - sized, - .w = sized, - .h = sized, - }, - }); - defer fw.deinit(); - - var bw: dvui.ButtonWidget = undefined; - bw.init(@src(), .{}, .{ - .label = .{ .text = "Collapse Explorer" }, - .expand = .both, - .corners = dvui.CornerRect.all(btn_radius), - .background = true, - .color_fill = .{ .color = dvui.themeGet().color(.content, .fill) }, - .color_fill_hover = .{ .color = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0) }, - .color_border = .transparent, - .padding = .all(0), - .margin = .all(margin), - .min_size_content = .{ .w = button_size, .h = button_size }, - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 4, - .offset = .{ .x = 0, .y = 2 }, - .corners = dvui.CornerRect.all(btn_radius), - }, - }); - defer bw.deinit(); - bw.processEvents(); - bw.drawBackground(); - - const icon_color = dvui.themeGet().color(.content, .text); - fizzy.core.icon.icon( - @src(), - "collapse_explorer", - icons.tvg.lucide.@"panel-left-close", - .{ .stroke_color = .{ .color = icon_color }, .fill_color = .{ .color = icon_color } }, - .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .min_size_content = .{ .w = 1.0, .h = 1.0 }, - .padding = .all(6), - }, - ); - - if (bw.clicked()) { + // Grows from its own centre. + const centre: dvui.Point = .{ .x = wr.w - margin - button_size / 2, .y = wr.h - margin - button_size / 2 }; + const rect: dvui.Rect = .{ .x = centre.x - sized / 2, .y = centre.y - sized / 2, .w = sized, .h = sized }; + if (fizzy.core.widgets.glass_button.bubble(@src(), rect, icons.tvg.lucide.@"panel-left-close", .{ .label = "Collapse Explorer" })) { explorer.peekClose(editor); } } diff --git a/src/editor/menu_model.zig b/src/editor/menu_model.zig index 2ee10cde5..080d17fa1 100644 --- a/src/editor/menu_model.zig +++ b/src/editor/menu_model.zig @@ -209,6 +209,7 @@ const view_items = [_]Item{ .{ .command = .{ .id = "fizzy.toggleExplorer", .title = .{ .dynamic = explorerTitle } } }, .{ .command = .{ .id = "fizzy.resetLayout", .title = .{ .static = "Reset Layout" }, .sf_symbol = "arrow.counterclockwise" } }, .{ .command = .{ .id = "fizzy.toggleFullScreen", .title = .{ .static = "Toggle Full Screen" }, .sf_symbol = "arrow.up.left.and.arrow.down.right" } }, + .{ .command = .{ .id = "fizzy.fitView", .title = .{ .static = "Fit to View" }, .sf_symbol = "viewfinder" } }, .{ .plugin_section = "fizzy.menu.view" }, .separator, } ++ demo_items ++ [_]Item{ diff --git a/tests/bench/bench_tape.zig b/tests/bench/bench_tape.zig index 6c0dfd740..7248e07cc 100644 --- a/tests/bench/bench_tape.zig +++ b/tests/bench/bench_tape.zig @@ -1,4 +1,5 @@ -//! `zig build bench-tape` — how long a tape takes to save and load, ZON against the binary form. +//! `zig build bench-tape` — how long a tape takes to save and load, ZON against the binary form, +//! and a recording of drawing's size and speed plain against deflated. //! //! Prints timings rather than asserting them: compare runs at the same `-Doptimize` //! (`-Doptimize=ReleaseFast` for numbers worth quoting). The tapes are recording-shaped — glides @@ -63,14 +64,96 @@ fn zonRead(source: [:0]const u8) !void { fn binWrite(t: Tape, out: *std.Io.Writer.Allocating) !void { out.clearRetainingCapacity(); - try binary.write(std.testing.allocator, t, &out.writer); + try binary.write(std.testing.allocator, t, &out.writer, .{}); } fn binRead(bytes: []const u8) !void { - var owned = try binary.read(std.testing.allocator, bytes, .{}); + var owned = try binary.read(std.testing.allocator, bytes, steps_check); owned.deinit(); } +const steps_check = Tape.test_steps; + +/// `n` steps of drawing a small sprite, shaped as a pixi stroke's effect will be: the layer, the +/// pixels' indices as deltas (neighbours, so mostly one byte), their colours before and after in +/// runs, and the pen's path. Not pixi's encoding — that is pixi's (step 4 of +/// plans/RECORDING_PLAN.md measures the real one) — but its size and its sameness. +fn drawing(arena: std.mem.Allocator, n: usize) !Tape { + var prng: std.Random.DefaultPrng = .init(0x51e9); + const r = prng.random(); + const palette = [_][4]u8{ .{ 0, 0, 0, 0 }, .{ 34, 32, 52, 255 }, .{ 69, 40, 60, 255 }, .{ 172, 50, 50, 255 }, .{ 223, 113, 38, 255 }, .{ 251, 242, 54, 255 } }; + const ops = try arena.alloc(Tape.Op, n + 1); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; + var t: u32 = 0; + for (ops[1..]) |*op| { + const pixels = 4 + r.uintLessThan(usize, 56); + var effect: std.ArrayList(u8) = .empty; + try effect.appendSlice(arena, &.{ 1, @intCast(pixels) }); + for (0..pixels) |_| try effect.append(arena, if (r.uintLessThan(u8, 8) == 0) 64 else 1); + // Before: what was there, in a run or three; after: the pen's colour. + for (0..1 + r.uintLessThan(usize, 3)) |_| { + try effect.append(arena, @intCast(1 + r.uintLessThan(usize, pixels))); + try effect.appendSlice(arena, &palette[r.uintLessThan(usize, palette.len)]); + } + try effect.append(arena, @intCast(pixels)); + try effect.appendSlice(arena, &palette[1 + r.uintLessThan(usize, palette.len - 1)]); + // The pen's path: a point every other pixel, a small step and a few ms each. + const motion = try arena.alloc(u8, (pixels / 2) * 3); + for (motion, 0..) |*b, i| b.* = if (i % 3 == 2) 8 + r.uintLessThan(u8, 8) else r.uintLessThan(u8, 3); + const ms: u32 = @intCast(pixels * 12); + t += 400 + r.uintLessThan(u32, 3000); + op.* = .{ .at = t, .ms = ms, .do = .{ .step = .{ .file = "sprite.pixi", .effect = effect.items, .motion = motion } } }; + t += ms; + } + // The sprite as it opened: a small file, already packed, so it barely deflates. + const sprite = try arena.alloc(u8, 6 * 1024); + r.bytes(sprite); + const files = try arena.alloc(Tape.File, 1); + files[0] = .{ .path = "sprite.pixi", .text = sprite, .owner = "pixi", .format = 1 }; + const kf = try arena.alloc(Tape.Keyframe, 1); + kf[0] = .{ .root = "demo://drawing", .files = files, .open = &.{"sprite.pixi"} }; + return .{ .name = "drawing", .ops = ops, .keyframes = kf }; +} + +fn binWriteWith(t: Tape, out: *std.Io.Writer.Allocating, opts: binary.Options) !void { + out.clearRetainingCapacity(); + try binary.write(std.testing.allocator, t, &out.writer, opts); +} + +test "bench tape: a recording of drawing, plain against deflated" { + std.debug.print("\n{s:>9} {s:>10} | {s:>9} {s:>9} {s:>9} | {s:>9} {s:>9} {s:>9} | {s:>7}\n", .{ + "steps", "drawing", "plain KB", "write ms", "read ms", "zlib KB", "write ms", "read ms", "smaller", + }); + for ([_]usize{ 2_000, 20_000, 100_000 }) |n| { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const t = try drawing(arena.allocator(), n); + try t.validate(steps_check); + + var plain: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer plain.deinit(); + const plain_write = try best(binWriteWith, .{ t, &plain, binary.Options{} }); + const plain_read = try best(binRead, .{plain.written()}); + var deflated: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer deflated.deinit(); + const deflated_write = try best(binWriteWith, .{ t, &deflated, binary.Options{ .deflate = true } }); + const deflated_read = try best(binRead, .{deflated.written()}); + + const minutes = @as(f64, @floatFromInt(t.duration())) / std.time.ms_per_min; + std.debug.print("{d:>9} {d:>7.0} min | {d:>9} {d:>9.3} {d:>9.3} | {d:>9} {d:>9.3} {d:>9.3} | {d:>6.1}x\n", .{ + n, + minutes, + plain.written().len / 1024, + plain_write, + plain_read, + deflated.written().len / 1024, + deflated_write, + deflated_read, + @as(f64, @floatFromInt(plain.written().len)) / @as(f64, @floatFromInt(deflated.written().len)), + }); + } +} + test "bench tape: ZON against the binary form" { std.debug.print("\n{s:>9} | {s:>9} {s:>9} {s:>10} | {s:>9} {s:>9} {s:>10} | {s:>7} {s:>7}\n", .{ "ops", "ZON KB", "write ms", "read ms", "bin KB", "write ms", "read ms", "smaller", "faster", diff --git a/tests/bench/bench_tracks.zig b/tests/bench/bench_tracks.zig new file mode 100644 index 000000000..b2170a9dd --- /dev/null +++ b/tests/bench/bench_tracks.zig @@ -0,0 +1,153 @@ +//! `zig build bench-tracks` — what a document's track costs (`app.Tracks`): a step recorded, a +//! frame's top with nothing asked of it and with a checkpoint, a replay, and what a session holds. +//! +//! The owner is a stand-in whose document is a plain buffer: taking its state is a copy, applying +//! a step writes the step's bytes in. So this measures the tracks' own work around an owner's, +//! not text's or pixi's encoding (`plans/RECORDING_PLAN.md`; those are measured where they are +//! built). Prints rather than asserts; compare runs at the same `-Doptimize`. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const Tracks = @import("app").Tracks; + +fn nowNs() i96 { + return std.Io.Clock.boot.now(std.testing.io).nanoseconds; +} + +/// The bench's own frame clock: a step every 50 ms of it. +var clock_ns: i128 = 0; +fn clock() i128 { + return clock_ns; +} + +/// A document that is a buffer. +var doc_bytes: []u8 = &.{}; + +const Owner = struct { + fn capture(_: *anyopaque, _: sdk.DocHandle, allocator: std.mem.Allocator) anyerror![]u8 { + return allocator.dupe(u8, doc_bytes); + } + fn restore(_: *anyopaque, _: sdk.DocHandle, bytes: []const u8) anyerror!void { + @memcpy(doc_bytes[0..bytes.len], bytes); + } + fn fingerprint(_: *anyopaque, _: sdk.DocHandle) u64 { + return std.hash.Wyhash.hash(0, doc_bytes); + } + fn apply(_: *anyopaque, _: sdk.DocHandle, effect: []const u8, _: []const u8, _: u32) anyerror!void { + const at = std.mem.readInt(u32, effect[0..4], .little) % (doc_bytes.len - effect.len); + @memcpy(doc_bytes[at..][0..effect.len], effect); + } +}; + +var owner_state: u8 = 0; +const owner_vtable: sdk.Plugin.VTable = .{ + .captureDocumentState = Owner.capture, + .restoreDocumentState = Owner.restore, + .documentFingerprint = Owner.fingerprint, + .applyStep = Owner.apply, +}; +var owner: sdk.Plugin = .{ .state = &owner_state, .vtable = &owner_vtable, .id = "bench", .display_name = "Bench", .step_format = 1 }; + +fn doc(id: u64) sdk.DocHandle { + return .{ .ptr = &owner_state, .owner = &owner, .id = id }; +} + +/// Record `n` steps of `size` bytes each (a keystroke's ~24, a stroke's ~200), taking the +/// checkpoints they ask for at the next frame's top as the app does. +fn record(tracks: *Tracks, n: usize, size: usize) void { + var effect: [4096]u8 = undefined; + var prng: std.Random.DefaultPrng = .init(0x7ac5); + prng.random().bytes(&effect); + const rec = tracks.recorder(); + for (0..n) |i| { + std.mem.writeInt(u32, effect[0..4], @intCast(i * 7919), .little); + rec.vtable.step(rec.ctx, 1, .{ .kind = .added, .id = i + 1, .effect = effect[0..size] }); + clock_ns += 50 * std.time.ns_per_ms; + tracks.tick(); + } +} + +fn fresh(doc_size: usize) !Tracks { + std.testing.allocator.free(doc_bytes); + doc_bytes = try std.testing.allocator.alloc(u8, doc_size); + @memset(doc_bytes, 0); + var tracks: Tracks = .init(std.testing.allocator); + tracks.clock = clock; + tracks.on = true; + tracks.opened(doc(1)); + tracks.tick(); + return tracks; +} + +const runs = 5; + +test "bench tracks: a step, a frame's top, a replay, what a session holds" { + defer std.testing.allocator.free(doc_bytes); + + std.debug.print("\n{s:>8} {s:>9} {s:>9} | {s:>9} {s:>11} {s:>11} | {s:>9} {s:>12}\n", .{ + "steps", "step B", "doc KB", "ns/step*", "checkpoints", "held KB", "replay ms", "per step", + }); + for ([_]struct { n: usize, step: usize, doc: usize }{ + .{ .n = 2_000, .step = 24, .doc = 64 << 10 }, + .{ .n = 2_000, .step = 200, .doc = 256 << 10 }, + .{ .n = 20_000, .step = 200, .doc = 256 << 10 }, + .{ .n = 20_000, .step = 200, .doc = 8 << 20 }, + }) |c| { + var best_step: i96 = std.math.maxInt(i96); + var best_replay: i96 = std.math.maxInt(i96); + var checkpoints: usize = 0; + var held: usize = 0; + var replayed: usize = 0; + for (0..runs) |_| { + var tracks = try fresh(c.doc); + defer tracks.deinit(); + const t0 = nowNs(); + record(&tracks, c.n, c.step); + best_step = @min(best_step, nowNs() - t0); + const track = tracks.trackOf(1).?; + checkpoints = track.checkpoints.items.len; + held = tracks.held; + // A seek to just before the last checkpoint: the furthest a replay has to go, from the + // state before it. + const cps = track.checkpoints.items; + const to = if (cps.len > 0) cps[cps.len - 1].step - 1 else track.steps.items.len; + const from_step = if (track.stateBefore(to)) |st| st.step else 0; + replayed = to - from_step; + const t1 = nowNs(); + try tracks.replayInto(1, doc(2), to); + best_replay = @min(best_replay, nowNs() - t1); + } + const ns_step = @as(f64, @floatFromInt(best_step)) / @as(f64, @floatFromInt(c.n)); + const replay_ms = @as(f64, @floatFromInt(best_replay)) / std.time.ns_per_ms; + std.debug.print("{d:>8} {d:>9} {d:>9} | {d:>9.0} {d:>11} {d:>11} | {d:>9.3} {d:>9.0} ns\n", .{ + c.n, c.step, c.doc >> 10, ns_step, checkpoints, held >> 10, replay_ms, replay_ms * std.time.ns_per_ms / @as(f64, @floatFromInt(@max(replayed, 1))), + }); + } + + // One checkpoint at a frame's top, by the document's size: the copy a frame pays for it. + for ([_]usize{ 64 << 10, 1 << 20, 8 << 20 }) |size| { + var best: i96 = std.math.maxInt(i96); + for (0..runs) |_| { + var tracks = try fresh(size); + defer tracks.deinit(); + const rec = tracks.recorder(); + var effect: [8]u8 = @splat(1); + for (0..Tracks.checkpoint_max_steps) |i| rec.vtable.step(rec.ctx, 1, .{ .kind = .added, .id = i + 1, .effect = &effect }); + std.debug.assert(tracks.due); + const t0 = nowNs(); + tracks.tick(); + best = @min(best, nowNs() - t0); + } + std.debug.print("a checkpoint of a {d} KB document at a frame's top: {d:.3} ms\n", .{ size >> 10, @as(f64, @floatFromInt(best)) / std.time.ns_per_ms }); + } + + std.debug.print("* each step with its share of the checkpoints it asks for\n", .{}); + + // A frame's top with nothing asked of it: what every frame pays. + var tracks = try fresh(64 << 10); + defer tracks.deinit(); + const idle_frames = 1_000_000; + const t0 = nowNs(); + for (0..idle_frames) |_| tracks.tick(); + const idle_ns = @as(f64, @floatFromInt(nowNs() - t0)) / idle_frames; + std.debug.print("a frame's top with nothing asked: {d:.2} ns\n", .{idle_ns}); +} diff --git a/tests/integration.zig b/tests/integration.zig index d428ebe69..4a10b5c6b 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -14,6 +14,7 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("fizzy"); +const Tracks = @import("app").Tracks; const shim = @import("fizzy_shim.zig"); const TextEntryWidget = @import("text").TextEntryWidget; const sdk = @import("fizzy_sdk"); @@ -1900,6 +1901,72 @@ test "assigning a region overrides keyword matching, duplicates and empties" { try std.testing.expectEqualStrings("test.other", layout.matching(sidebar)[0].id); } +// A pane adds or drops a tab by reading its assignment, editing the list and writing it back +// (`Workspace.addTab`), so the list it writes borrows the ids it replaces. On 2026-10-16 that +// crashed fizzy in `evictIdFromOthers`, reading ids the write had just freed, on a document an +// agent opened beside the person. The page allocator unmaps every free, so a read of one faults +// here every time rather than whenever the freed slot's page happened to go. +test "assigning a region a list borrowed from the assignments it replaces" { + const gpa = std.heap.page_allocator; + var state: fizzy.Editor.Layout.State = .{}; + defer state.deinitAssignments(gpa); + + try state.assign(gpa, "pane.1", &.{ "doc.a", "doc.b" }); + try state.assign(gpa, "pane.2", &.{ "doc.c", "doc.d" }); + + // Pane 1 takes doc.c as a tab: its own ids, then the new one borrowed from pane 2's list. + const one = state.assignment("pane.1").?; + const two = state.assignment("pane.2").?; + try state.assign(gpa, "pane.1", &.{ one[0], one[1], two[0] }); + try std.testing.expectEqual(@as(usize, 3), state.assignment("pane.1").?.len); + try std.testing.expectEqualStrings("doc.c", state.assignment("pane.1").?[2]); + try std.testing.expectEqual(@as(usize, 1), state.assignment("pane.2").?.len); + try std.testing.expectEqualStrings("doc.d", state.assignment("pane.2").?[0]); + + // Pane 2 drops back to its first tab, written from its own list, under its own key. + const keyed = state.assignments.getEntry("pane.2").?; + try state.assign(gpa, keyed.key_ptr.*, keyed.value_ptr.*[0..1]); + try std.testing.expectEqualStrings("doc.d", state.assignment("pane.2").?[0]); +} + +// Taking a region's ids out of the others rewrites them, and rewriting even an existing entry +// can grow the map (`getOrPut` makes room first), moving every entry. Whatever `assign` holds of +// its own entry must outlive that. Some count of regions leaves the map full at the rewrite; +// trying every count up to well past a resize reaches it, and the page allocator faults on a +// stale read. +test "assigning a region whose ids other regions hold, whatever the map's fill" { + const gpa = std.heap.page_allocator; + for (1..64) |count| { + var state: fizzy.Editor.Layout.State = .{}; + defer state.deinitAssignments(gpa); + var names: [64][16]u8 = undefined; + var ids: [64][]const u8 = undefined; + for (0..count) |i| { + const name = try std.fmt.bufPrint(&names[i], "pane.{d}", .{i}); + try state.assign(gpa, name, &.{name}); + ids[i] = name; + } + // One region takes every id: each other region is emptied, none twice. + try state.assign(gpa, "all", ids[0..count]); + try std.testing.expectEqual(count, state.assignment("all").?.len); + for (ids[0..count]) |name| try std.testing.expectEqual(@as(usize, 0), state.assignment(name).?.len); + } +} + +// A place of many tabs keeps every one when another place takes one of them. +test "taking one id out of a region of many keeps the rest" { + const gpa = std.testing.allocator; + var state: fizzy.Editor.Layout.State = .{}; + defer state.deinitAssignments(gpa); + var bufs: [40][16]u8 = undefined; + var ids: [40][]const u8 = undefined; + for (&ids, 0..) |*id, i| id.* = try std.fmt.bufPrint(&bufs[i], "doc.{d}", .{i}); + try state.assign(gpa, "pane.1", &ids); + try state.assign(gpa, "pane.2", &.{ids[0]}); + try std.testing.expectEqual(@as(usize, 39), state.assignment("pane.1").?.len); + try std.testing.expectEqualStrings("doc.39", state.assignment("pane.1").?[38]); +} + // -- regions inside regions ---------------------------------------------------------------------- // A region declared inside another names a place *within* it: a document pane inside the main area @@ -2430,6 +2497,104 @@ test "a split before a region that hides itself is not drawn, and nothing draws try std.testing.expectEqual(@as(usize, 2), editor.app.layout.regions.items.len); } +/// Sidebar beside Main over Panel, with the picker drawn over them as fizzy's frame does. +const PickerFrame = struct { + var editor: ?*fizzy.Editor = null; + + fn draw(_: ?*anyopaque) anyerror!dvui.App.Result { + return .ok; + } + + fn frame() anyerror!dvui.App.Result { + const e = editor.?; + fizzy.core.anchor.want(); + var layout = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + { + var content = try layout.region(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); + defer content.deinit(); + { + var sidebar = try layout.region(@src(), .{ .name = "Sidebar", .keywords = sdk.keywords.ide.sidebar, .shows = .many }, .{ .min_size_content = .{ .w = 200 }, .expand = .vertical }); + defer sidebar.deinit(); + } + layout.split(@src(), .{}); + { + var right = try layout.region(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer right.deinit(); + { + var main = try layout.region(@src(), .{ .name = "Main", .keywords = sdk.keywords.ide.main }, .{ .expand = .both }); + defer main.deinit(); + } + layout.split(@src(), .{}); + { + var panel = try layout.region(@src(), .{ .name = "Panel", .keywords = sdk.keywords.ide.panel, .shows = .many }, .{ .min_size_content = .{ .h = 200 }, .expand = .horizontal }); + defer panel.deinit(); + } + } + } + e.app.layout.publishRegions(); + layout.captureUnplaced(); + e.app.layout.picker.draw(&layout); + return .ok; + } +}; + +test "the picker lists each view under the place it is made for, the open place first" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + defer editor.app.layout.regions.deinit(editor.app.gpa); + defer editor.app.layout.regions_building.deinit(editor.app.gpa); + defer editor.app.layout.deinitQualified(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + defer editor.app.layout.deinitExtents(editor.app.gpa); + defer editor.app.layout.picker.close(editor.app.gpa); + defer editor.app.layout.discardSnapshots(editor.app.gpa); + + try editor.app.host.registerSurface(.{ .id = "test.files", .title = "Files", .keywords = sdk.keywords.ide.sidebar, .draw = PickerFrame.draw }); + try editor.app.host.registerSurface(.{ .id = "test.output", .title = "Output", .keywords = sdk.keywords.ide.panel, .draw = PickerFrame.draw }); + try editor.app.host.registerSurface(.{ .id = "test.misc", .title = "Misc", .keywords = &.{"inspector"}, .draw = PickerFrame.draw }); + + PickerFrame.editor = editor; + defer PickerFrame.editor = null; + try dvui.testing.settle(PickerFrame.frame); + + const y = struct { + fn of(id: []const u8) !f32 { + var buf: [64]u8 = undefined; + return (try dvui.testing.tagGet(try std.fmt.bufPrint(&buf, "picker:{s}", .{id}))).rect.y; + } + }.of; + + // From the panel: its own view first, then the sidebar's, then the one no place asks for. + // Stepped, not settled: the open popup keeps asking for frames. + editor.app.layout.openPicker(editor.app.gpa, "Panel", null); + for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); + try std.testing.expect(try y("test.output") < try y("test.files")); + try std.testing.expect(try y("test.files") < try y("test.misc")); + + // From the sidebar, the sidebar's view leads instead. + editor.app.layout.openPicker(editor.app.gpa, "Sidebar", null); + for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); + try std.testing.expect(try y("test.files") < try y("test.output")); + try std.testing.expect(try y("test.output") < try y("test.misc")); + + // Files moved into the panel reads under the panel, in one row with Output, from either + // picker. + try editor.app.layout.assign(editor.app.gpa, "Panel", &.{ "test.output", "test.files" }); + for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); + try std.testing.expectEqual(try y("test.output"), try y("test.files")); + try std.testing.expect(try y("test.output") < try y("test.misc")); + // The sidebar holds nothing now, and still leads, empty, under its own name. + try std.testing.expect((try dvui.testing.tagGet("picker.empty")).rect.y < try y("test.files")); + editor.app.layout.openPicker(editor.app.gpa, "Panel", null); + for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); + try std.testing.expectEqual(try y("test.output"), try y("test.files")); + try std.testing.expect(try y("test.output") < try y("test.misc")); + try std.testing.expect(dvui.tagGet("picker.empty") == null); +} + test "a hide_when_empty panel stays while its view is dragged onto main" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); @@ -2712,6 +2877,38 @@ const FizzySplitFrame = struct { } }; +test "picker: its controls are named for a tape or an agent to aim at, while they can be pressed" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + defer editor.app.layout.regions.deinit(editor.app.gpa); + defer editor.app.layout.regions_building.deinit(editor.app.gpa); + defer editor.app.layout.deinitExtents(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + defer editor.app.layout.deinitQualified(editor.app.gpa); + defer editor.app.layout.picker.close(editor.app.gpa); + + PickerFrame.editor = editor; + defer PickerFrame.editor = null; + + try dvui.testing.settle(PickerFrame.frame); + editor.app.layout.picker.open(editor.app.gpa, "Main", null); + _ = try dvui.testing.step(PickerFrame.frame); + _ = try dvui.testing.step(PickerFrame.frame); + + for ([_][]const u8{ "picker.mode:one", "picker.mode:many", "picker.action:split" }) |name| { + if (dvui.tagGet(name) == null) { + std.debug.print("no anchor {s}\n", .{name}); + return error.TestExpectedEqual; + } + } + // Main shows nothing, so Clear is dimmed and has no name; a shape's place has no Remove. + try std.testing.expect(dvui.tagGet("picker.action:clear") == null); + try std.testing.expect(dvui.tagGet("picker.action:remove") == null); +} + test "a fizzy Main split is a removable slot, not main.slot" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); @@ -2796,6 +2993,195 @@ test "a fizzy Main split keeps the workspace on the leftover side" { try std.testing.expectEqual(@as(usize, 0), created.len); } +/// fizzy's own Main: a carded place that hands its card to its panes, in a column of the window. +const ColumnCardFrame = struct { + var editor: ?*fizzy.Editor = null; + + fn frame() anyerror!dvui.App.Result { + const e = editor.?; + var layout = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + layout.card = .{ .background = true, .padding = .all(8) }; + { + var column = try layout.region(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer column.deinit(); + var main = try layout.region(@src(), .{ .name = "Main", .keywords = fizzy.sdk.keywords.ide.main, .card = true, .pane_cards = true }, .{ .expand = .both }); + defer main.deinit(); + } + e.app.layout.publishRegions(); + return .ok; + } + + /// What the place takes on screen: its border box in physical pixels, measured where it was + /// drawn rather than read back from the `size` a split halves. + fn sizeOf(e: *fizzy.Editor, name: []const u8) !dvui.Size { + for (e.app.layout.regions.items) |r| { + if (std.mem.eql(u8, r.name, name)) return .{ .w = r.bounds.w, .h = r.bounds.h }; + } + return error.TestExpectedEqual; + } +}; + +// A split's extent is the new half's whole reach, card and all. Measured from the place's content +// instead, every split of a carded place came out uneven by the card's padding, twice over. +test "a split of a carded place, and of its new half, opens even halves" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + defer editor.app.layout.regions.deinit(editor.app.gpa); + defer editor.app.layout.regions_building.deinit(editor.app.gpa); + defer editor.app.layout.deinitExtents(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + defer editor.app.layout.deinitQualified(editor.app.gpa); + + ColumnCardFrame.editor = editor; + defer ColumnCardFrame.editor = null; + + try dvui.testing.settle(ColumnCardFrame.frame); + { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + try std.testing.expectEqualStrings("Main/r1", layout.splitOn("Main", .right) orelse return error.TestExpectedEqual); + } + try dvui.testing.settle(ColumnCardFrame.frame); + const left = try ColumnCardFrame.sizeOf(editor, "Main"); + const right = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + try std.testing.expect(left.w > 40); + try std.testing.expectApproxEqAbs(left.w, right.w, 1); + + { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + try std.testing.expectEqualStrings("Main/r1/b1", layout.splitOn("Main/r1", .bottom) orelse return error.TestExpectedEqual); + } + try dvui.testing.settle(ColumnCardFrame.frame); + const top = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + const bottom = try ColumnCardFrame.sizeOf(editor, "Main/r1/b1"); + try std.testing.expect(top.h > 40); + try std.testing.expectApproxEqAbs(top.h, bottom.h, 1); +} + +/// Main | (Main/r1 over Main/r1/b1) in fizzy's split forest, each holding one view. Main/r1 is two +/// things at once: the half Main's split minted, and the half its own split kept. +const NestedSplitCase = struct { + ctx: shim.Ctx, + + fn init() !NestedSplitCase { + var ctx = try shim.init(std.testing.allocator); + errdefer ctx.deinit(std.testing.allocator); + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + ColumnCardFrame.editor = editor; + const draw = struct { + fn f(_: ?*anyopaque) anyerror!dvui.App.Result { + return .ok; + } + }.f; + for ([_][]const u8{ "test.left", "test.top", "test.bottom", "test.right" }) |id| { + try editor.app.host.registerSurface(.{ .id = id, .title = id, .keywords = fizzy.sdk.keywords.ide.main, .draw = draw }); + } + try dvui.testing.settle(ColumnCardFrame.frame); + try editor.app.layout.assign(editor.app.gpa, "Main", &.{"test.left"}); + try split(editor, "Main", .right, "Main/r1", "test.top"); + try split(editor, "Main/r1", .bottom, "Main/r1/b1", "test.bottom"); + return .{ .ctx = ctx }; + } + + fn split(editor: *fizzy.Editor, name: []const u8, side: fizzy.Editor.Layout.SplitTree.Side, want: []const u8, view: []const u8) !void { + { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + try std.testing.expectEqualStrings(want, layout.splitOn(name, side) orelse return error.TestExpectedEqual); + } + try editor.app.layout.assign(editor.app.gpa, want, &.{view}); + try dvui.testing.settle(ColumnCardFrame.frame); + } + + fn deinit(self: *NestedSplitCase) void { + const editor = self.ctx.editor; + editor.app.layout.regions.deinit(editor.app.gpa); + editor.app.layout.regions_building.deinit(editor.app.gpa); + editor.app.layout.deinitExtents(editor.app.gpa); + editor.app.layout.deinitAssignments(editor.app.gpa); + editor.app.layout.deinitQualified(editor.app.gpa); + ColumnCardFrame.editor = null; + self.ctx.deinit(std.testing.allocator); + } + + fn trash(self: *NestedSplitCase, name: []const u8) !void { + const editor = self.ctx.editor; + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + fizzy.Editor.Layout.ViewDrag.place(&layout, name, name, .remove); + try dvui.testing.settle(ColumnCardFrame.frame); + try dvui.testing.settle(ColumnCardFrame.frame); + } +}; + +test "split: emptying the half that was split again closes only that half" { + var case = try NestedSplitCase.init(); + defer case.deinit(); + const state = &case.ctx.editor.app.layout; + const column = (try ColumnCardFrame.sizeOf(case.ctx.editor, "Main/r1")).w; + + try case.trash("Main/r1"); + + // The column stays, one place again under the name Main's split knows, holding what its + // other half held — the same merge as emptying a split's origin anywhere else. + try std.testing.expectEqualStrings("Main", state.splits.siblingLeaf("Main/r1") orelse return error.TestExpectedEqual); + try std.testing.expect(state.splits.rootOf("Main/r1/b1") == null); + const kept = state.assignment("Main/r1") orelse return error.TestExpectedEqual; + try std.testing.expectEqual(@as(usize, 1), kept.len); + try std.testing.expectEqualStrings("test.bottom", kept[0]); + try std.testing.expectApproxEqAbs(column, (try ColumnCardFrame.sizeOf(case.ctx.editor, "Main/r1")).w, 1); + + const links = state.splits.collectLinks(std.testing.allocator); + defer std.testing.allocator.free(links); + try std.testing.expectEqual(@as(usize, 1), links.len); + try std.testing.expectEqualStrings("Main/r1", links[0].name); +} + +test "split: emptying the half that was split again stays when its other half is split too" { + var case = try NestedSplitCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try NestedSplitCase.split(editor, "Main/r1/b1", .right, "Main/r1/b1/r1", "test.right"); + + try case.trash("Main/r1"); + + // No one place to merge into: the column and every split in it stay, Main/r1 empty. + const leaves = editor.app.layout.splits.leavesUnder(dvui.currentWindow().arena(), "Main"); + try std.testing.expectEqual(@as(usize, 4), leaves.len); + const kept = editor.app.layout.assignment("Main/r1") orelse return error.TestExpectedEqual; + try std.testing.expectEqual(@as(usize, 0), kept.len); + try std.testing.expectEqualStrings("test.bottom", editor.app.layout.assignment("Main/r1/b1").?[0]); +} + +test "split: a column shut by its sash goes only once every place in it is empty" { + var case = try NestedSplitCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + const state = &editor.app.layout; + // The branch registers before the half that kept its name, so the first `Main/r1` is the + // column: the box its parent's sash sizes. + const column = for (state.regions.items) |r| { + if (std.mem.eql(u8, r.name, "Main/r1")) break r.id; + } else return error.TestExpectedEqual; + + // Shut with a view still in its bottom half: the column stays, and so does every place in it. + try state.assign(editor.app.gpa, "Main/r1", &.{}); + fizzy.core.widgets.Split.close(column); + try dvui.testing.settle(ColumnCardFrame.frame); + try dvui.testing.settle(ColumnCardFrame.frame); + try std.testing.expectEqual(@as(usize, 3), state.splits.leavesUnder(dvui.currentWindow().arena(), "Main").len); + try std.testing.expectEqualStrings("test.bottom", state.assignment("Main/r1/b1").?[0]); + + // Nothing left in it, the whole column goes, and nothing it held is left behind. + try state.assign(editor.app.gpa, "Main/r1/b1", &.{}); + try dvui.testing.settle(ColumnCardFrame.frame); + try dvui.testing.settle(ColumnCardFrame.frame); + try std.testing.expect(state.splits.root("Main") == null); + try std.testing.expect(state.assignment("Main/r1/b1") == null); + try std.testing.expectEqual(@as(f32, -1), state.extent("Main/r1/b1", -1)); +} + test "a view-drag split opens on the dropped edge" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); @@ -5147,7 +5533,7 @@ test "demo: every bundled demo builds into a valid tape" { try std.testing.expect(std.mem.endsWith(u8, owned.tape.keyframes[0].root, e.name)); // And it travels as a `.tape` (what a site hosts) unchanged. - const bytes = try automation.binary.encode(std.testing.allocator, owned.tape); + const bytes = try automation.binary.encode(std.testing.allocator, owned.tape, .{}); defer std.testing.allocator.free(bytes); var back = try automation.Tape.load(std.testing.allocator, bytes, automation.Input.check); defer back.deinit(); @@ -5191,7 +5577,7 @@ test "demo: the hand-written sample tape parses and round-trips" { // `Tape.load` takes the source as it is and the binary form alike. var loaded = try automation.Tape.load(std.testing.allocator, source, automation.Input.check); defer loaded.deinit(); - const bytes = try automation.binary.encode(std.testing.allocator, owned.tape); + const bytes = try automation.binary.encode(std.testing.allocator, owned.tape, .{}); defer std.testing.allocator.free(bytes); var from_binary = try automation.Tape.load(std.testing.allocator, bytes, automation.Input.check); defer from_binary.deinit(); @@ -6915,12 +7301,18 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const lookback = fizzy.core.profile.lookback() orelse return error.TestUnexpectedResult; lookback.budget_ns = 0; defer lookback.budget_ns = std.time.ns_per_s / 60; + const unattributed_before = editor.app.host.unattributed_steps; try editor.demo.play("tour"); var frames: usize = 0; while (editor.demo.player.state != .ended and frames < 5000) : (frames += 1) { _ = try dvui.testing.step(headlessFrame); } try std.testing.expectEqual(app.automation.Player.State.ended, editor.demo.player.state); + // Every step the tour made has an author: its own where it drives the frame, or its commands' + // (`Host.act` in the demo's stage). A step nobody acted for is a missed site + // (`plans/SHARED_HISTORY_PLAN.md`). Owners report every step once that plan's step 3 lands; + // until then this guards the steps reported while recording. + try std.testing.expectEqual(unattributed_before, editor.app.host.unattributed_steps); { var out: std.Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); @@ -7160,6 +7552,34 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expect(std.mem.indexOf(u8, try docText(host), ".text = \"typed\"") != null); } + // Documents a command creates open beside the person (`Host.openBeside`), as an agent's + // `pixi.create` does, into panes whose tabs have come and gone. Each pane's tab list is + // rewritten from itself (`Workspace.addTab`), the crash of 2026-10-16 (`Layout.State.assign`). + { + const host = &editor.app.host; + const here = editor.workbench.open_workspace_grouping; + const grid: sdk.EditorAPI.NewDocGrid = .{ .column_width = 16, .row_height = 16 }; + var made: [4]u64 = undefined; + for (&made, 0..) |*id, i| { + const p = try std.fmt.allocPrint(gpa, "{s}/beside-{d}.txt", .{ project, i }); + defer gpa.free(p); + host.openBeside(); + defer host.endOpenBeside(); + const doc = try editor.newFile(p, grid); + id.* = doc.id; + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqual(here, editor.workbench.open_workspace_grouping); + // Every other one goes again, leaving its pane's list shorter for the next. + if (i % 2 == 1) try editor.closeFileID(made[i - 1]); + try dvui.testing.settle(headlessFrame); + } + const last = editor.app.docById(made[made.len - 1]) orelse return error.TestUnexpectedResult; + const beside = for (editor.workbench.workspaces.values()) |ws| { + if (ws.grouping != here) if (ws.active) |a| if (a.id == last.id) break true; + } else false; + try std.testing.expect(beside); + } + // A document's history as a line (`Plugin.historyLine`): each step keeps its id through // undo and redo, and going to one (`historyGoTo`) is so many undos or redos. { @@ -7213,6 +7633,285 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try dvui.testing.settle(headlessFrame); } + // A document's steps (`Host.recordStep`): while recorded, each change is reported as it + // happens, forward, and played into a fresh document (`Plugin.applyStep`) they make the same + // text. Typing runs join a step; an undo is its step reversed; nothing is encoded unrecorded. + { + const host = &editor.app.host; + const was = editor.activeDoc() orelse return error.TestUnexpectedResult; + const newDoc = struct { + fn newDoc(ed: *fizzy.Editor, h: *sdk.Host, not: u64) !sdk.DocHandle { + try h.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = ed.activeDoc() orelse continue; + if (d.id != not and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) return d; + } + return error.TestUnexpectedResult; + } + }.newDoc; + const textOf = struct { + fn textOf(h: *sdk.Host, d: sdk.DocHandle) ![]const u8 { + const args = try std.fmt.allocPrint(h.arena(), ".{{ .path = \"{s}\" }}", .{d.owner.documentPath(d)}); + const zon = switch (h.callCommand("text.read", args, h.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + const at = std.mem.indexOf(u8, zon, ".text = \"") orelse return error.TestUnexpectedResult; + const rest = zon[at + ".text = \"".len ..]; + return rest[0 .. std.mem.indexOfScalar(u8, rest, '"') orelse return error.TestUnexpectedResult]; + } + }.textOf; + const Recorded = struct { + doc: u64 = 0, + kinds: std.ArrayList(sdk.Step.Kind) = .empty, + effects: std.ArrayList([]u8) = .empty, + + fn wants(ctx: *anyopaque, id: u64) bool { + return @as(*@This(), @ptrCast(@alignCast(ctx))).doc == id; + } + fn step(ctx: *anyopaque, id: u64, st: sdk.Step) void { + const self: *@This() = @ptrCast(@alignCast(ctx)); + std.debug.assert(id == self.doc); + self.kinds.append(std.testing.allocator, st.kind) catch unreachable; + self.effects.append(std.testing.allocator, std.testing.allocator.dupe(u8, st.effect) catch unreachable) catch unreachable; + } + fn deinit(self: *@This()) void { + for (self.effects.items) |e| std.testing.allocator.free(e); + self.effects.deinit(std.testing.allocator); + self.kinds.deinit(std.testing.allocator); + } + }; + var rec: Recorded = .{}; + defer rec.deinit(); + host.recorder = .{ .ctx = &rec, .vtable = &.{ .wants = Recorded.wants, .step = Recorded.step } }; + defer host.recorder = null; + + const a = try newDoc(editor, host, was.id); + rec.doc = a.id; + // Typed a keystroke at a time: one step, joined. + for ([_][]const u8{ "a", "b" }) |key| { + try dvui.testing.writeText(key); + try dvui.testing.settle(headlessFrame); + } + try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"X\", .line = 1 }", host.arena()) == .ok); + try editor.undo(); + try editor.redo(); + try editor.undo(); + // An edit after going back: the step gone back over stays undone. + try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"Y\", .line = 1 }", host.arena()) == .ok); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings("Yab", try textOf(host, a)); + try std.testing.expectEqualSlices(sdk.Step.Kind, &.{ .added, .joined, .added, .undone, .redone, .undone, .added }, rec.kinds.items); + + // Played into a document of its own, forward, in order: the same text. + const b = try newDoc(editor, host, a.id); + try std.testing.expect(b.owner.step_format == a.owner.step_format and a.owner.step_format != 0); + for (rec.effects.items) |effect| try std.testing.expect(b.owner.applyStep(b, effect, "", 0)); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings("Yab", try textOf(host, b)); + // Unrecorded, B reported nothing; and a step that does not fit is refused, B untouched: + // the undo takes back an `X` that is not there. + try std.testing.expectEqual(@as(usize, 7), rec.kinds.items.len); + // (Through the hook itself: the wrapper logs the refusal as an error, which fails a run.) + try std.testing.expectError(error.StepDoesNotApply, b.owner.vtable.applyStep.?(b.owner.state, b, rec.effects.items[3], "", 0)); + try std.testing.expectEqualStrings("Yab", try textOf(host, b)); + + try editor.rawCloseFileID(b.id); + try editor.rawCloseFileID(a.id); + editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); + try dvui.testing.settle(headlessFrame); + } + + // Tracks (`app.Tracks`): with tracking on, an open document's steps are kept with their times, + // beside its state when the track began and every so often since; any moment of the track + // replays into another document. A replay target records nothing of it. + { + const host = &editor.app.host; + const was = editor.activeDoc() orelse return error.TestUnexpectedResult; + const newDoc = struct { + fn newDoc(ed: *fizzy.Editor, h: *sdk.Host, not: u64) !sdk.DocHandle { + try h.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = ed.activeDoc() orelse continue; + if (d.id != not and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) return d; + } + return error.TestUnexpectedResult; + } + }.newDoc; + const textOf = struct { + fn textOf(h: *sdk.Host, d: sdk.DocHandle) ![]const u8 { + const args = try std.fmt.allocPrint(h.arena(), ".{{ .path = \"{s}\" }}", .{d.owner.documentPath(d)}); + const zon = switch (h.callCommand("text.read", args, h.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + const at = std.mem.indexOf(u8, zon, ".text = \"") orelse return error.TestUnexpectedResult; + const rest = zon[at + ".text = \"".len ..]; + return rest[0 .. std.mem.indexOfScalar(u8, rest, '"') orelse return error.TestUnexpectedResult]; + } + }.textOf; + editor.setTracking(true); + defer editor.setTracking(false); + const tracks = &editor.app.tracks.?; + + const a = try newDoc(editor, host, was.id); + try dvui.testing.settle(headlessFrame); + for ([_][]const u8{ "a", "b" }) |key| { + try dvui.testing.writeText(key); + try dvui.testing.settle(headlessFrame); + } + // Enough steps for a checkpoint, then an undo. + for (0..Tracks.checkpoint_steps + 44) |_| { + try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"x\", .line = 1 }", host.arena()) == .ok); + } + try editor.undo(); + try dvui.testing.settle(headlessFrame); + const want = try std.testing.allocator.dupe(u8, try textOf(host, a)); + defer std.testing.allocator.free(want); + try std.testing.expectEqual(Tracks.checkpoint_steps + 43 + 2, want.len); + + const track = tracks.trackOf(a.id) orelse return error.TestUnexpectedResult; + const n = track.steps.items.len; + try std.testing.expectEqual(@as(usize, 2 + Tracks.checkpoint_steps + 44 + 1), n); + try std.testing.expect(track.base != null); + try std.testing.expectEqual(@as(usize, 1), track.checkpoints.items.len); + try std.testing.expectEqual(n, track.checkpoints.items[0].step); + try std.testing.expectEqual(sdk.Step.Kind.undone, track.steps.items[n - 1].kind); + for (track.steps.items[1..], track.steps.items[0 .. n - 1]) |later, earlier| try std.testing.expect(later.at >= earlier.at); + try std.testing.expectEqual(a.id, tracks.focus.items[tracks.focus.items.len - 1].doc_id); + + // All of it, from the checkpoint, to the fingerprint it recorded; and the start of it, + // from the base. + const b = try newDoc(editor, host, a.id); + try dvui.testing.settle(headlessFrame); + try tracks.replayInto(a.id, b, n); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings(want, try textOf(host, b)); + try std.testing.expectEqual(a.owner.documentFingerprint(a), b.owner.documentFingerprint(b)); + try tracks.replayInto(a.id, b, 2); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings("ab", try textOf(host, b)); + // The target kept its own track, and recorded none of the replay. + const tb = tracks.trackOf(b.id) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(usize, 0), tb.steps.items.len); + + // A frame nothing asked of does no tracks work: an idle app, recording, costs a branch. + const worked = tracks.worked; + for (0..120) |_| { + dvui.refresh(null, @src(), null); + _ = try dvui.testing.step(headlessFrame); + } + try std.testing.expectEqual(worked, tracks.worked); + // What they hold, as devtools and an agent read it. + const report = switch (host.callCommand("fizzy.tracks", "", host.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + try std.testing.expect(std.mem.indexOf(u8, report, ".on = true") != null); + const steps_row = try std.fmt.allocPrint(host.arena(), ".steps = {d}", .{n}); + try std.testing.expect(std.mem.indexOf(u8, report, steps_row) != null); + + // Closed, a document's track is kept for the session. + try editor.rawCloseFileID(b.id); + try editor.rawCloseFileID(a.id); + try std.testing.expect(tracks.open.get(a.id) == null); + try std.testing.expectEqual(track, tracks.trackOf(a.id).?); + editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); + try dvui.testing.settle(headlessFrame); + } + + // A document's transport (`app.HistoryTransport`): undo shows it over the document's pane; a + // press on its line goes to that step, its buttons step, a drag along it follows; and an undo + // held down goes further each press. + { + const host = &editor.app.host; + const was = editor.activeDoc() orelse return error.TestUnexpectedResult; + try host.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = editor.activeDoc() orelse continue; + if (d.id != was.id and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) break; + } + const doc = editor.activeDoc() orelse return error.TestUnexpectedResult; + try std.testing.expect(doc.id != was.id); + const path = doc.owner.documentPath(doc); + for (0..40) |_| try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"x\", .line = 1 }", host.arena()) == .ok); + try dvui.testing.settle(headlessFrame); + const at = struct { + fn at(h: *sdk.Host, d: sdk.DocHandle) !usize { + return (d.owner.historyLine(d, h.arena()) orelse return error.TestUnexpectedResult).at; + } + }.at; + const ds = editor.doc_surfaces.get(doc.id) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(f32, 0), ds.transport.shown.openness(dvui.frameTimeNS())); + + try editor.undo(); + for (0..4) |_| _ = try dvui.testing.step(headlessFrame); + try std.testing.expect(ds.transport.shown.openness(dvui.frameTimeNS()) > 0); + try std.testing.expectEqual(@as(usize, 39), try at(host, doc)); + + const play = struct { + fn play(ed: *fizzy.Editor, script: *automation.Script) !void { + try ed.demo.live.play(try script.finish()); + for (0..300) |_| { + if (!ed.demo.live.playing()) break; + _ = try dvui.testing.step(headlessFrame); + } + try std.testing.expect(ed.demo.live.outcome.? == .finished); + } + }.play; + const line_tag = try std.fmt.allocPrint(gpa, "history.line:{s}", .{path}); + defer gpa.free(line_tag); + const forward_tag = try std.fmt.allocPrint(gpa, "history.forward:{s}", .{path}); + defer gpa.free(forward_tag); + { + // A press on the title, over the line's start, is the bar's and goes nowhere. + var s: automation.Script = .init(gpa, "transport-title", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.click(.{ .tag = line_tag, .x = 0, .y = 0.15 }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 39), try at(host, doc)); + { + // The line's start: before any change. + var s: automation.Script = .init(gpa, "transport-start", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.click(.{ .tag = line_tag, .x = 0 }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 0), try at(host, doc)); + { + // Redo: one step on. + var s: automation.Script = .init(gpa, "transport-forward", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.click(.{ .tag = forward_tag }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 1), try at(host, doc)); + { + // A drag from the start to the end follows the pointer there. + var s: automation.Script = .init(gpa, "transport-drag", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.drag(.{ .tag = line_tag, .x = 0 }, .{ .tag = line_tag, .x = 1 }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 40), try at(host, doc)); + + // Held down, ten presses in a frame: one step each for six, then two each. + for (0..10) |_| try editor.undo(); + try std.testing.expectEqual(@as(usize, 40 - 14), try at(host, doc)); + + try editor.rawCloseFileID(doc.id); + editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); + try dvui.testing.settle(headlessFrame); + } + // Revealing a view (`Host.revealSurface`): with the bottom panel shut, revealing Output opens // the panel with Output selected, and a view no region shows is not revealed. { @@ -7294,6 +7993,24 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes for (0..3) |_| _ = try dvui.testing.step(inlineMarkdownFrame); try std.testing.expect(inline_md_height > one_para * 2); inline_md.?.release(0x1d1e); + + // Streamed in a few bytes a frame, as a chat reply arrives, it only ever grows. Each + // re-parse left the edited block with an estimate that a pin then emitted for a frame, so + // a streaming reply shrank by a line and grew back, over and over. + const reply = "Sure. Let me look at how the view lays out a message while it streams, because that is where the motion comes from.\n\nThe **scroll area** sizes its content from last frame's sizes, and a message that grows by a line is a frame late.\n\n- one point\n- and a second that goes on long enough to wrap at this width, and then some more"; + var last: f32 = 0; + var n: usize = 0; + while (n < reply.len) { + n = @min(reply.len, n + 9); + inline_md_text = reply[0..n]; + _ = try dvui.testing.step(inlineMarkdownFrame); + if (inline_md_height + 0.5 < last) { + std.debug.print("inline markdown shrank from {d:.1} to {d:.1} at byte {d}\n", .{ last, inline_md_height, n }); + return error.TestUnexpectedResult; + } + last = inline_md_height; + } + inline_md.?.release(0x1d1e); } // The `plugins` service: what each plugin's loading did. A built-in is loaded; a reload that @@ -7396,6 +8113,56 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes editor.session = null; } + // A document never saved comes back from its unsaved bytes (`Plugin.loadUntitledDocument`), + // kept as its owner would save them (`Plugin.bytesPath`), not from a file named by its title, + // which there is none of. Here it has no state (its owner might keep none): all it has is + // its bytes, and it is still untitled, dirty and whole. + { + const before = editor.activeDoc() orelse return error.TestUnexpectedResult; + try std.testing.expect(!before.owner.documentHasRecognizedSaveExtension(before)); + const path = try gpa.dupe(u8, before.owner.documentPath(before)); + defer gpa.free(path); + const grouping = before.owner.documentGrouping(before); + const dir = try fizzy.Editor.KeptDocuments.sessionDir(gpa, editor.app.config_folder); + defer gpa.free(dir); + { + var kept = try fizzy.Editor.KeptDocuments.capture(editor, null); + defer kept.deinit(); + for (kept.docs.items) |d| if (std.mem.eql(u8, d.path, path)) { + try std.testing.expect(d.save_as != null and d.unsaved != null); + }; + try kept.save(dir); + } + { + var d = try std.Io.Dir.cwd().openDir(dvui.io, dir, .{ .iterate = true }); + defer d.close(dvui.io); + var it = d.iterate(); + while (try it.next(dvui.io)) |file| { + if (std.mem.endsWith(u8, file.name, ".state")) try d.deleteFile(dvui.io, file.name); + } + const zon = try d.readFileAlloc(dvui.io, "session.zon", gpa, .unlimited); + defer gpa.free(zon); + const stateless = try std.mem.replaceOwned(u8, gpa, zon, ".has_state = true", ".has_state = false"); + defer gpa.free(stateless); + try d.writeFile(dvui.io, .{ .sub_path = "session.zon", .data = stateless }); + } + var ids: std.ArrayListUnmanaged(u64) = .empty; + defer ids.deinit(gpa); + for (editor.app.open_files.values()) |d| try ids.append(gpa, d.id); + for (ids.items) |id| editor.detachFileID(id); + + editor.session = fizzy.Editor.KeptDocuments.loadSession(gpa, dir) orelse return error.TestUnexpectedResult; + _ = try editor.app.host.openFile(.{ .path = path, .grouping = grouping }); + const after = editor.docFromPath(path) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("text", after.owner.id); + try std.testing.expect(after.owner.isDirty(after)); + try std.testing.expect(!after.owner.documentHasRecognizedSaveExtension(after)); + const out = editor.app.host.callCommand("text.read", "", editor.app.host.arena()); + try std.testing.expect(std.mem.indexOf(u8, out.ok.?, ".text = \"typed\"") != null); + if (editor.session) |*kept| kept.deinit(); + editor.session = null; + } + // A checkpoint (`Checkpoint`): what a run that did not quit cleanly left is read once at the // next launch. Each write is a generation of files in the same folder (`saveGeneration`), the // last one's removed once the new `session.zon` is in place; one cut off before any @@ -7443,6 +8210,30 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try expectEmptyFolder(dir); } + // A plugin's New flow writes its file itself and asks the `files` service to show it + // (`reveal`): fizzy emits `fizzy.files.reveal`, and the tree, which hears it, reveals the row + // with its name open for editing. The tree holds the path until the row is laid out, then + // takes it, so heard-then-taken is the whole round trip. + { + try editor.setProjectFolder(project); + editor.explorer.open(editor); + editor.app.host.setSelectionFor(sdk.keywords.ide.sidebar, fizzy.Editor.workbench_files_view); + try tmp.dir.writeFile(dvui.io, .{ .sub_path = "project/made-by-a-plugin.md", .data = "" }); + const made = try std.fs.path.join(gpa, &.{ project, "made-by-a-plugin.md" }); + defer gpa.free(made); + const files_api = editor.app.host.getServiceTyped(sdk.services.files.Api) orelse return error.TestUnexpectedResult; + files_api.reveal(made); + var heard = false; + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + if (editor.workbench.pending_new_file_path) |pending| { + if (std.mem.eql(u8, pending, made)) heard = true; + } else if (heard) break; + } + try std.testing.expect(heard); + try std.testing.expect(editor.workbench.pending_new_file_path == null); + } + // A quit straight after a pane slid shut (`snapshotClosingPane`): the pane still holds its // picture, and the quit comes after the last frame, when nothing can be freed at a frame's end. { @@ -7608,7 +8399,7 @@ test "automation service: tape bytes in, a ticket back, the outcome by ticket" { // The binary form plays the same; the first tape's outcome is still there to ask for. var owned = try liveTape(); defer owned.deinit(); - const encoded = try automation.binary.encode(std.testing.allocator, owned.tape); + const encoded = try automation.binary.encode(std.testing.allocator, owned.tape, .{}); defer std.testing.allocator.free(encoded); const second = try started(api.play(encoded)); try std.testing.expect(second != ticket); diff --git a/web/web.js b/web/web.js index 4fb689b72..acef9caf7 100644 --- a/web/web.js +++ b/web/web.js @@ -654,7 +654,18 @@ export class Dvui { }, wasm_programCreate: (ptr, len, textures, uniform_vec4s) => { const gl = this.gl; - const src = this.programPrelude(uniform_vec4s) + this.stringFromPointer(ptr, len); + const body = this.stringFromPointer(ptr, len); + // A complete source — one generated from a program's HLSL, GLSL ES 3.00 + // (`scripts/compile-shaders.sh`) — is compiled as it is, on WebGL2 only. On WebGL1 + // it fails, and whoever drew with it falls back (the glass to its meshes). + const complete = body.startsWith("#version"); + if (complete && !this.webgl2) { + const id = this.newProgramId; + this.newProgramId += 1; + this.programs.set(id, { prog: null, fs: null, loc: null, status: 0 }); + return id; + } + const src = complete ? body : this.programPrelude(uniform_vec4s) + body; const vs = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(vs, this.webgl2 ? vertexShaderSource_webgl2 : vertexShaderSource_webgl); gl.compileShader(vs);