Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9612140432 | ||
|
|
31f2da9f79 | ||
|
|
a10633ec8d | ||
|
|
ee267286a3 | ||
|
|
e3d7b8c8a5 | ||
|
|
1ffd388350 | ||
|
|
747b6bc3b9 | ||
|
|
047132e05d | ||
|
|
ee235bbf14 | ||
|
|
216bdbb19d | ||
|
|
a54ce4902a | ||
|
|
2f768d4166 | ||
|
|
a0107b622a | ||
|
|
eb937b4470 | ||
|
|
1d7cee7cc7 | ||
|
|
85d9efc67b | ||
|
|
ab6ea7ef58 | ||
|
|
9c02975882 | ||
|
|
6a148e63e0 | ||
|
|
48d1eabef2 | ||
|
|
107b09e506 | ||
|
|
1f47880e8a | ||
|
|
4920c2a11c | ||
|
|
5dd9cc91bb |
@@ -0,0 +1,5 @@
|
|||||||
|
# Golden files and project sources are byte-stable across platforms.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Preserve the upstream font license exactly as committed (it uses CRLF).
|
||||||
|
tools/gendocs/site/static/fonts/IBM-Plex-OFL.txt -text
|
||||||
+39
-1
@@ -1,3 +1,41 @@
|
|||||||
# CI workflows
|
# CI workflows
|
||||||
|
|
||||||
The workflows in this directory automate checks, builds, tests, and release publishing for Aego. Optional platform-runner jobs cover Windows and macOS alongside the main Linux checks.
|
Gitea is the source of truth. The Linux workflow runs there; optional Gitea
|
||||||
|
platform runners remain controlled by `PLATFORM_RUNNERS`.
|
||||||
|
|
||||||
|
`github-native.yml` is for the GitHub mirror. It builds and tests on native
|
||||||
|
Windows, macOS (arm64 and amd64), and Linux runners, and uploads unsigned CI
|
||||||
|
artifacts and diagnostic logs. The Windows installer smoke is restricted to an
|
||||||
|
ephemeral GitHub-hosted runner. macOS bundles are ad-hoc signed, not notarized.
|
||||||
|
These jobs are not evidence of platform support until they actually pass on
|
||||||
|
their respective runners.
|
||||||
|
|
||||||
|
On Gitea, the GitHub-only matrix routes to the Linux runner before being
|
||||||
|
skipped. This prevents Windows and macOS entries from waiting indefinitely for
|
||||||
|
runners that Gitea does not have. On GitHub, the actual native labels are used.
|
||||||
|
macOS and Windows build the pinned GLSL reference validator from verified
|
||||||
|
source, rather than depending on a changing package-manager version.
|
||||||
|
|
||||||
|
To show native GitHub results on the Gitea commit, create the GitHub Actions
|
||||||
|
secret `GITEA_STATUS_TOKEN` containing a Gitea API token with repository write
|
||||||
|
permission. Prefer a non-admin bot that has access only to `sanspie/aego`:
|
||||||
|
Gitea's repository-write token is broader than commit statuses. The reporter
|
||||||
|
publishes pending and final statuses with links to the real GitHub jobs only
|
||||||
|
for trusted `master` pushes or manual runs, never for pull requests. If the
|
||||||
|
secret is missing, CI still runs but no Gitea statuses are published.
|
||||||
|
|
||||||
|
Keep GitHub Actions disabled through the first mirror sync: historical tags
|
||||||
|
can contain older workflows. Before enabling Actions, verify mirrored refs and
|
||||||
|
restrict the allowed workflows so historical release jobs cannot run. Use a
|
||||||
|
separate repository-scoped credential for the mirror; do not put it in a URL.
|
||||||
|
Gitea's build-and-publish job remains paused. `github-release-mirror.yml`
|
||||||
|
copies already-published Gitea releases and assets to GitHub after the current
|
||||||
|
master passes Gitea Linux CI and all four real GitHub native jobs. It also
|
||||||
|
checks periodically and can be dispatched manually from master. Versioned
|
||||||
|
stable releases are published normally, with the highest stable version marked
|
||||||
|
Latest; nightly, beta versions, and the stable-channel pointer are prereleases.
|
||||||
|
The mirror verifies matching git refs and asset sizes/checksums, stages new
|
||||||
|
releases as drafts, and never moves tags or replaces existing assets. If a
|
||||||
|
mutable nightly or another mirrored snapshot changes, the mirror stops rather
|
||||||
|
than overwriting it; a separate reviewed update is needed in that case. Signed
|
||||||
|
update manifests are copied unchanged and retain their original download URLs.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
# Native runs on Windows and macOS machines; the Windows build itself is cross-compiled on Linux in ci.yml (D301).
|
# Native runs on Windows and macOS machines; the Windows build itself is cross-compiled on Linux in ci.yml (D301).
|
||||||
smoke:
|
smoke:
|
||||||
name: ${{ matrix.goos }} ${{ matrix.goarch }} smoke
|
name: ${{ matrix.goos }} ${{ matrix.goarch }} smoke
|
||||||
if: vars.PLATFORM_RUNNERS == 'true'
|
if: github.server_url != 'https://github.com' && vars.PLATFORM_RUNNERS == 'true'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
|
if: github.server_url != 'https://github.com'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -44,6 +45,7 @@ jobs:
|
|||||||
run: make nsis-check
|
run: make nsis-check
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
if: github.server_url != 'https://github.com'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -121,7 +123,9 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [checks, build]
|
needs: [checks, build]
|
||||||
if: github.event_name == 'push'
|
# Pause all automatic releases until the reviewed nightly/stable pipeline
|
||||||
|
# enforces native gates and preserves existing tags and release assets.
|
||||||
|
if: ${{ false }}
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
name: github-release-mirror
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [github-native]
|
||||||
|
types: [completed]
|
||||||
|
branches: [master]
|
||||||
|
schedule:
|
||||||
|
- cron: '17 */6 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: github-release-mirror
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
if: github.server_url == 'https://github.com' && github.repository == 'Alexander-D-Karpov/aego' && github.ref == 'refs/heads/master'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
ref: refs/heads/master
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Test release mirror
|
||||||
|
run: python3 -m unittest discover -s tools/ci -p mirror_releases_test.py -v
|
||||||
|
- name: Mirror published Gitea releases
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: python3 tools/ci/mirror_releases.py --apply
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -98,7 +99,8 @@ func TestKeyTracksNativeSources(t *testing.T) {
|
|||||||
|
|
||||||
func TestLocalReplaces(t *testing.T) {
|
func TestLocalReplaces(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeFile(t, filepath.Join(root, "go.mod"), `module game
|
absolute := filepath.VolumeName(root) + "/abs/c"
|
||||||
|
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf(`module game
|
||||||
|
|
||||||
go 1.27
|
go 1.27
|
||||||
|
|
||||||
@@ -107,9 +109,9 @@ replace aego => ../engine // local engine
|
|||||||
replace (
|
replace (
|
||||||
example.com/a v1.2.3 => ./vendor/a
|
example.com/a v1.2.3 => ./vendor/a
|
||||||
example.com/b => example.com/b v1.0.0
|
example.com/b => example.com/b v1.0.0
|
||||||
example.com/c => /abs/c
|
example.com/c => %s
|
||||||
)
|
)
|
||||||
`)
|
`, absolute))
|
||||||
got, err := LocalReplaces(root)
|
got, err := LocalReplaces(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -117,7 +119,7 @@ replace (
|
|||||||
want := map[string]bool{
|
want := map[string]bool{
|
||||||
filepath.Clean(filepath.Join(root, "../engine")): true,
|
filepath.Clean(filepath.Join(root, "../engine")): true,
|
||||||
filepath.Clean(filepath.Join(root, "vendor", "a")): true,
|
filepath.Clean(filepath.Join(root, "vendor", "a")): true,
|
||||||
filepath.Clean("/abs/c"): true,
|
filepath.Clean(absolute): true,
|
||||||
}
|
}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("replaces = %v", got)
|
t.Fatalf("replaces = %v", got)
|
||||||
|
|||||||
+13
-3
@@ -78,11 +78,21 @@ type Bundle struct {
|
|||||||
func sanitizer() func(string) string {
|
func sanitizer() func(string) string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return func(s string) string {
|
return func(s string) string {
|
||||||
if home != "" {
|
return hideHome(s, home)
|
||||||
s = strings.ReplaceAll(s, home, "~")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hideHome(s, home string) string {
|
||||||
|
if home == "" {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
// Windows paths can appear with either separator in stack traces and logs.
|
||||||
|
for _, prefix := range [2]string{home, strings.ReplaceAll(home, `\`, "/")} {
|
||||||
|
s = strings.ReplaceAll(s, prefix+`\`, "~/")
|
||||||
|
s = strings.ReplaceAll(s, prefix+"/", "~/")
|
||||||
|
s = strings.ReplaceAll(s, prefix, "~")
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func Write(b Bundle) (string, error) {
|
func Write(b Bundle) (string, error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package crash
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -10,6 +11,9 @@ import (
|
|||||||
func TestBundleHidesHome(t *testing.T) {
|
func TestBundleHidesHome(t *testing.T) {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Setenv("USERPROFILE", home)
|
||||||
|
}
|
||||||
root := filepath.Join(home, "game")
|
root := filepath.Join(home, "game")
|
||||||
dir, err := Write(Bundle{Root: root, Kind: "panic", Where: "panel inspector", Panic: "boom in " + root, Stack: []byte(root + "/x.go:1")})
|
dir, err := Write(Bundle{Root: root, Kind: "panic", Where: "panel inspector", Panic: "boom in " + root, Stack: []byte(root + "/x.go:1")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -25,3 +29,12 @@ func TestBundleHidesHome(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHideHomeWithWindowsSeparators(t *testing.T) {
|
||||||
|
home := `C:\Users\Alice`
|
||||||
|
for _, input := range []string{`C:\Users\Alice\game\main.go`, `C:/Users/Alice/game/main.go`} {
|
||||||
|
if got := hideHome(input, home); got != `~/game/main.go` && got != `~/game\main.go` {
|
||||||
|
t.Fatalf("hideHome(%q) = %q", input, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package edit
|
package edit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -11,10 +12,81 @@ import (
|
|||||||
"aego/core/mathx"
|
"aego/core/mathx"
|
||||||
"aego/editor/core/workspace"
|
"aego/editor/core/workspace"
|
||||||
"aego/modules/render2d"
|
"aego/modules/render2d"
|
||||||
|
render2deditor "aego/modules/render2d/editor"
|
||||||
"aego/scene"
|
"aego/scene"
|
||||||
"aego/scene/frame"
|
"aego/scene/frame"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The toolbar must leave the debug image, not just clear its checked state.
|
||||||
|
func TestSceneViewLightingToggleClearsDebugPixels(t *testing.T) {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
p := newProject(t)
|
||||||
|
const path = "scenes/debug-toggle.aescene"
|
||||||
|
writeScene(t, p, path, func(s *scene.Scene) {
|
||||||
|
h := s.Create(s.Root(), "Red")
|
||||||
|
sr := scene.Add[render2d.SpriteRenderer](s, h)
|
||||||
|
*sr = render2d.DefaultSpriteRenderer()
|
||||||
|
sr.Sprite = asset.NewRef[asset.Texture](asset.BuiltinWhite)
|
||||||
|
sr.Material = asset.NewRef[asset.Material](asset.BuiltinMaterialLit)
|
||||||
|
sr.Tint = mathx.Color{R: 1, A: 1}
|
||||||
|
sr.Size = mathx.V2(4, 4)
|
||||||
|
})
|
||||||
|
s, err := Open(p, Config{Width: 800, Height: 600, Hidden: true, NoWatch: true, Logger: log.Nop()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
s.Step()
|
||||||
|
ed := s.App.Context()
|
||||||
|
doc, err := ed.Docs.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc.View.Camera.Position = mathx.Vec2{}
|
||||||
|
s.App.SceneView().SetZoom(40)
|
||||||
|
ed.Settings.Grid = false
|
||||||
|
for range 3 {
|
||||||
|
s.Step()
|
||||||
|
}
|
||||||
|
panel := s.App.SceneView()
|
||||||
|
raw, ok := panel.LightingWorld().ServiceAny(frame.ServiceID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing Scene View runner")
|
||||||
|
}
|
||||||
|
runner := raw.(*frame.Runner)
|
||||||
|
read := func() []byte {
|
||||||
|
t.Helper()
|
||||||
|
for range 2 {
|
||||||
|
s.Step()
|
||||||
|
}
|
||||||
|
target, w, h := runner.Target()
|
||||||
|
pixels := make([]byte, w*h*4)
|
||||||
|
if target.IsZero() || ed.Renderer.Device().ReadPixels(target, 0, 0, w, h, pixels) != nil {
|
||||||
|
t.Fatal("cannot read the Scene View")
|
||||||
|
}
|
||||||
|
return pixels
|
||||||
|
}
|
||||||
|
press := func(id string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := ed.Commands.Run(id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
press(render2deditor.CmdLighting)
|
||||||
|
unlit := read()
|
||||||
|
for _, debug := range []string{render2deditor.CmdLightingNormals, render2deditor.CmdLightingShadows} {
|
||||||
|
press(debug)
|
||||||
|
if bytes.Equal(unlit, read()) {
|
||||||
|
t.Fatalf("%s did not change the rendered image", debug)
|
||||||
|
}
|
||||||
|
press(render2deditor.CmdLighting)
|
||||||
|
if off := read(); !bytes.Equal(unlit, off) {
|
||||||
|
t.Fatalf("turning off lighting left %s in the rendered image", debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLightingAuthoringWorkflowNative(t *testing.T) {
|
func TestLightingAuthoringWorkflowNative(t *testing.T) {
|
||||||
runtime.LockOSThread()
|
runtime.LockOSThread()
|
||||||
defer runtime.UnlockOSThread()
|
defer runtime.UnlockOSThread()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
//go:build aego_gltest
|
||||||
|
|
||||||
|
package edit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"aego/asset"
|
||||||
|
"aego/core/log"
|
||||||
|
"aego/core/mathx"
|
||||||
|
"aego/editor/core/document"
|
||||||
|
"aego/modules/render2d"
|
||||||
|
"aego/scene"
|
||||||
|
"aego/scene/frame"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSceneView3DShows2DSpritePixels(t *testing.T) {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
p := newProject(t)
|
||||||
|
const path = "scenes/2d-in-3d.aescene"
|
||||||
|
writeScene(t, p, path, func(s *scene.Scene) {
|
||||||
|
h := s.Create(s.Root(), "Red sprite")
|
||||||
|
sr := scene.Add[render2d.SpriteRenderer](s, h)
|
||||||
|
*sr = render2d.DefaultSpriteRenderer()
|
||||||
|
sr.Sprite = asset.NewRef[asset.Texture](asset.BuiltinWhite)
|
||||||
|
sr.Tint = mathx.Color{R: 1, A: 1}
|
||||||
|
sr.Size = mathx.V2(4, 4)
|
||||||
|
})
|
||||||
|
s, err := Open(p, Config{Width: 800, Height: 600, Hidden: true, NoWatch: true, Logger: log.Nop()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
s.Step()
|
||||||
|
ed := s.App.Context()
|
||||||
|
doc, err := ed.Docs.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc.View.Mode = document.View3D
|
||||||
|
doc.View.Orbit = document.Orbit{Distance: 10, FOV: 60}
|
||||||
|
ed.Settings.Grid = false
|
||||||
|
for range 3 {
|
||||||
|
s.Step()
|
||||||
|
}
|
||||||
|
raw, ok := s.App.SceneView().LightingWorld().ServiceAny(frame.ServiceID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing Scene View runner")
|
||||||
|
}
|
||||||
|
runner := raw.(*frame.Runner)
|
||||||
|
readRed := func() int {
|
||||||
|
t.Helper()
|
||||||
|
for range 2 {
|
||||||
|
s.Step()
|
||||||
|
}
|
||||||
|
target, w, h := runner.Target()
|
||||||
|
pixels := make([]byte, w*h*4)
|
||||||
|
if target.IsZero() {
|
||||||
|
t.Fatal("no Scene View target")
|
||||||
|
}
|
||||||
|
if err := ed.Renderer.Device().ReadPixels(target, 0, 0, w, h, pixels); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for i := 0; i+3 < len(pixels); i += 4 {
|
||||||
|
if pixels[i] > 160 && pixels[i+1] < 80 && pixels[i+2] < 80 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
visible := readRed()
|
||||||
|
if visible < 100 {
|
||||||
|
t.Fatalf("2D sprite produced only %d red pixels in 3D Scene View", visible)
|
||||||
|
}
|
||||||
|
sc := doc.Scene()
|
||||||
|
h := sc.Find(sc.Root(), "Red sprite")
|
||||||
|
sc.SetEnabled(h, false)
|
||||||
|
if hidden := readRed(); hidden >= visible/2 {
|
||||||
|
t.Fatalf("disabling the sprite left %d of %d red pixels", hidden, visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-4
@@ -43,18 +43,31 @@ func walkExport(dir string, dst *os.Root) ([32]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return sum, err
|
return sum, err
|
||||||
}
|
}
|
||||||
|
info, err := os.Lstat(clean)
|
||||||
|
if err != nil {
|
||||||
|
return sum, err
|
||||||
|
}
|
||||||
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return sum, errors.New("export directory was replaced by a symlink or is not a directory")
|
||||||
|
}
|
||||||
|
// System paths such as /var on macOS may have symlinked ancestors. Resolve
|
||||||
|
// them, but pin the final directory and still refuse links inside the export.
|
||||||
actual, err := filepath.EvalSymlinks(clean)
|
actual, err := filepath.EvalSymlinks(clean)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sum, err
|
return sum, err
|
||||||
}
|
}
|
||||||
if actual != clean {
|
root, err := os.OpenRoot(actual)
|
||||||
return sum, errors.New("export directory was replaced by a symlink")
|
|
||||||
}
|
|
||||||
root, err := os.OpenRoot(clean)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sum, err
|
return sum, err
|
||||||
}
|
}
|
||||||
defer root.Close()
|
defer root.Close()
|
||||||
|
opened, err := root.Stat(".")
|
||||||
|
if err != nil {
|
||||||
|
return sum, err
|
||||||
|
}
|
||||||
|
if !os.SameFile(info, opened) {
|
||||||
|
return sum, errors.New("export directory changed during verification")
|
||||||
|
}
|
||||||
const maxFiles = 32768
|
const maxFiles = 32768
|
||||||
const maxBytes = 1 << 30
|
const maxBytes = 1 << 30
|
||||||
var count int
|
var count int
|
||||||
|
|||||||
@@ -59,3 +59,53 @@ func TestExportSealRefusesChildLink(t *testing.T) {
|
|||||||
t.Fatal("symlink into another tree was sealed")
|
t.Fatal("symlink into another tree was sealed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExportSealSymlinkedAncestorAndRoot(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("unprivileged symlinks are not available by default on Windows")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
parent := filepath.Join(dir, "real")
|
||||||
|
other := filepath.Join(dir, "other")
|
||||||
|
for _, p := range []string{parent, other} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(p, "export"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(parent, "export", "game"), []byte("good"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(other, "export", "game"), []byte("evil"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
alias := filepath.Join(dir, "alias")
|
||||||
|
if err := os.Symlink(parent, alias); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(alias, "export")
|
||||||
|
seal, err := Seal(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("symlinked ancestor: %v", err)
|
||||||
|
}
|
||||||
|
copy, err := VerifiedCopy(path, seal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.RemoveAll(copy) })
|
||||||
|
if err := os.Remove(alias); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(other, alias); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := VerifiedCopy(path, seal); err == nil {
|
||||||
|
t.Fatal("a changed ancestor redirected the sealed export")
|
||||||
|
}
|
||||||
|
rootLink := filepath.Join(dir, "export-link")
|
||||||
|
if err := os.Symlink(filepath.Join(parent, "export"), rootLink); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Seal(rootLink); err == nil {
|
||||||
|
t.Fatal("a symlink used as the export directory was sealed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-2
@@ -50,8 +50,7 @@ func ShaderProblems(imps *pipeline.ImporterRegistry) problems.Source {
|
|||||||
if si == nil {
|
if si == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
_, rev := si.Diagnostics()
|
return si.Revision()
|
||||||
return rev
|
|
||||||
},
|
},
|
||||||
Append: func(dst []problems.Diagnostic) []problems.Diagnostic {
|
Append: func(dst []problems.Diagnostic) []problems.Diagnostic {
|
||||||
if si == nil {
|
if si == nil {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package shader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"aego/render/shaderx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiagnosticRevisionDoesNotAllocate(t *testing.T) {
|
||||||
|
i := New()
|
||||||
|
i.record("assets/a.aeshader", []shaderx.Diag{{Code: shaderx.SHD001}})
|
||||||
|
_, before := i.Diagnostics()
|
||||||
|
if before == 0 || i.Revision() != before {
|
||||||
|
t.Fatal("diagnostic revision was not reported")
|
||||||
|
}
|
||||||
|
if n := testing.AllocsPerRun(100, func() { _ = i.Revision() }); n != 0 {
|
||||||
|
t.Fatalf("checking the revision allocates %v times", n)
|
||||||
|
}
|
||||||
|
i.record("assets/a.aeshader", nil)
|
||||||
|
if i.Revision() == before {
|
||||||
|
t.Fatal("clearing a diagnostic did not advance the revision")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,13 @@ func (i *Importer) Diagnostics() ([]shaderx.Diag, uint64) {
|
|||||||
return out, i.rev
|
return out, i.rev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Revision allows consumers to check for changes without rebuilding the sorted diagnostics.
|
||||||
|
func (i *Importer) Revision() uint64 {
|
||||||
|
i.mu.Lock()
|
||||||
|
defer i.mu.Unlock()
|
||||||
|
return i.rev
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Importer) record(path string, diags []shaderx.Diag) {
|
func (i *Importer) record(path string, diags []shaderx.Diag) {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
defer i.mu.Unlock()
|
defer i.mu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pump
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/bits"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// counterDuration converts monotonic performance-counter ticks without losing
|
||||||
|
// sub-millisecond precision or overflowing on long intervals.
|
||||||
|
func counterDuration(ticks, frequency uint64) time.Duration {
|
||||||
|
if frequency == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const maxDuration = uint64(1<<63 - 1)
|
||||||
|
seconds := ticks / frequency
|
||||||
|
if seconds > maxDuration/uint64(time.Second) {
|
||||||
|
return time.Duration(maxDuration)
|
||||||
|
}
|
||||||
|
hi, lo := bits.Mul64(ticks%frequency, uint64(time.Second))
|
||||||
|
nanos, _ := bits.Div64(hi, lo, frequency)
|
||||||
|
whole := seconds * uint64(time.Second)
|
||||||
|
if nanos > maxDuration-whole {
|
||||||
|
return time.Duration(maxDuration)
|
||||||
|
}
|
||||||
|
return time.Duration(whole + nanos)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package pump
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type mixClockSample struct{ start time.Time }
|
||||||
|
|
||||||
|
func mixClockStart() mixClockSample { return mixClockSample{start: time.Now()} }
|
||||||
|
func mixClockElapsed(s mixClockSample) time.Duration { return time.Since(s.start) }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package pump
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"math/big"
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCounterDuration(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
ticks, frequency uint64
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{0, 10_000_000, 0},
|
||||||
|
{1, 0, 0},
|
||||||
|
{1, 10_000_000, 100 * time.Nanosecond},
|
||||||
|
{1, 3, 333_333_333 * time.Nanosecond},
|
||||||
|
{15_000_000, 10_000_000, 1500 * time.Millisecond},
|
||||||
|
{math.MaxUint64 - 1, math.MaxUint64, 999_999_999 * time.Nanosecond},
|
||||||
|
{math.MaxUint64, 1, time.Duration(math.MaxInt64)},
|
||||||
|
} {
|
||||||
|
if got := counterDuration(tc.ticks, tc.frequency); got != tc.want {
|
||||||
|
t.Errorf("counterDuration(%d, %d) = %v; want %v", tc.ticks, tc.frequency, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCounterDurationMatchesExactArithmetic(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewSource(125))
|
||||||
|
limit := big.NewInt(math.MaxInt64)
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
ticks, frequency := rng.Uint64(), rng.Uint64()|1
|
||||||
|
want := new(big.Int).SetUint64(ticks)
|
||||||
|
want.Mul(want, big.NewInt(int64(time.Second)))
|
||||||
|
want.Quo(want, new(big.Int).SetUint64(frequency))
|
||||||
|
if want.Cmp(limit) > 0 {
|
||||||
|
want.Set(limit)
|
||||||
|
}
|
||||||
|
if got := counterDuration(ticks, frequency); int64(got) != want.Int64() {
|
||||||
|
t.Fatalf("ticks=%d frequency=%d: got %v, want %s ns", ticks, frequency, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMixStatisticsRecordDurations(t *testing.T) {
|
||||||
|
p := &Pump{}
|
||||||
|
p.record(150 * time.Nanosecond)
|
||||||
|
p.record(450 * time.Nanosecond)
|
||||||
|
if p.st.Blocks != 2 || p.st.MixMax != 450*time.Nanosecond || p.st.MixAvg != 300*time.Nanosecond {
|
||||||
|
t.Fatalf("incorrect measured mixing statistics: %+v", p.st)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package pump
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Go's Windows monotonic time can advance in coarse interrupt-time steps, so
|
||||||
|
// short audio blocks otherwise report zero despite doing work. QPC is the
|
||||||
|
// Windows monotonic high-resolution interval clock; it does not change global
|
||||||
|
// timer resolution or require a new dependency.
|
||||||
|
var (
|
||||||
|
perfClock = syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
perfCounterAddr = perfClock.NewProc("QueryPerformanceCounter").Addr()
|
||||||
|
perfFrequencyAddr = perfClock.NewProc("QueryPerformanceFrequency").Addr()
|
||||||
|
perfClockRate = readPerfFrequency()
|
||||||
|
)
|
||||||
|
|
||||||
|
func readPerfFrequency() uint64 {
|
||||||
|
var value int64
|
||||||
|
ok, _, _ := syscall.SyscallN(perfFrequencyAddr, uintptr(unsafe.Pointer(&value)))
|
||||||
|
if ok == 0 || value <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uint64(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPerfCounter() (int64, bool) {
|
||||||
|
var value int64
|
||||||
|
ok, _, _ := syscall.SyscallN(perfCounterAddr, uintptr(unsafe.Pointer(&value)))
|
||||||
|
return value, ok != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type mixClockSample struct {
|
||||||
|
counter int64
|
||||||
|
fallback time.Time
|
||||||
|
highRes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func mixClockStart() mixClockSample {
|
||||||
|
if perfClockRate != 0 {
|
||||||
|
if counter, ok := readPerfCounter(); ok {
|
||||||
|
return mixClockSample{counter: counter, highRes: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mixClockSample{fallback: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mixClockElapsed(s mixClockSample) time.Duration {
|
||||||
|
if !s.highRes {
|
||||||
|
return time.Since(s.fallback)
|
||||||
|
}
|
||||||
|
end, ok := readPerfCounter()
|
||||||
|
if !ok || end <= s.counter {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return counterDuration(uint64(end-s.counter), perfClockRate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pump
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestWindowsHighResolutionMixClock(t *testing.T) {
|
||||||
|
if perfClockRate == 0 {
|
||||||
|
t.Fatal("QueryPerformanceFrequency is unavailable")
|
||||||
|
}
|
||||||
|
start := mixClockStart()
|
||||||
|
if !start.highRes {
|
||||||
|
t.Fatal("QueryPerformanceCounter is unavailable")
|
||||||
|
}
|
||||||
|
var elapsed bool
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
if mixClockElapsed(start) > 0 {
|
||||||
|
elapsed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !elapsed {
|
||||||
|
t.Fatal("the high-resolution performance counter did not advance")
|
||||||
|
}
|
||||||
|
if allocs := testing.AllocsPerRun(1000, func() { _ = mixClockElapsed(mixClockStart()) }); allocs != 0 {
|
||||||
|
t.Fatalf("the audio timer allocated: %v allocations per measurement", allocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -144,9 +144,9 @@ func (p *Pump) Fill() int {
|
|||||||
}
|
}
|
||||||
n := 0
|
n := 0
|
||||||
for q < target {
|
for q < target {
|
||||||
t := time.Now()
|
t := mixClockStart()
|
||||||
p.m.Mix(p.buf)
|
p.m.Mix(p.buf)
|
||||||
d := time.Since(t)
|
d := mixClockElapsed(t)
|
||||||
w := p.out.Write(p.buf)
|
w := p.out.Write(p.buf)
|
||||||
p.record(d)
|
p.record(d)
|
||||||
n++
|
n++
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ func iconsUsage() string {
|
|||||||
|
|
||||||
func editorIcons() []module.IconUse {
|
func editorIcons() []module.IconUse {
|
||||||
out := iconuse.Uses()
|
out := iconuse.Uses()
|
||||||
|
// Module-provided toolbar icons belong in the application composition, not
|
||||||
|
// in the module-independent editor's icon registry.
|
||||||
|
out = append(out,
|
||||||
|
module.IconUse{ID: "render2d.normals", Owner: iconuse.Owner, Size: 16, By: "Scene View: surface normals"},
|
||||||
|
module.IconUse{ID: "render2d.point_light", Owner: iconuse.Owner, Size: 16, By: "Scene View: lighting preview"},
|
||||||
|
module.IconUse{ID: "render2d.shadow_view", Owner: iconuse.Owner, Size: 16, By: "Scene View: shadow visibility"},
|
||||||
|
)
|
||||||
for _, t := range projects.Templates {
|
for _, t := range projects.Templates {
|
||||||
if t.Icon != "" {
|
if t.Icon != "" {
|
||||||
out = append(out, module.IconUse{ID: t.Icon, Owner: iconuse.Owner, By: "New Project: template " + t.ID, Size: 24})
|
out = append(out, module.IconUse{ID: t.Icon, Owner: iconuse.Owner, By: "New Project: template " + t.ID, Size: 24})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -31,3 +32,23 @@ func TestIconsCheckReportsMissingEditorIcon(t *testing.T) {
|
|||||||
t.Fatalf("a missing editor icon must fail the check:\n%s", b.String())
|
t.Fatalf("a missing editor icon must fail the check:\n%s", b.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIconsIncludeModuleToolbarUses(t *testing.T) {
|
||||||
|
report, err := iconReport()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"render2d.normals": "Scene View: surface normals",
|
||||||
|
"render2d.point_light": "Scene View: lighting preview",
|
||||||
|
"render2d.shadow_view": "Scene View: shadow visibility",
|
||||||
|
}
|
||||||
|
for _, row := range report.Rows() {
|
||||||
|
if by, ok := want[row.ID]; ok && slices.Contains(row.UsedBy, by) {
|
||||||
|
delete(want, row.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(want) != 0 {
|
||||||
|
t.Fatalf("module toolbar icons missing from the audit: %v", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ func WriteChrome(w io.Writer, t *Tracer, frames []Frame) error {
|
|||||||
bw.WriteString(`,{"name":"frame ` + u(f.Index) + `","ph":"X","pid":1,"tid":1,"ts":` + us(f.Start) +
|
bw.WriteString(`,{"name":"frame ` + u(f.Index) + `","ph":"X","pid":1,"tid":1,"ts":` + us(f.Start) +
|
||||||
`,"dur":` + us(f.Dur) + `,"args":{"gc":` + u(uint64(f.GC)) +
|
`,"dur":` + us(f.Dur) + `,"args":{"gc":` + u(uint64(f.GC)) +
|
||||||
`,"dropped":` + u(uint64(f.Dropped)) + `,"world":` + u(f.World) + `,"frameId":` + u(f.FrameID) +
|
`,"dropped":` + u(uint64(f.Dropped)) + `,"world":` + u(f.World) + `,"frameId":` + u(f.FrameID) +
|
||||||
`,"firstTick":` + u(f.FirstTick) + `,"lastTick":` + u(f.LastTick) + `}}`)
|
`,"firstTick":` + u(f.FirstTick) + `,"lastTick":` + u(f.LastTick) +
|
||||||
|
`,"paceWaitNs":` + strconv.FormatInt(f.PaceWaitNS, 10) + `,"idleWaitNs":` + strconv.FormatInt(f.IdleWaitNS, 10) + `}}`)
|
||||||
for _, e := range f.Events {
|
for _, e := range f.Events {
|
||||||
name := `""`
|
name := `""`
|
||||||
if int(e.Zone) < len(names) {
|
if int(e.Zone) < len(names) {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package trace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPaceWaitMetadataSurvivesOverflowAndResets(t *testing.T) {
|
||||||
|
tr := New(Options{Frames: 2, Events: 1})
|
||||||
|
tr.SetEnabled(true)
|
||||||
|
zone := tr.Zone("test")
|
||||||
|
tr.BeginFrame()
|
||||||
|
tr.Begin(zone).End()
|
||||||
|
tr.Begin(zone).End() // No room for the pacing event itself.
|
||||||
|
tr.AddPaceWait(10*time.Millisecond, false)
|
||||||
|
tr.AddPaceWait(60*time.Millisecond, true)
|
||||||
|
tr.AddPaceWait(-time.Second, true)
|
||||||
|
tr.EndFrame()
|
||||||
|
f := tr.Frame(0)
|
||||||
|
if f.Dropped != 1 || f.PaceWaitNS != int64(70*time.Millisecond) || f.IdleWaitNS != int64(60*time.Millisecond) {
|
||||||
|
t.Fatalf("dropped=%d pace=%d idle=%d", f.Dropped, f.PaceWaitNS, f.IdleWaitNS)
|
||||||
|
}
|
||||||
|
snapshot := tr.Snapshot(1)
|
||||||
|
for range 2 {
|
||||||
|
tr.BeginFrame()
|
||||||
|
tr.EndFrame()
|
||||||
|
}
|
||||||
|
if f = tr.Frame(0); f.PaceWaitNS != 0 || f.IdleWaitNS != 0 {
|
||||||
|
t.Fatalf("reused frame retained wait: %+v", f)
|
||||||
|
}
|
||||||
|
if snapshot[0].PaceWaitNS != int64(70*time.Millisecond) || snapshot[0].IdleWaitNS != int64(60*time.Millisecond) {
|
||||||
|
t.Fatal("snapshot lost pacing metadata")
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := WriteChrome(&out, tr, snapshot); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var exported struct {
|
||||||
|
Events []struct {
|
||||||
|
Args struct {
|
||||||
|
PaceWaitNS int64 `json:"paceWaitNs"`
|
||||||
|
IdleWaitNS int64 `json:"idleWaitNs"`
|
||||||
|
} `json:"args"`
|
||||||
|
} `json:"traceEvents"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &exported); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(exported.Events) < 2 || exported.Events[1].Args.PaceWaitNS != int64(70*time.Millisecond) || exported.Events[1].Args.IdleWaitNS != int64(60*time.Millisecond) {
|
||||||
|
t.Fatalf("exported pacing metadata: %+v", exported.Events)
|
||||||
|
}
|
||||||
|
var nilTracer *Tracer
|
||||||
|
nilTracer.AddPaceWait(time.Second, true)
|
||||||
|
}
|
||||||
+25
-10
@@ -24,16 +24,19 @@ type Event struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Frame struct {
|
type Frame struct {
|
||||||
Index uint64
|
Index uint64
|
||||||
Start int64
|
Start int64
|
||||||
Dur int64
|
Dur int64
|
||||||
GC uint32
|
// PaceWaitNS is deliberate frame-limiter sleep; IdleWaitNS is its idle-mode part.
|
||||||
Dropped uint32
|
PaceWaitNS int64
|
||||||
World uint64
|
IdleWaitNS int64
|
||||||
FrameID uint64
|
GC uint32
|
||||||
FirstTick uint64
|
Dropped uint32
|
||||||
LastTick uint64
|
World uint64
|
||||||
Events []Event
|
FrameID uint64
|
||||||
|
FirstTick uint64
|
||||||
|
LastTick uint64
|
||||||
|
Events []Event
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Frame) Duration() time.Duration {
|
func (f *Frame) Duration() time.Duration {
|
||||||
@@ -158,6 +161,7 @@ func (t *Tracer) BeginFrame() {
|
|||||||
t.seq++
|
t.seq++
|
||||||
f := &t.frames[t.head]
|
f := &t.frames[t.head]
|
||||||
f.Index, f.Start, f.Dur, f.GC, f.Dropped = t.seq, t.now(), 0, 0, 0
|
f.Index, f.Start, f.Dur, f.GC, f.Dropped = t.seq, t.now(), 0, 0, 0
|
||||||
|
f.PaceWaitNS, f.IdleWaitNS = 0, 0
|
||||||
f.World, f.FrameID, f.FirstTick, f.LastTick = 0, 0, 0, 0
|
f.World, f.FrameID, f.FirstTick, f.LastTick = 0, 0, 0, 0
|
||||||
f.Events = f.Events[:0]
|
f.Events = f.Events[:0]
|
||||||
t.cur, t.depth = f, 0
|
t.cur, t.depth = f, 0
|
||||||
@@ -171,6 +175,17 @@ func (t *Tracer) Mark(world, frame, first, last uint64) {
|
|||||||
f.World, f.FrameID, f.FirstTick, f.LastTick = world, frame, first, last
|
f.World, f.FrameID, f.FirstTick, f.LastTick = world, frame, first, last
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddPaceWait records intentional pacing independently of the event capacity.
|
||||||
|
func (t *Tracer) AddPaceWait(d time.Duration, idle bool) {
|
||||||
|
if t == nil || t.cur == nil || d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.cur.PaceWaitNS += int64(d)
|
||||||
|
if idle {
|
||||||
|
t.cur.IdleWaitNS += int64(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Tracer) EndFrame() {
|
func (t *Tracer) EndFrame() {
|
||||||
if t == nil || t.cur == nil {
|
if t == nil || t.cur == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
+13
-4
@@ -19,6 +19,8 @@ Sources are `<id>.svg` files in `engine/res/icons/src` and `modules/<id>/res/ico
|
|||||||
| `anim.slice` | engine | 16 | Create palette: Animation → Flipbook from Selection; PNG inspector: Slicing section | ok |
|
| `anim.slice` | engine | 16 | Create palette: Animation → Flipbook from Selection; PNG inspector: Slicing section | ok |
|
||||||
| `anim.to_end` | engine | 16 | Animation panel: go to end | ok |
|
| `anim.to_end` | engine | 16 | Animation panel: go to end | ok |
|
||||||
| `anim.to_start` | engine | 16 | Animation panel: go to start | ok |
|
| `anim.to_start` | engine | 16 | Animation panel: go to start | ok |
|
||||||
|
| `animation.animator` | engine | 16 | component animation.Animator | ok |
|
||||||
|
| `animation.player` | engine | 16 | component animation.AnimationPlayer | ok |
|
||||||
| `app` | engine | 16 | launcher header; colored PNGs in app/brand are the window, installer and favicon icon | ok |
|
| `app` | engine | 16 | launcher header; colored PNGs in app/brand are the window, installer and favicon icon | ok |
|
||||||
| `app.quit` | engine | 16 | File → Quit | ok |
|
| `app.quit` | engine | 16 | File → Quit | ok |
|
||||||
| `audio.listener` | engine | 16 | component audio.AudioListener2D | ok |
|
| `audio.listener` | engine | 16 | component audio.AudioListener2D | ok |
|
||||||
@@ -36,7 +38,7 @@ Sources are `<id>.svg` files in `engine/res/icons/src` and `modules/<id>/res/ico
|
|||||||
| `cat.node` | engine | 24 | New Project: template empty; Search: node results; Add Component and Create: category | ok |
|
| `cat.node` | engine | 24 | New Project: template empty; Search: node results; Add Component and Create: category | ok |
|
||||||
| `cat.particles` | engine | 16 | Add Component and Create: Effects category; template menu Effects | ok |
|
| `cat.particles` | engine | 16 | Add Component and Create: Effects category; template menu Effects | ok |
|
||||||
| `cat.physics` | engine | 16 | template menu Physics | ok |
|
| `cat.physics` | engine | 16 | template menu Physics | ok |
|
||||||
| `cat.render2d` | render2d | 16 | template menu Lighting | missing |
|
| `cat.render2d` | engine | 16 | Add Component and Create: Lights category; template menu Lighting | ok |
|
||||||
| `cat.rendering` | engine | 24 | New Project: template 2d; Search: mesh assets; Add Component and Create: category | ok |
|
| `cat.rendering` | engine | 24 | New Project: template 2d; Search: mesh assets; Add Component and Create: category | ok |
|
||||||
| `cat.scripts` | engine | 16 | Search: script symbols; Add Component and Create: category | ok |
|
| `cat.scripts` | engine | 16 | Search: script symbols; Add Component and Create: category | ok |
|
||||||
| `cat.settings` | engine | 16 | References: the project file; Add Component and Create: category | ok |
|
| `cat.settings` | engine | 16 | References: the project file; Add Component and Create: category | ok |
|
||||||
@@ -58,9 +60,9 @@ Sources are `<id>.svg` files in `engine/res/icons/src` and `modules/<id>/res/ico
|
|||||||
| `comp.persist` | engine | 16 | component save.Persist | ok |
|
| `comp.persist` | engine | 16 | component save.Persist | ok |
|
||||||
| `comp.polygon` | engine | 16 | Polygon shape component; Shapes debug overlay; component physics.PolygonCollider2D | ok |
|
| `comp.polygon` | engine | 16 | Polygon shape component; Shapes debug overlay; component physics.PolygonCollider2D | ok |
|
||||||
| `comp.rigidbody` | engine | 16 | component physics.Body2D; template physics.rigidbody | ok |
|
| `comp.rigidbody` | engine | 16 | component physics.Body2D; template physics.rigidbody | ok |
|
||||||
| `comp.script` | engine | 16 | Scripts component; Assets menu: Attach to Selected; component engine.Script | ok |
|
| `comp.script` | engine | 16 | Scripts component and Add Component entries; Assets menu: Attach to Selected; component engine.Script | ok |
|
||||||
| `comp.shake` | engine | 16 | component camera.CameraShake2D | ok |
|
| `comp.shake` | engine | 16 | component camera.CameraShake2D | ok |
|
||||||
| `comp.sprite` | engine | 16 | Create: Material and Material from Shader; material asset inspector; component engine.Light2D; component engine.MaterialOverride; component engine.SpriteRenderer; template render2d.directional-light; template render2d.point-light; template render2d.spot-light; template render2d.sprite; template render2d.sprite-light | ok |
|
| `comp.sprite` | engine | 16 | Create: Material and Material from Shader; material asset inspector; component engine.MaterialOverride; component engine.SpriteRenderer; template render2d.sprite | ok |
|
||||||
| `comp.tags` | engine | 16 | component engine.Tags | ok |
|
| `comp.tags` | engine | 16 | component engine.Tags | ok |
|
||||||
| `comp.vcam` | engine | 16 | component camera.VirtualCamera2D | ok |
|
| `comp.vcam` | engine | 16 | component camera.VirtualCamera2D | ok |
|
||||||
| `comp.zone` | engine | 16 | component camera.CameraZone2D | ok |
|
| `comp.zone` | engine | 16 | component camera.CameraZone2D | ok |
|
||||||
@@ -92,6 +94,7 @@ Sources are `<id>.svg` files in `engine/res/icons/src` and `modules/<id>/res/ico
|
|||||||
| `file.autosave` | engine | 16 | File → Autosave Scripts | ok |
|
| `file.autosave` | engine | 16 | File → Autosave Scripts | ok |
|
||||||
| `file.data` | engine | 24 | Assets: data asset tiles | ok |
|
| `file.data` | engine | 24 | Assets: data asset tiles | ok |
|
||||||
| `file.download` | engine | 16 | Documentation: Install and run page; PNG inspector: Import section | ok |
|
| `file.download` | engine | 16 | Documentation: Install and run page; PNG inspector: Import section | ok |
|
||||||
|
| `file.export` | engine | 16 | File → Export and the Export panel | ok |
|
||||||
| `file.folder` | engine | 24 | Assets: folders | ok |
|
| `file.folder` | engine | 24 | Assets: folders | ok |
|
||||||
| `file.mesh` | engine | 24 | Assets: mesh tiles | ok |
|
| `file.mesh` | engine | 24 | Assets: mesh tiles | ok |
|
||||||
| `file.open` | engine | 16 | Assets menu: Open | ok |
|
| `file.open` | engine | 16 | Assets menu: Open | ok |
|
||||||
@@ -160,7 +163,7 @@ Sources are `<id>.svg` files in `engine/res/icons/src` and `modules/<id>/res/ico
|
|||||||
| `play.copy_back` | engine | 16 | Component menu during Play: Copy values to edit scene | ok |
|
| `play.copy_back` | engine | 16 | Component menu during Play: Copy values to edit scene | ok |
|
||||||
| `play.pause` | engine | 16 | toolbar: Pause; Animation panel: pause the clip | ok |
|
| `play.pause` | engine | 16 | toolbar: Pause; Animation panel: pause the clip | ok |
|
||||||
| `play.speed` | engine | 16 | Play → Time Scale… | ok |
|
| `play.speed` | engine | 16 | Play → Time Scale… | ok |
|
||||||
| `play.start` | engine | 16 | component animation.AnimationPlayer; component animation.Animator; toolbar: Play; Assets: startup scene mark; Animation panel: play the clip | ok |
|
| `play.start` | engine | 16 | toolbar: Play; Assets: startup scene mark; Animation panel: play the clip | ok |
|
||||||
| `play.step` | engine | 16 | toolbar: Step | ok |
|
| `play.step` | engine | 16 | toolbar: Step | ok |
|
||||||
| `play.step_back` | engine | 16 | Play → Step Back | ok |
|
| `play.step_back` | engine | 16 | Play → Step Back | ok |
|
||||||
| `play.stop` | engine | 16 | toolbar: Stop | ok |
|
| `play.stop` | engine | 16 | toolbar: Stop | ok |
|
||||||
@@ -168,6 +171,12 @@ Sources are `<id>.svg` files in `engine/res/icons/src` and `modules/<id>/res/ico
|
|||||||
| `prefab.revert` | engine | 16 | Prefab menus: Revert and Reset to Prefab Value | ok |
|
| `prefab.revert` | engine | 16 | Prefab menus: Revert and Reset to Prefab Value | ok |
|
||||||
| `prefab.unpack` | engine | 16 | Hierarchy prefab menu: Unpack | ok |
|
| `prefab.unpack` | engine | 16 | Hierarchy prefab menu: Unpack | ok |
|
||||||
| `prefab.variant` | engine | 16 | Assets menu: Create Variant | ok |
|
| `prefab.variant` | engine | 16 | Assets menu: Create Variant | ok |
|
||||||
|
| `render2d.directional_light` | engine | 16 | template render2d.directional-light | ok |
|
||||||
|
| `render2d.normals` | engine | 16 | Scene View: surface normals | ok |
|
||||||
|
| `render2d.point_light` | engine | 16 | Scene View: lighting preview; component engine.Light2D; template render2d.point-light | ok |
|
||||||
|
| `render2d.shadow_view` | engine | 16 | Scene View: shadow visibility | ok |
|
||||||
|
| `render2d.spot_light` | engine | 16 | template render2d.spot-light | ok |
|
||||||
|
| `render2d.sprite_light` | engine | 16 | template render2d.sprite-light | ok |
|
||||||
| `shape.cube` | engine | 16 | template render3d.cube | ok |
|
| `shape.cube` | engine | 16 | template render3d.cube | ok |
|
||||||
| `shape.cylinder` | engine | 16 | template render3d.cylinder | ok |
|
| `shape.cylinder` | engine | 16 | template render3d.cylinder | ok |
|
||||||
| `shape.dice` | engine | 16 | Documentation: random numbers module | ok |
|
| `shape.dice` | engine | 16 | Documentation: random numbers module | ok |
|
||||||
|
|||||||
@@ -135,8 +135,10 @@ type Desc struct {
|
|||||||
Checked func() bool
|
Checked func() bool
|
||||||
Enabled func() bool
|
Enabled func() bool
|
||||||
Run func() error
|
Run func() error
|
||||||
nameKey string
|
// SearchRun optionally opens an item rather than toggling it from Search Everywhere.
|
||||||
tipKey string
|
SearchRun func() error
|
||||||
|
nameKey string
|
||||||
|
tipKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info adds an icon and a tooltip to a command registered elsewhere.
|
// Info adds an icon and a tooltip to a command registered elsewhere.
|
||||||
@@ -312,6 +314,15 @@ func (r *Registry) Enabled(id string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) Run(id string) error {
|
func (r *Registry) Run(id string) error {
|
||||||
|
return r.run(id, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunFromSearch uses the search-specific action when one is registered.
|
||||||
|
func (r *Registry) RunFromSearch(id string) error {
|
||||||
|
return r.run(id, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) run(id string, search bool) error {
|
||||||
d, ok := r.byID[id]
|
d, ok := r.byID[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return errs.New(errs.EditorCommand, "unknown command", log.F("command", id))
|
return errs.New(errs.EditorCommand, "unknown command", log.F("command", id))
|
||||||
@@ -319,7 +330,11 @@ func (r *Registry) Run(id string) error {
|
|||||||
if d.Enabled != nil && !d.Enabled() {
|
if d.Enabled != nil && !d.Enabled() {
|
||||||
return errs.New(errs.EditorCommand, "command is disabled", log.F("command", id))
|
return errs.New(errs.EditorCommand, "command is disabled", log.F("command", id))
|
||||||
}
|
}
|
||||||
err := d.Run()
|
run := d.Run
|
||||||
|
if search && d.SearchRun != nil {
|
||||||
|
run = d.SearchRun
|
||||||
|
}
|
||||||
|
err := run()
|
||||||
if err != nil && r.OnError != nil {
|
if err != nil && r.OnError != nil {
|
||||||
r.OnError(id, err)
|
r.OnError(id, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
package command
|
package command
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"aego/platform/input"
|
"aego/platform/input"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestRunFromSearchUsesOptionalAction(t *testing.T) {
|
||||||
|
r := New()
|
||||||
|
var normal, search int
|
||||||
|
failure := errors.New("search failed")
|
||||||
|
var reported error
|
||||||
|
r.OnError = func(id string, err error) { reported = err }
|
||||||
|
if err := r.Register(Desc{ID: "window.example", Run: func() error {
|
||||||
|
normal++
|
||||||
|
return nil
|
||||||
|
}, SearchRun: func() error {
|
||||||
|
search++
|
||||||
|
return failure
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Run("window.example"); err != nil || normal != 1 || search != 0 {
|
||||||
|
t.Fatalf("normal action: %v, normal %d, search %d", err, normal, search)
|
||||||
|
}
|
||||||
|
if err := r.RunFromSearch("window.example"); !errors.Is(err, failure) || !errors.Is(reported, failure) || search != 1 {
|
||||||
|
t.Fatalf("search action: %v, reported %v, ran %d", err, reported, search)
|
||||||
|
}
|
||||||
|
if err := r.Register(Desc{ID: "ordinary", Run: func() error { normal++; return nil }}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.RunFromSearch("ordinary"); err != nil || normal != 2 {
|
||||||
|
t.Fatalf("ordinary action from search: %v, ran %d", err, normal)
|
||||||
|
}
|
||||||
|
if err := r.Register(Desc{ID: "disabled", Enabled: func() bool { return false }, Run: func() error { return nil }, SearchRun: func() error { search++; return nil }}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.RunFromSearch("disabled"); err == nil || search != 1 {
|
||||||
|
t.Fatal("search must not run a disabled command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAltShortcutDispatches(t *testing.T) {
|
func TestAltShortcutDispatches(t *testing.T) {
|
||||||
r := New()
|
r := New()
|
||||||
ran := 0
|
ran := 0
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ func MenuItem(ui *core.Context, reg *Registry, id, label string) bool {
|
|||||||
Tip: d.TipText(),
|
Tip: d.TipText(),
|
||||||
Enabled: reg.Enabled(id),
|
Enabled: reg.Enabled(id),
|
||||||
Checked: d.Checked != nil && d.Checked(),
|
Checked: d.Checked != nil && d.Checked(),
|
||||||
|
KeepOpen: d.Checked != nil,
|
||||||
}
|
}
|
||||||
if widgets.Entry(ui, it) {
|
if widgets.Entry(ui, it) {
|
||||||
reg.Run(id)
|
reg.Run(id)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ var Editor = []module.IconUse{
|
|||||||
use("cat.engine", 14, "Agent panel: steps that read component types"),
|
use("cat.engine", 14, "Agent panel: steps that read component types"),
|
||||||
use("cat.node", 16, "Search: node results; Add Component and Create: category"),
|
use("cat.node", 16, "Search: node results; Add Component and Create: category"),
|
||||||
use("cat.particles", 16, "Add Component and Create: Effects category"),
|
use("cat.particles", 16, "Add Component and Create: Effects category"),
|
||||||
|
use("cat.render2d", 16, "Add Component and Create: Lights category"),
|
||||||
use("cat.rendering", 16, "Search: mesh assets; Add Component and Create: category"),
|
use("cat.rendering", 16, "Search: mesh assets; Add Component and Create: category"),
|
||||||
use("cat.scripts", 16, "Search: script symbols; Add Component and Create: category"),
|
use("cat.scripts", 16, "Search: script symbols; Add Component and Create: category"),
|
||||||
use("cat.settings", 16, "References: the project file; Add Component and Create: category"),
|
use("cat.settings", 16, "References: the project file; Add Component and Create: category"),
|
||||||
@@ -50,7 +51,7 @@ var Editor = []module.IconUse{
|
|||||||
use("comp.joint_revolute", 16, "Revolute Joint 2D component; Joints debug overlay"),
|
use("comp.joint_revolute", 16, "Revolute Joint 2D component; Joints debug overlay"),
|
||||||
use("comp.polygon", 16, "Polygon shape component; Shapes debug overlay"),
|
use("comp.polygon", 16, "Polygon shape component; Shapes debug overlay"),
|
||||||
use("comp.sprite", 16, "Create: Material and Material from Shader; material asset inspector"),
|
use("comp.sprite", 16, "Create: Material and Material from Shader; material asset inspector"),
|
||||||
use("comp.script", 16, "Scripts component; Assets menu: Attach to Selected"),
|
use("comp.script", 16, "Scripts component and Add Component entries; Assets menu: Attach to Selected"),
|
||||||
use("dbg.aabbs", 16, "AABBs debug overlay"),
|
use("dbg.aabbs", 16, "AABBs debug overlay"),
|
||||||
use("dbg.broadphase", 16, "Broadphase Tree debug overlay"),
|
use("dbg.broadphase", 16, "Broadphase Tree debug overlay"),
|
||||||
use("dbg.contacts", 16, "Contacts debug overlay; Vectors → Contact Impulses"),
|
use("dbg.contacts", 16, "Contacts debug overlay; Vectors → Contact Impulses"),
|
||||||
@@ -78,6 +79,7 @@ var Editor = []module.IconUse{
|
|||||||
use("file.audio", 16, "Documentation: sound clips page"),
|
use("file.audio", 16, "Documentation: sound clips page"),
|
||||||
use("file.autosave", 16, "File → Autosave Scripts"),
|
use("file.autosave", 16, "File → Autosave Scripts"),
|
||||||
use("file.download", 16, "Documentation: Install and run page; PNG inspector: Import section"),
|
use("file.download", 16, "Documentation: Install and run page; PNG inspector: Import section"),
|
||||||
|
use("file.export", 16, "File → Export and the Export panel"),
|
||||||
use("file.data", 24, "Assets: data asset tiles"),
|
use("file.data", 24, "Assets: data asset tiles"),
|
||||||
use("file.folder", 24, "Assets: folders"),
|
use("file.folder", 24, "Assets: folders"),
|
||||||
use("file.mesh", 24, "Assets: mesh tiles"),
|
use("file.mesh", 24, "Assets: mesh tiles"),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"image/png"
|
"image/png"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"aego/editor/core/code/highlight"
|
||||||
)
|
)
|
||||||
|
|
||||||
type glyph struct {
|
type glyph struct {
|
||||||
@@ -91,6 +93,51 @@ func (s *Service) card(header string, lines []string) pixels {
|
|||||||
return raster(im)
|
return raster(im)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// codeCard draws code as colored strokes rather than shrinking bitmap glyphs
|
||||||
|
// into illegible noise at the 48–96px sizes of the asset browser.
|
||||||
|
func codeCard(lines []string) pixels {
|
||||||
|
im := canvas()
|
||||||
|
draw.Draw(im, image.Rect(0, 0, Size, 5), image.NewUniform(accent), image.Point{}, draw.Src)
|
||||||
|
palette := [highlight.KindCount]color.NRGBA{}
|
||||||
|
for i := range palette {
|
||||||
|
palette[i] = color.NRGBA{175, 186, 197, 255}
|
||||||
|
}
|
||||||
|
palette[highlight.Keyword] = color.NRGBA{180, 145, 220, 255}
|
||||||
|
palette[highlight.Script] = color.NRGBA{180, 145, 220, 255}
|
||||||
|
palette[highlight.String] = color.NRGBA{165, 200, 140, 255}
|
||||||
|
palette[highlight.Number] = color.NRGBA{220, 165, 115, 255}
|
||||||
|
palette[highlight.Comment] = color.NRGBA{105, 130, 130, 255}
|
||||||
|
palette[highlight.Type] = color.NRGBA{115, 190, 210, 255}
|
||||||
|
var state highlight.State
|
||||||
|
var spans []highlight.Span
|
||||||
|
for lineNo, line := range lines {
|
||||||
|
y := 13 + lineNo*9
|
||||||
|
if y+3 >= Size-3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
spans, state = highlight.Line([]byte(line), state, spans)
|
||||||
|
for _, sp := range spans {
|
||||||
|
start := -1
|
||||||
|
for i := sp.Start; i <= sp.End; i++ {
|
||||||
|
if i < sp.End && line[i] != ' ' && line[i] != '\t' {
|
||||||
|
if start < 0 {
|
||||||
|
start = i
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if start >= 0 {
|
||||||
|
x0, x1 := 7+start*3, min(7+i*3, Size-7)
|
||||||
|
if x0 < x1 {
|
||||||
|
draw.Draw(im, image.Rect(x0, y, x1, y+3), image.NewUniform(palette[sp.Kind]), image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
start = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raster(im)
|
||||||
|
}
|
||||||
|
|
||||||
func fitPixels(im *image.NRGBA, area image.Rectangle, p pixels) {
|
func fitPixels(im *image.NRGBA, area image.Rectangle, p pixels) {
|
||||||
if p.w < 1 || p.h < 1 {
|
if p.w < 1 || p.h < 1 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -257,10 +257,13 @@ func (s *Service) startCard(e asset.Entry, it *item, spec tasks.Spec, bad bool)
|
|||||||
reason = "GPU preview unavailable in this session"
|
reason = "GPU preview unavailable in this session"
|
||||||
}
|
}
|
||||||
it.task = s.d.Tasks.Work(spec, func(*tasks.Task) error {
|
it.task = s.d.Tasks.Work(spec, func(*tasks.Task) error {
|
||||||
|
cached := false
|
||||||
if !bad && reason == "" {
|
if !bad && reason == "" {
|
||||||
if cached, err := s.load(key); err == nil {
|
if old, err := s.load(key); err == nil {
|
||||||
out = cached
|
out, cached = old, true
|
||||||
return nil
|
if !codeEntry(e) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +289,9 @@ func (s *Service) startCard(e asset.Entry, it *item, spec tasks.Spec, bad bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines = cardLines(e, src)
|
lines = cardLines(e, src)
|
||||||
|
if cached {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if len(src) == 0 && len(e.Artifacts) > 0 && s.d.Pipeline != nil {
|
if len(src) == 0 && len(e.Artifacts) > 0 && s.d.Pipeline != nil {
|
||||||
a := e.Artifacts[0]
|
a := e.Artifacts[0]
|
||||||
if f, err := s.d.Pipeline.Artifacts().Open(a.Path); err == nil {
|
if f, err := s.d.Pipeline.Artifacts().Open(a.Path); err == nil {
|
||||||
@@ -315,7 +321,11 @@ func (s *Service) startCard(e asset.Entry, it *item, spec tasks.Spec, bad bool)
|
|||||||
if reason != "" {
|
if reason != "" {
|
||||||
lines = append([]string{"! " + reason}, lines...)
|
lines = append([]string{"! " + reason}, lines...)
|
||||||
}
|
}
|
||||||
out = s.card(string(e.Kind), lines)
|
if codeEntry(e) && reason == "" && len(src) > 0 {
|
||||||
|
out = codeCard(lines)
|
||||||
|
} else {
|
||||||
|
out = s.card(string(e.Kind), lines)
|
||||||
|
}
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
return s.save(key, out)
|
return s.save(key, out)
|
||||||
}
|
}
|
||||||
@@ -332,6 +342,10 @@ func (s *Service) startCard(e asset.Entry, it *item, spec tasks.Spec, bad bool)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func codeEntry(e asset.Entry) bool {
|
||||||
|
return e.Kind == asset.ArtScript || e.Kind == SourceKind && strings.EqualFold(filepath.Ext(e.Source), ".go")
|
||||||
|
}
|
||||||
|
|
||||||
func textKind(e asset.Entry) bool {
|
func textKind(e asset.Entry) bool {
|
||||||
switch strings.ToLower(filepath.Ext(e.Source)) {
|
switch strings.ToLower(filepath.Ext(e.Source)) {
|
||||||
case ".go", ".aes", ".aeshader", ".glsl", ".vert", ".frag", ".aedata", ".aetable", ".aepreset", ".aectrl", ".aeanim", ".aemat", ".aescene", ".aeprefab", ".aego", ".aetext", ".json", ".csv", ".toml":
|
case ".go", ".aes", ".aeshader", ".glsl", ".vert", ".frag", ".aedata", ".aetable", ".aepreset", ".aectrl", ".aeanim", ".aemat", ".aescene", ".aeprefab", ".aego", ".aetext", ".json", ".csv", ".toml":
|
||||||
@@ -343,21 +357,6 @@ func textKind(e asset.Entry) bool {
|
|||||||
func cardLines(e asset.Entry, src []byte) []string {
|
func cardLines(e asset.Entry, src []byte) []string {
|
||||||
if len(src) > 0 && !bytes.ContainsRune(src, 0) && textKind(e) {
|
if len(src) > 0 && !bytes.ContainsRune(src, 0) && textKind(e) {
|
||||||
ls := firstLines(string(src), lineCount)
|
ls := firstLines(string(src), lineCount)
|
||||||
if e.Kind == asset.ArtScript || filepath.Ext(e.Source) == ".go" {
|
|
||||||
var decl []string
|
|
||||||
for _, line := range strings.Split(string(src), "\n") {
|
|
||||||
l := strings.TrimSpace(line)
|
|
||||||
for _, k := range []string{"script ", "func ", "type "} {
|
|
||||||
if strings.HasPrefix(l, k) {
|
|
||||||
decl = append(decl, strings.TrimSuffix(l, " {"))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(decl) > 0 {
|
|
||||||
ls = decl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(ls) > 0 {
|
if len(ls) > 0 {
|
||||||
return ls
|
return ls
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,9 @@ func (s *Service) Key(e asset.Entry) (string, bool) {
|
|||||||
}
|
}
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
fmt.Fprintf(h, "%s:%d:%s:%s", salt, Size, s.handlers[e.Kind].Version, e.Kind)
|
fmt.Fprintf(h, "%s:%d:%s:%s", salt, Size, s.handlers[e.Kind].Version, e.Kind)
|
||||||
|
if codeEntry(e) {
|
||||||
|
fmt.Fprint(h, "|code-preview-v2")
|
||||||
|
}
|
||||||
if s.handlers[e.Kind].Render != nil && !s.gpuOK {
|
if s.handlers[e.Kind].Render != nil && !s.gpuOK {
|
||||||
fmt.Fprint(h, "|no-gpu")
|
fmt.Fprint(h, "|no-gpu")
|
||||||
}
|
}
|
||||||
@@ -296,7 +299,7 @@ func (s *Service) Missing(info pipeline.AssetInfo) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Lines(info pipeline.AssetInfo) []string {
|
func (s *Service) Lines(info pipeline.AssetInfo) []string {
|
||||||
if info.Kind != asset.ArtScript {
|
if !codeEntry(asset.Entry{Kind: info.Kind, Source: info.Path}) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if it := s.ensure(info); it != nil {
|
if it := s.ensure(info); it != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"aego/render/rt"
|
"aego/render/rt"
|
||||||
"aego/render/view"
|
"aego/render/view"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"aego/asset"
|
"aego/asset"
|
||||||
@@ -85,3 +86,23 @@ func TestFirstLinesSkipImports(t *testing.T) {
|
|||||||
t.Fatalf("lines = %q", got)
|
t.Fatalf("lines = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScriptPreviewUsesSourceAndColoredStrokes(t *testing.T) {
|
||||||
|
src := []byte("package game\n\nscript Player {\n var Speed float = 10\n func Update() {\n Speed = 20\n }\n}\n")
|
||||||
|
e := asset.Entry{Kind: asset.ArtScript, Source: "scripts/player.aes"}
|
||||||
|
lines := cardLines(e, src)
|
||||||
|
if len(lines) < 3 || lines[0] != "script Player {" || !strings.Contains(strings.Join(lines, "\n"), "Speed = 20") {
|
||||||
|
t.Fatalf("preview lost the script contents: %q", lines)
|
||||||
|
}
|
||||||
|
p := codeCard(lines)
|
||||||
|
if p.w != Size || p.h != Size {
|
||||||
|
t.Fatalf("bad card dimensions: %d×%d", p.w, p.h)
|
||||||
|
}
|
||||||
|
colors := map[[4]byte]bool{}
|
||||||
|
for i := 0; i < len(p.buf); i += 4 {
|
||||||
|
colors[[4]byte(p.buf[i:i+4])] = true
|
||||||
|
}
|
||||||
|
if len(colors) < 4 {
|
||||||
|
t.Fatalf("code preview has no syntax strokes: %d colors", len(colors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type Preset struct {
|
|||||||
// Explicit prescribes a default layout, without affecting existing saved layouts.
|
// Explicit prescribes a default layout, without affecting existing saved layouts.
|
||||||
Explicit bool
|
Explicit bool
|
||||||
Front []string
|
Front []string
|
||||||
|
// Splits place default panels beside their anchors; saved layouts are unchanged.
|
||||||
|
Splits []PanelSplit
|
||||||
// Rev drops layouts saved for an older revision of a built-in preset.
|
// Rev drops layouts saved for an older revision of a built-in preset.
|
||||||
Rev int
|
Rev int
|
||||||
Owner string
|
Owner string
|
||||||
@@ -40,6 +42,12 @@ type Preset struct {
|
|||||||
Base string
|
Base string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PanelSplit struct {
|
||||||
|
Panel, Anchor string
|
||||||
|
Side dock.Side
|
||||||
|
Ratio float32
|
||||||
|
}
|
||||||
|
|
||||||
type layout struct {
|
type layout struct {
|
||||||
tree []byte
|
tree []byte
|
||||||
toolbar bool
|
toolbar bool
|
||||||
@@ -76,6 +84,7 @@ func (w *Workspace) RegisterPreset(p Preset) error {
|
|||||||
}
|
}
|
||||||
p.Slots = maps.Clone(p.Slots)
|
p.Slots = maps.Clone(p.Slots)
|
||||||
p.Front = slices.Clone(p.Front)
|
p.Front = slices.Clone(p.Front)
|
||||||
|
p.Splits = slices.Clone(p.Splits)
|
||||||
w.presets = append(w.presets, p)
|
w.presets = append(w.presets, p)
|
||||||
slices.SortStableFunc(w.presets, func(a, b Preset) int { return a.Order - b.Order })
|
slices.SortStableFunc(w.presets, func(a, b Preset) int { return a.Order - b.Order })
|
||||||
if w.cur == "" {
|
if w.cur == "" {
|
||||||
@@ -157,6 +166,17 @@ func (w *Workspace) layoutFor(p *Preset) *dock.Tree {
|
|||||||
}
|
}
|
||||||
t := dock.Columns(cols[SlotLeft], cols[SlotBrowser], cols[SlotCenter], cols[SlotBottom], cols[SlotRight])
|
t := dock.Columns(cols[SlotLeft], cols[SlotBrowser], cols[SlotCenter], cols[SlotBottom], cols[SlotRight])
|
||||||
if p != nil {
|
if p != nil {
|
||||||
|
for _, split := range p.Splits {
|
||||||
|
if !t.Has(split.Panel) || !t.Has(split.Anchor) || split.Panel == split.Anchor {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.DockPanel(split.Panel, t.FindPanel(split.Anchor), split.Side)
|
||||||
|
if node, ok := t.Node(t.FindPanel(split.Panel)); ok && split.Side != dock.SideCenter && node.Parent != 0 && split.Ratio > 0 && split.Ratio < 1 {
|
||||||
|
if parent, ok := t.Node(node.Parent); ok && parent.Kind == dock.KindSplit {
|
||||||
|
parent.Ratio = split.Ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, id := range p.Front {
|
for _, id := range p.Front {
|
||||||
t.Activate(id)
|
t.Activate(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ type FieldVisibility interface {
|
|||||||
FieldVisible(ctx *context.Editor, nodes []ids.NodeID, field *schema.FieldDescriptor) bool
|
FieldVisible(ctx *context.Editor, nodes []ids.NodeID, field *schema.FieldDescriptor) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FieldValidation supplies a localized explanation for an invalid field.
|
||||||
|
type FieldValidation interface {
|
||||||
|
FieldError(ctx *context.Editor, nodes []ids.NodeID, field *schema.FieldDescriptor) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizedCurve marks curves whose key times must stay within 0..1.
|
||||||
|
type NormalizedCurve interface {
|
||||||
|
NormalizedCurve(ctx *context.Editor, nodes []ids.NodeID, field *schema.FieldDescriptor) bool
|
||||||
|
}
|
||||||
|
|
||||||
type CustomFunc func(ctx *context.Editor, nodes []ids.NodeID, defaultFields func())
|
type CustomFunc func(ctx *context.Editor, nodes []ids.NodeID, defaultFields func())
|
||||||
|
|
||||||
func (f CustomFunc) Draw(ctx *context.Editor, nodes []ids.NodeID, defaultFields func()) {
|
func (f CustomFunc) Draw(ctx *context.Editor, nodes []ids.NodeID, defaultFields func()) {
|
||||||
@@ -63,6 +73,28 @@ func (r *Registry) FieldVisible(t schema.TypeID, nodes []ids.NodeID, field *sche
|
|||||||
return !ok || filter.FieldVisible(r.editor, nodes, field)
|
return !ok || filter.FieldVisible(r.editor, nodes, field)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Registry) FieldError(t schema.TypeID, nodes []ids.NodeID, field *schema.FieldDescriptor) string {
|
||||||
|
c, ok := r.Custom(t)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if validation, ok := c.(FieldValidation); ok {
|
||||||
|
return validation.FieldError(r.editor, nodes, field)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) NormalizedCurve(t schema.TypeID, nodes []ids.NodeID, field *schema.FieldDescriptor) bool {
|
||||||
|
c, ok := r.Custom(t)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if limits, ok := c.(NormalizedCurve); ok {
|
||||||
|
return limits.NormalizedCurve(r.editor, nodes, field)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func NewRegistry() *Registry {
|
func NewRegistry() *Registry {
|
||||||
return &Registry{
|
return &Registry{
|
||||||
custom: map[schema.TypeID]Custom{},
|
custom: map[schema.TypeID]Custom{},
|
||||||
|
|||||||
@@ -90,6 +90,32 @@ func (s *safeCustom) FieldVisible(ctx *context.Editor, nodes []ids.NodeID, field
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *safeCustom) FieldError(ctx *context.Editor, nodes []ids.NodeID, field *schema.FieldDescriptor) (message string) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
s.reg.Disable(s.ext, fmt.Sprint(rec))
|
||||||
|
message = ""
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if v, ok := s.inner.(inspect.FieldValidation); ok {
|
||||||
|
return v.FieldError(ctx, nodes, field)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *safeCustom) NormalizedCurve(ctx *context.Editor, nodes []ids.NodeID, field *schema.FieldDescriptor) (normalized bool) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
s.reg.Disable(s.ext, fmt.Sprint(rec))
|
||||||
|
normalized = false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if v, ok := s.inner.(inspect.NormalizedCurve); ok {
|
||||||
|
return v.NormalizedCurve(ctx, nodes, field)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type safeGizmo struct {
|
type safeGizmo struct {
|
||||||
inner gizmo.Gizmo
|
inner gizmo.Gizmo
|
||||||
ext string
|
ext string
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"aego/core/mathx"
|
||||||
|
"aego/editor/core/follow"
|
||||||
|
"aego/editor/core/presence"
|
||||||
|
edselection "aego/editor/core/selection"
|
||||||
|
"aego/editor/core/services"
|
||||||
|
"aego/platform/input"
|
||||||
|
"aego/render/text"
|
||||||
|
"aego/ui/core"
|
||||||
|
"aego/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolbarTestHost struct {
|
||||||
|
*providerTestHost
|
||||||
|
auto bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *toolbarTestHost) AutoApply() bool { return h.auto }
|
||||||
|
func (h *toolbarTestHost) SetAutoApply(v bool) { h.auto = v }
|
||||||
|
|
||||||
|
func TestAgentHeaderControlsRemainUsableWhenNarrow(t *testing.T) {
|
||||||
|
for _, width := range []float32{420, 260} {
|
||||||
|
t.Run(strconv.Itoa(int(width)), func(t *testing.T) {
|
||||||
|
h := &toolbarTestHost{providerTestHost: newProviderTestHost()}
|
||||||
|
p := providerPanel(t, h.providerTestHost)
|
||||||
|
p.d.Host = h
|
||||||
|
p.d.Follow = follow.New(presence.New(), edselection.New(), nil)
|
||||||
|
ui := core.New(text.NewFont(14, 1, 18, 14))
|
||||||
|
ui.Theme = theme.Editor()
|
||||||
|
in := &input.State{}
|
||||||
|
r := mathx.Rect{W: width, H: 520}
|
||||||
|
frame := func(at mathx.Vec2, down, up bool) {
|
||||||
|
in.BeginFrame()
|
||||||
|
in.ApplyMouseMove(at, mathx.Vec2{})
|
||||||
|
if down {
|
||||||
|
in.ApplyMouseDown(at, input.MouseLeft)
|
||||||
|
}
|
||||||
|
if up {
|
||||||
|
in.ApplyMouseUp(at, input.MouseLeft)
|
||||||
|
}
|
||||||
|
ui.Begin(in, mathx.V2(800, 600), 1)
|
||||||
|
ui.Region(r)
|
||||||
|
p.Draw(&services.Frame{UI: ui}, r)
|
||||||
|
ui.PopCursor()
|
||||||
|
ui.End()
|
||||||
|
}
|
||||||
|
click := func(at mathx.Vec2) { frame(at, true, false); frame(at, false, true); frame(at, false, false) }
|
||||||
|
frame(mathx.V2(0, 0), false, false)
|
||||||
|
pad, row := ui.Theme.Padding, ui.Theme.RowHeight
|
||||||
|
y := row + pad + pad + row/2
|
||||||
|
more := mathx.V2(width-pad-row/2, y)
|
||||||
|
followAt := mathx.V2(width-pad-row-4-row/2, y)
|
||||||
|
right := width - pad - 2*(row+4)
|
||||||
|
auto := mathx.V2(right-row/2, y)
|
||||||
|
click(auto)
|
||||||
|
if !h.auto {
|
||||||
|
t.Fatal("auto-apply control is missing or overlaps its neighbours")
|
||||||
|
}
|
||||||
|
click(followAt)
|
||||||
|
if !p.d.Follow.On() {
|
||||||
|
t.Fatal("follow button is missing or overlaps its neighbours")
|
||||||
|
}
|
||||||
|
p.d.Follow.UserActive(1)
|
||||||
|
click(followAt)
|
||||||
|
if !p.d.Follow.Active() {
|
||||||
|
t.Fatal("the follow button must resume a paused follow")
|
||||||
|
}
|
||||||
|
click(more)
|
||||||
|
if !ui.PopupOpen(p.menu) {
|
||||||
|
t.Fatal("session menu button is missing or overlaps its neighbours")
|
||||||
|
}
|
||||||
|
ui.CloseAllPopups()
|
||||||
|
settings := mathx.V2(p.settingsRect.X+p.settingsRect.W/2, p.settingsRect.Y+p.settingsRect.H/2)
|
||||||
|
click(settings)
|
||||||
|
if !ui.PopupOpen(p.providerSettings) {
|
||||||
|
t.Fatal("settings button is missing or overlaps the provider picker")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"aego/core/mathx"
|
||||||
|
"aego/editor/core/services"
|
||||||
|
"aego/platform/input"
|
||||||
|
"aego/render/text"
|
||||||
|
"aego/ui/core"
|
||||||
|
"aego/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Measure the panel itself independently of asynchronous editor work.
|
||||||
|
func TestIdlePanelDrawDoesNotAllocate(t *testing.T) {
|
||||||
|
p := providerPanel(t, newProviderTestHost())
|
||||||
|
ui := core.New(text.NewFont(14, 1, 18, 14))
|
||||||
|
ui.Theme = theme.Editor()
|
||||||
|
in := &input.State{}
|
||||||
|
r := mathx.Rect{W: 420, H: 520}
|
||||||
|
frame := func(draw bool) {
|
||||||
|
in.BeginFrame()
|
||||||
|
ui.Begin(in, mathx.V2(800, 600), 1)
|
||||||
|
ui.Region(r)
|
||||||
|
if draw {
|
||||||
|
p.Draw(&services.Frame{UI: ui}, r)
|
||||||
|
}
|
||||||
|
ui.PopCursor()
|
||||||
|
ui.End()
|
||||||
|
}
|
||||||
|
for range 60 {
|
||||||
|
frame(false)
|
||||||
|
frame(true)
|
||||||
|
}
|
||||||
|
base := testing.AllocsPerRun(100, func() { frame(false) })
|
||||||
|
with := testing.AllocsPerRun(100, func() { frame(true) })
|
||||||
|
if with > base {
|
||||||
|
t.Fatalf("idle panel adds allocations: base=%v with panel=%v", base, with)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"aego/core/mathx"
|
||||||
|
"aego/editor/core/document"
|
||||||
|
"aego/editor/core/services"
|
||||||
|
"aego/engine/base"
|
||||||
|
"aego/platform/input"
|
||||||
|
"aego/scene"
|
||||||
|
"aego/ui/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMentionTypedSearchPickedAndInsertedAtCaret(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, query, label, full string
|
||||||
|
}{
|
||||||
|
{"node", "Hero", "@Hero", "@Hero (#"},
|
||||||
|
{"asset", "walk", "@player_walk.aeanim", "@assets/anim/player_walk.aeanim"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
types, err := base.NewTypes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sc := scene.New(types, nil, scene.Options{})
|
||||||
|
sc.Create(sc.Root(), "Hero")
|
||||||
|
sc.Flush()
|
||||||
|
mgr := document.NewManager(document.Options{Root: t.TempDir(), Types: types})
|
||||||
|
doc := mgr.New("scenes/main.aescene", sc)
|
||||||
|
host := newProviderTestHost()
|
||||||
|
p := providerPanel(t, host)
|
||||||
|
p.d.Docs = &quickDocs{doc: doc}
|
||||||
|
p.d.Assets = func() []string { return []string{"assets/anim/player_walk.aeanim", "assets/anim/enemy.aectrl"} }
|
||||||
|
p.Ask("end")
|
||||||
|
ui, in := core.New(nil), &input.State{}
|
||||||
|
r := mathx.Rect{W: 480, H: 520}
|
||||||
|
draw := func() {
|
||||||
|
ui.Begin(in, mathx.V2(800, 600), 1)
|
||||||
|
ui.Region(r)
|
||||||
|
p.Draw(&services.Frame{UI: ui}, r)
|
||||||
|
ui.PopCursor()
|
||||||
|
ui.End()
|
||||||
|
}
|
||||||
|
frame := func(event func()) {
|
||||||
|
in.BeginFrame()
|
||||||
|
if event != nil {
|
||||||
|
event()
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
frame(nil)
|
||||||
|
frame(func() { in.ApplyKeyDown(input.KeyHome, 0, 0, false) })
|
||||||
|
frame(func() { in.ApplyKeyUp(input.KeyHome, 0, 0) })
|
||||||
|
frame(func() { in.ApplyText([]byte("@")) })
|
||||||
|
if p.pal == nil || !p.pal.Active() || p.input != "end" || ui.Focus() != p.pal.SearchID() {
|
||||||
|
t.Fatalf("opening lost the draft or focus: input=%q focus=%v", p.input, ui.Focus())
|
||||||
|
}
|
||||||
|
frame(func() { in.ApplyText([]byte(tc.query)) })
|
||||||
|
if p.pal.Query() != tc.query || p.input != "end" {
|
||||||
|
t.Fatalf("search copied trigger or leaked into draft: query=%q input=%q", p.pal.Query(), p.input)
|
||||||
|
}
|
||||||
|
frame(func() { in.ApplyKeyDown(input.KeyEnter, 0, 0, false) })
|
||||||
|
if p.pal.Active() || p.input != tc.label+" end" || len(p.picked) != 1 {
|
||||||
|
t.Fatalf("selection not inserted at caret: %q, %#v", p.input, p.picked)
|
||||||
|
}
|
||||||
|
frame(func() { in.ApplyKeyUp(input.KeyEnter, 0, 0) })
|
||||||
|
frame(func() { in.ApplyText([]byte("!")) })
|
||||||
|
if p.input != tc.label+" !end" {
|
||||||
|
t.Fatalf("typing did not resume at the caret: %q", p.input)
|
||||||
|
}
|
||||||
|
p.send()
|
||||||
|
if len(host.sent) != 1 || !strings.Contains(host.sent[0], tc.full) {
|
||||||
|
t.Fatalf("missing full reference in sent text: %q", host.sent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMentionAssetIcons(t *testing.T) {
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"assets/walk.aeanim": "anim.key", "assets/player.aectrl": "anim.graph",
|
||||||
|
"assets/scene.aescene": "file.scene", "assets/data.aetable": "file.table",
|
||||||
|
} {
|
||||||
|
if got := assetIcon(path); got != want {
|
||||||
|
t.Errorf("assetIcon(%q) = %q, want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-63
File diff suppressed because it is too large
Load Diff
@@ -170,7 +170,7 @@ func (p *Panel) providerRow(ui *core.Context, r mathx.Rect) float32 {
|
|||||||
row := mathx.Rect{X: r.X + th.Padding, Y: r.Y + th.Padding/2, W: max(0, r.W-2*th.Padding), H: th.RowHeight}
|
row := mathx.Rect{X: r.X + th.Padding, Y: r.Y + th.Padding/2, W: max(0, r.W-2*th.Padding), H: th.RowHeight}
|
||||||
ui.Rect(mathx.Rect{X: r.X, Y: r.Y, W: r.W, H: height}, th.Header)
|
ui.Rect(mathx.Rect{X: r.X, Y: r.Y, W: r.W, H: height}, th.Header)
|
||||||
label := i18n.Tr("editor.agent.provider_settings", "Agent settings")
|
label := i18n.Tr("editor.agent.provider_settings", "Agent settings")
|
||||||
settingsW := min(row.W/2, ui.TextSize(label).X+20)
|
settingsW := min(row.W, row.H)
|
||||||
choice := mathx.Rect{X: row.X, Y: row.Y, W: min(140, max(0, row.W-settingsW-6)), H: row.H}
|
choice := mathx.Rect{X: row.X, Y: row.Y, W: min(140, max(0, row.W-settingsW-6)), H: row.H}
|
||||||
index := 0
|
index := 0
|
||||||
if provider == "codex" {
|
if provider == "codex" {
|
||||||
@@ -187,7 +187,7 @@ func (p *Panel) providerRow(ui *core.Context, r mathx.Rect) float32 {
|
|||||||
}
|
}
|
||||||
settings := mathx.Rect{X: row.X + row.W - settingsW, Y: row.Y, W: settingsW, H: row.H}
|
settings := mathx.Rect{X: row.X + row.W - settingsW, Y: row.Y, W: settingsW, H: row.H}
|
||||||
p.settingsRect = settings
|
p.settingsRect = settings
|
||||||
if widgets.ButtonRect(ui, ui.ID("provider-settings"), settings, label) {
|
if agentButton(ui, ui.ID("provider-settings"), settings, "panel.settings", "", label, ui.PopupOpen(p.providerSettings)) {
|
||||||
p.readProviderSettings()
|
p.readProviderSettings()
|
||||||
p.loadModels()
|
p.loadModels()
|
||||||
ui.OpenPopup(p.providerSettings, mathx.Vec2{X: max(0, settings.X+settings.W-360), Y: row.Y + row.H + 4})
|
ui.OpenPopup(p.providerSettings, mathx.Vec2{X: max(0, settings.X+settings.W-360), Y: row.Y + row.H + 4})
|
||||||
@@ -202,6 +202,8 @@ func (p *Panel) providerRow(ui *core.Context, r mathx.Rect) float32 {
|
|||||||
}
|
}
|
||||||
mr := mathx.Rect{X: choice.X + choice.W + 8, Y: row.Y, W: max(0, settings.X-choice.X-choice.W-16), H: row.H}
|
mr := mathx.Rect{X: choice.X + choice.W + 8, Y: row.Y, W: max(0, settings.X-choice.X-choice.W-16), H: row.H}
|
||||||
ui.TextIn(mr, p.fits[4].get(ui, model, mr.W), th.TextDim, core.AlignLeft)
|
ui.TextIn(mr, p.fits[4].get(ui, model, mr.W), th.TextDim, core.AlignLeft)
|
||||||
|
ui.MouseOver(ui.ID("model-status"), mr)
|
||||||
|
widgets.Tooltip(ui, ui.ID("model-status"), model)
|
||||||
}
|
}
|
||||||
p.providerPopup(ui)
|
p.providerPopup(ui)
|
||||||
return height
|
return height
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ import (
|
|||||||
|
|
||||||
"aego/core/mathx"
|
"aego/core/mathx"
|
||||||
"aego/editor/core/agentui"
|
"aego/editor/core/agentui"
|
||||||
"aego/editor/core/follow"
|
|
||||||
"aego/editor/core/presence"
|
"aego/editor/core/presence"
|
||||||
"aego/engine/i18n"
|
"aego/engine/i18n"
|
||||||
"aego/ui/core"
|
"aego/ui/core"
|
||||||
"aego/ui/widgets"
|
"aego/ui/widgets"
|
||||||
wpresence "aego/ui/widgets/presence"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// dot is one tool call on the activity timeline: where its block is and how it went.
|
// dot is one tool call on the activity timeline: where its block is and how it went.
|
||||||
@@ -165,49 +163,6 @@ func (p *Panel) undoBack(card int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// where is the row under the header: where the agent is now, and Resume while following is paused.
|
|
||||||
func (p *Panel) where(ui *core.Context, r mathx.Rect) {
|
|
||||||
th := &ui.Theme
|
|
||||||
f := p.d.Follow
|
|
||||||
if f == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ui.Rect(r, th.PanelAlt)
|
|
||||||
x := r.X + th.Padding
|
|
||||||
right := r.X + r.W - th.Padding
|
|
||||||
if f.Paused() {
|
|
||||||
label := i18n.Tr("editor.agent.resume", "Resume Following")
|
|
||||||
bw := ui.TextSize(label).X + 20
|
|
||||||
if widgets.ButtonRect(ui, ui.ID("resume"), mathx.Rect{X: right - bw, Y: r.Y + 2, W: bw, H: r.H - 4}, label) {
|
|
||||||
f.ResumeNow()
|
|
||||||
}
|
|
||||||
right -= bw + 6
|
|
||||||
}
|
|
||||||
sp, at, ok := f.Model.Where(follow.Agent, p.now())
|
|
||||||
if !ok {
|
|
||||||
text := i18n.Tr("editor.agent.following", "Following the agent")
|
|
||||||
if f.Paused() {
|
|
||||||
text = i18n.Tr("editor.agent.following_paused", "Following paused while you work")
|
|
||||||
}
|
|
||||||
ui.Text(p.fits[3].get(ui, text, right-x), mathx.Vec2{X: x, Y: ui.TextY(r)}, th.TextDim)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wpresence.Marker(ui, mathx.Rect{X: x, Y: r.Y + (r.H-14)/2, W: 14, H: 14}, p.now()-at)
|
|
||||||
x += 20
|
|
||||||
label := p.spotLabel(sp)
|
|
||||||
lr := mathx.Rect{X: x, Y: r.Y, W: min(ui.TextSize(label).X+4, right-x), H: r.H}
|
|
||||||
id := ui.ID("where")
|
|
||||||
if ui.Clicked(id, lr) {
|
|
||||||
f.GoTo(sp)
|
|
||||||
}
|
|
||||||
col := th.Text
|
|
||||||
if ui.Hot() == id {
|
|
||||||
col = th.Accent
|
|
||||||
}
|
|
||||||
ui.Text(p.fits[3].get(ui, label, right-x), mathx.Vec2{X: x, Y: ui.TextY(r)}, col)
|
|
||||||
widgets.Tooltip(ui, id, i18n.Tr("editor.agent.where_tip", "Where the agent works now; click to go there"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// spotLabel is a place as the user names it: the file, then the node and the component, or the line.
|
// spotLabel is a place as the user names it: the file, then the node and the component, or the line.
|
||||||
func (p *Panel) spotLabel(sp presence.Spot) string {
|
func (p *Panel) spotLabel(sp presence.Spot) string {
|
||||||
label := docName(sp.Doc)
|
label := docName(sp.Doc)
|
||||||
|
|||||||
@@ -441,7 +441,11 @@ func (p *Panel) empty(ui *core.Context, body mathx.Rect) {
|
|||||||
ui.TextIn(mathx.Rect{X: x, Y: y, W: w, H: th.RowHeight}, i18n.Tr("editor.agent.empty_title", "Ask the agent"), th.Text, core.AlignCenter)
|
ui.TextIn(mathx.Rect{X: x, Y: y, W: w, H: th.RowHeight}, i18n.Tr("editor.agent.empty_title", "Ask the agent"), th.Text, core.AlignCenter)
|
||||||
y += th.RowHeight
|
y += th.RowHeight
|
||||||
hint := i18n.Tr("editor.agent.empty_hint", "Ask about the project or describe a change. Type @ to mention a node or an asset. Changes wait for you to apply them.")
|
hint := i18n.Tr("editor.agent.empty_hint", "Ask about the project or describe a change. Type @ to mention a node or an asset. Changes wait for you to apply them.")
|
||||||
for _, line := range widgets.WrapLines(ui, hint, w) {
|
if p.emptyHint != hint || p.emptyHintWidth != w || p.emptyHintFont != ui.Font() {
|
||||||
|
p.emptyHint, p.emptyHintWidth, p.emptyHintFont = hint, w, ui.Font()
|
||||||
|
p.emptyHintLines = widgets.WrapLines(ui, hint, w)
|
||||||
|
}
|
||||||
|
for _, line := range p.emptyHintLines {
|
||||||
ui.TextIn(mathx.Rect{X: x, Y: y, W: w, H: ui.LineHeight()}, line, th.TextDim, core.AlignCenter)
|
ui.TextIn(mathx.Rect{X: x, Y: y, W: w, H: ui.LineHeight()}, line, th.TextDim, core.AlignCenter)
|
||||||
y += ui.LineHeight()
|
y += ui.LineHeight()
|
||||||
}
|
}
|
||||||
@@ -477,7 +481,7 @@ func (p *Panel) empty(ui *core.Context, body mathx.Rect) {
|
|||||||
}
|
}
|
||||||
ui.RoundRect(er, th.Radius.Button, fill)
|
ui.RoundRect(er, th.Radius.Button, fill)
|
||||||
widgets.IconAt(ui, mathx.Rect{X: er.X + 8, Y: er.Y + (er.H-14)/2, W: 14, H: 14}, "agent.new", th.TextDim)
|
widgets.IconAt(ui, mathx.Rect{X: er.X + 8, Y: er.Y + (er.H-14)/2, W: 14, H: 14}, "agent.new", th.TextDim)
|
||||||
ui.Text(widgets.Elide(ui, ex, er.W-36), mathx.Vec2{X: er.X + 30, Y: ui.TextY(er)}, th.Text)
|
ui.Text(p.emptyFits[i].get(ui, ex, er.W-36), mathx.Vec2{X: er.X + 30, Y: ui.TextY(er)}, th.Text)
|
||||||
y += er.H + th.Spacing
|
y += er.H + th.Spacing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ func (p *Panel) refresh() {
|
|||||||
if changed {
|
if changed {
|
||||||
p.items = append(pl.Assets(), extras...)
|
p.items = append(pl.Assets(), extras...)
|
||||||
p.addCells()
|
p.addCells()
|
||||||
|
p.stacks = nil
|
||||||
p.prune()
|
p.prune()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func (p *Panel) stack(dir string) []int {
|
|||||||
var out []int
|
var out []int
|
||||||
for i := range p.items {
|
for i := range p.items {
|
||||||
it := &p.items[i]
|
it := &p.items[i]
|
||||||
if path.Dir(it.Path) != dir || it.Kind == asset.ArtScript || it.Kind == asset.ArtData || it.Kind == asset.ArtTable {
|
if path.Dir(it.Path) != dir || p.isCell(it.ID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, i)
|
out = append(out, i)
|
||||||
@@ -212,12 +212,16 @@ func (p *Panel) wave(a *pipeline.AssetInfo, r mathx.Rect) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderStack draws up to three previews as sheets sticking out of the folder icon.
|
// folderStack draws scalable folder shapes and up to three sheets of contents.
|
||||||
func (p *Panel) folderStack(dir string, img mathx.Rect) {
|
func (p *Panel) folderStack(dir string, img mathx.Rect) {
|
||||||
ui := p.f.UI
|
ui := p.f.UI
|
||||||
th := &ui.Theme
|
th := &ui.Theme
|
||||||
icon := img.W * 0.82
|
icon := img.W * 0.82
|
||||||
at := mathx.Rect{X: img.X + (img.W-icon)/2, Y: img.Y + (img.H-icon)/2 + icon*0.06, W: icon, H: icon}
|
at := mathx.Rect{X: img.X + (img.W-icon)/2, Y: img.Y + (img.H-icon)/2 + icon*0.06, W: icon, H: icon}
|
||||||
|
back := th.Accent
|
||||||
|
back.R, back.G, back.B = back.R*0.7, back.G*0.7, back.B*0.7
|
||||||
|
ui.RoundRect(mathx.Rect{X: at.X + icon*0.07, Y: at.Y + icon*0.12, W: icon * 0.36, H: icon * 0.22}, icon*0.035, back)
|
||||||
|
ui.RoundRect(mathx.Rect{X: at.X + icon*0.07, Y: at.Y + icon*0.22, W: icon * 0.86, H: icon * 0.64}, icon*0.035, back)
|
||||||
list := p.stack(dir)
|
list := p.stack(dir)
|
||||||
n := min(len(list), 3)
|
n := min(len(list), 3)
|
||||||
if img.W < 40 {
|
if img.W < 40 {
|
||||||
@@ -230,15 +234,29 @@ func (p *Panel) folderStack(dir string, img mathx.Rect) {
|
|||||||
it := &p.items[list[k]]
|
it := &p.items[list[k]]
|
||||||
lift := float32(k%2) * icon * 0.06
|
lift := float32(k%2) * icon * 0.06
|
||||||
cr := mathx.Rect{X: x0 + float32(k)*step, Y: at.Y + icon*0.02 - lift, W: cs, H: cs}
|
cr := mathx.Rect{X: x0 + float32(k)*step, Y: at.Y + icon*0.02 - lift, W: cs, H: cs}
|
||||||
ui.Rect(cr, th.CardBg)
|
fill, border := folderSheetColors(th)
|
||||||
in := mathx.Rect{X: cr.X + 2, Y: cr.Y + 2, W: cr.W - 4, H: cr.H * 0.42}
|
ui.Rect(cr, fill)
|
||||||
|
// The front of the folder covers the lower part of each sheet. Use
|
||||||
|
// the visible portion for the preview instead of a thin strip.
|
||||||
|
in := mathx.Rect{X: cr.X + 2, Y: cr.Y + 2, W: cr.W - 4, H: max(1, cr.H*0.72-2)}
|
||||||
if !p.thumb(it, in) {
|
if !p.thumb(it, in) {
|
||||||
ic := cs * 0.4
|
ic := cs * 0.4
|
||||||
widgets.IconAt(ui, mathx.Rect{X: cr.X + (cs-ic)/2, Y: cr.Y + 2, W: ic, H: ic}, kindIcon(it.Kind), p.kindColor(it.Kind))
|
widgets.IconAt(ui, mathx.Rect{X: cr.X + (cs-ic)/2, Y: cr.Y + 2, W: ic, H: ic}, kindIcon(it.Kind), p.kindColor(it.Kind))
|
||||||
}
|
}
|
||||||
ui.Outline(cr, th.Border)
|
ui.Outline(cr, border)
|
||||||
}
|
}
|
||||||
widgets.IconAt(ui, at, "file.folder", th.Accent)
|
front := mathx.Rect{X: at.X + icon*0.07, Y: at.Y + icon*0.43, W: icon * 0.86, H: icon * 0.43}
|
||||||
|
ui.RoundRect(front, icon*0.035, th.Accent)
|
||||||
|
ui.Rect(mathx.Rect{X: front.X, Y: front.Y, W: front.W, H: max(1, icon*0.025)}, back)
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderSheetColors(th *core.Theme) (mathx.Color, mathx.Color) {
|
||||||
|
if th.Dark {
|
||||||
|
return th.CardBg, th.Border
|
||||||
|
}
|
||||||
|
border := th.TextDim
|
||||||
|
border.A = 0.55
|
||||||
|
return th.WidgetDown, border
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Panel) code(lines []string, r mathx.Rect) {
|
func (p *Panel) code(lines []string, r mathx.Rect) {
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ package assets
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"aego/asset"
|
||||||
|
"aego/asset/pipeline"
|
||||||
|
"aego/core/ids"
|
||||||
"aego/editor/core/code/highlight"
|
"aego/editor/core/code/highlight"
|
||||||
|
"aego/ui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeclName(t *testing.T) {
|
func TestDeclName(t *testing.T) {
|
||||||
@@ -26,3 +30,31 @@ func TestDeclName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLightFolderSheetsContrastWithPanel(t *testing.T) {
|
||||||
|
light, dark := theme.Light(), theme.Editor()
|
||||||
|
fill, border := folderSheetColors(&light)
|
||||||
|
if theme.Contrast(fill, light.Panel) < 1.2 || theme.Contrast(border, fill) < 1.5 {
|
||||||
|
t.Fatal("light folder sheets must be distinguishable from the panel and each other")
|
||||||
|
}
|
||||||
|
fill, border = folderSheetColors(&dark)
|
||||||
|
if fill != dark.CardBg || border != dark.Border {
|
||||||
|
t.Fatal("dark folder sheet colors changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFolderStackIncludesScriptsAndData(t *testing.T) {
|
||||||
|
p := New(Deps{})
|
||||||
|
p.items = []pipeline.AssetInfo{
|
||||||
|
{ID: ids.AssetID{Lo: 1}, Path: "scripts/player/player.aes", Kind: asset.ArtScript},
|
||||||
|
{ID: ids.AssetID{Lo: 2}, Path: "scripts/other.aes", Kind: asset.ArtScript},
|
||||||
|
{ID: ids.AssetID{Lo: 3}, Path: "assets/data/settings.aedata", Kind: asset.ArtData},
|
||||||
|
{ID: ids.AssetID{Lo: 4}, Path: "assets/data/values.aetable", Kind: asset.ArtTable},
|
||||||
|
}
|
||||||
|
if got := p.stack("scripts/player"); len(got) != 1 || got[0] != 0 {
|
||||||
|
t.Fatalf("script previews = %v", got)
|
||||||
|
}
|
||||||
|
if got := p.stack("assets/data"); len(got) != 2 || got[0] != 2 || got[1] != 3 {
|
||||||
|
t.Fatalf("data previews = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user