Author SHA1 Message Date
sanspie 9612140432 ci: add opt-in Stage web diagnostics 2026-11-07 10:36:17 +03:00
sanspie 31f2da9f79 ci: make native timing and installer smoke independent
Use a deterministic headless frame clock and check the actual 2D and 3D rotation. Create a guarded fresh project with the installed CLI even when preceding tests failed.
2026-11-07 06:43:53 +03:00
sanspie a10633ec8d ci: add opt-in cross-platform Aseprite diagnostics
Run real fixture and headless editor-import tests for an exact Stage commit without changing ordinary native CI.
2026-11-07 06:07:41 +03:00
sanspie ee267286a3 ci: include ancestor ACLs in Windows diagnostics 2026-11-07 05:19:37 +03:00
sanspie e3d7b8c8a5 ci: add isolated native remote store diagnostics
Only an explicit workflow_dispatch with an exact Stage commit runs these tests; ordinary native CI remains unchanged.
2026-11-07 05:08:47 +03:00
sanspie 1ffd388350 ci: isolate native integration tests and retain installer diagnostics 2026-11-07 03:56:53 +03:00
sanspie 747b6bc3b9 fix(audio): measure Windows mix time with performance counter 2026-11-07 03:12:17 +03:00
sanspie 047132e05d fix(windows): preserve user PATH during install 2026-11-07 02:38:43 +03:00
sanspie ee235bbf14 ci: mirror verified Gitea releases to GitHub
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 6m1s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m35s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: success
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
2026-11-07 01:12:53 +03:00
sanspie 216bdbb19d test: measure agent allocations after previews settle 2026-11-07 01:11:28 +03:00
sanspie a54ce4902a fix: make native tests portable across Windows
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Successful in 6m2s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m11s
ci / release (push) Skipped
2026-11-07 00:54:36 +03:00
sanspie 2f768d4166 fix: preserve native export and updater checks
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 7m12s
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 14m42s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: failure
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
2026-11-06 21:04:10 +03:00
sanspie a0107b622a ci: report native GitHub results to Gitea
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m49s
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Successful in 6m36s
ci / release (push) Skipped
2026-11-06 20:43:11 +03:00
sanspie eb937b4470 ci: route mirror jobs safely and build pinned validator
ci / checks (push) Successful in 5m35s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
ci / release (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m0s
2026-11-06 20:33:00 +03:00
sanspie 1d7cee7cc7 test: update light scene preview goldens
ci-platforms / windows amd64 smoke (push) Skipped
github-native / linux amd64 native (push) Skipped
ci / checks (push) Successful in 6m34s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m53s
ci / release (push) Skipped
github-native / darwin amd64 native (push) Canceled after 0s
github-native / windows amd64 native (push) Canceled after 0s
github-native / darwin arm64 native (push) Canceled after 0s
2026-11-06 17:11:45 +03:00
sanspie 85d9efc67b ci: make repository paths portable to Windows 2026-11-06 17:11:18 +03:00
sanspie ab6ea7ef58 editor: improve scene tools and prepare native CI
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / linux amd64 native (push) Skipped
ci / checks (push) Successful in 6m33s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m26s
ci / release (push) Skipped
github-native / darwin amd64 native (push) Canceled after 0s
github-native / windows amd64 native (push) Canceled after 0s
github-native / darwin arm64 native (push) Canceled after 0s
2026-11-06 16:49:11 +03:00
sanspie 9c02975882 engine: cache frame pacer capability
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 5m59s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 8m7s
ci / release (push) Skipped
2026-11-06 15:47:34 +03:00
sanspie 6a148e63e0 ci: pause releases and fix profiler lint
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 11m27s
ci / release (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 5m41s
2026-11-06 15:20:17 +03:00
sanspie 48d1eabef2 profiler: report actual FPS and idle waits
ci / checks (push) Failing after 5m21s
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 7m49s
ci / release (push) Skipped
2026-11-06 12:27:31 +03:00
sanspie 107b09e506 tests: fix golden references for the Design workspace
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m50s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m56s
ci / release (push) Successful in 7m22s
Keep the material inspector fixture full height and refresh the reviewed Russian palette reference for current icons and layout.
2026-11-05 22:53:42 +03:00
sanspie 1f47880e8a docs: update editor guidance for 0.12.1
Document the default Design layout, agent mentions and follow controls, curve preset editing, and Scene View lighting toggles in English and Russian.
2026-11-05 22:41:19 +03:00
sanspie 4920c2a11c release: prepare 0.12.1 and fix CI checks
Prepare the stable editor update and fix CI checks for module icons, diagnostics, updater retries and integration snapshots.
2026-11-05 22:28:27 +03:00
sanspie 5dd9cc91bb editor: refine agent, scene controls and curves
Improve mentions and the default Design workspace, clarify scene lighting controls, and polish editor workflows for 0.12.5-dev.
2026-11-05 20:14:53 +03:00
208 changed files with 6322 additions and 527 deletions
+5
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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:
+5 -1
View File
@@ -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
+6 -4
View File
@@ -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
View File
@@ -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) {
+13
View File
@@ -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)
}
}
}
+72
View File
@@ -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
+84
View File
@@ -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
View File
@@ -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
+50
View File
@@ -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
View File
@@ -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 {
+23
View File
@@ -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")
}
}
+7
View File
@@ -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()
+26
View File
@@ -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)
}
+10
View File
@@ -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) }
+54
View File
@@ -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)
}
}
+59
View File
@@ -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)
}
+26
View File
@@ -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
View File
@@ -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++
+7
View File
@@ -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})
+21
View File
@@ -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)
}
}
+2 -1
View File
@@ -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) {
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
+18 -3
View File
@@ -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)
} }
+36
View File
@@ -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
+1
View File
@@ -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)
+3 -1
View File
@@ -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"),
+47
View File
@@ -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
+18 -19
View File
@@ -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
} }
+4 -1
View File
@@ -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 {
+21
View File
@@ -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))
}
}
+20
View File
@@ -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)
} }
+32
View File
@@ -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{},
+26
View File
@@ -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
+85
View File
@@ -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")
}
})
}
}
+40
View File
@@ -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)
}
}
+91
View File
@@ -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)
}
}
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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
-45
View File
@@ -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)
+6 -2
View File
@@ -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
} }
} }
+1
View File
@@ -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()
} }
} }
+24 -6
View File
@@ -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) {
+32
View File
@@ -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