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
|
||||
|
||||
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).
|
||||
smoke:
|
||||
name: ${{ matrix.goos }} ${{ matrix.goarch }} smoke
|
||||
if: vars.PLATFORM_RUNNERS == 'true'
|
||||
if: github.server_url != 'https://github.com' && vars.PLATFORM_RUNNERS == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -20,6 +20,7 @@ env:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
if: github.server_url != 'https://github.com'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -44,6 +45,7 @@ jobs:
|
||||
run: make nsis-check
|
||||
|
||||
build:
|
||||
if: github.server_url != 'https://github.com'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -121,7 +123,9 @@ jobs:
|
||||
|
||||
release:
|
||||
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
|
||||
permissions:
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -98,7 +99,8 @@ func TestKeyTracksNativeSources(t *testing.T) {
|
||||
|
||||
func TestLocalReplaces(t *testing.T) {
|
||||
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
|
||||
|
||||
@@ -107,9 +109,9 @@ replace aego => ../engine // local engine
|
||||
replace (
|
||||
example.com/a v1.2.3 => ./vendor/a
|
||||
example.com/b => example.com/b v1.0.0
|
||||
example.com/c => /abs/c
|
||||
example.com/c => %s
|
||||
)
|
||||
`)
|
||||
`, absolute))
|
||||
got, err := LocalReplaces(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -117,7 +119,7 @@ replace (
|
||||
want := map[string]bool{
|
||||
filepath.Clean(filepath.Join(root, "../engine")): true,
|
||||
filepath.Clean(filepath.Join(root, "vendor", "a")): true,
|
||||
filepath.Clean("/abs/c"): true,
|
||||
filepath.Clean(absolute): true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("replaces = %v", got)
|
||||
|
||||
+12
-2
@@ -78,11 +78,21 @@ type Bundle struct {
|
||||
func sanitizer() func(string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return func(s string) string {
|
||||
if home != "" {
|
||||
s = strings.ReplaceAll(s, home, "~")
|
||||
return hideHome(s, home)
|
||||
}
|
||||
}
|
||||
|
||||
func hideHome(s, home string) string {
|
||||
if home == "" {
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package crash
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -10,6 +11,9 @@ import (
|
||||
func TestBundleHidesHome(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Setenv("USERPROFILE", home)
|
||||
}
|
||||
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")})
|
||||
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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
@@ -11,10 +12,81 @@ import (
|
||||
"aego/core/mathx"
|
||||
"aego/editor/core/workspace"
|
||||
"aego/modules/render2d"
|
||||
render2deditor "aego/modules/render2d/editor"
|
||||
"aego/scene"
|
||||
"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) {
|
||||
runtime.LockOSThread()
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return sum, err
|
||||
}
|
||||
if actual != clean {
|
||||
return sum, errors.New("export directory was replaced by a symlink")
|
||||
}
|
||||
root, err := os.OpenRoot(clean)
|
||||
root, err := os.OpenRoot(actual)
|
||||
if err != nil {
|
||||
return sum, err
|
||||
}
|
||||
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 maxBytes = 1 << 30
|
||||
var count int
|
||||
|
||||
@@ -59,3 +59,53 @@ func TestExportSealRefusesChildLink(t *testing.T) {
|
||||
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 {
|
||||
return 0
|
||||
}
|
||||
_, rev := si.Diagnostics()
|
||||
return rev
|
||||
return si.Revision()
|
||||
},
|
||||
Append: func(dst []problems.Diagnostic) []problems.Diagnostic {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
i.mu.Lock()
|
||||
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
|
||||
for q < target {
|
||||
t := time.Now()
|
||||
t := mixClockStart()
|
||||
p.m.Mix(p.buf)
|
||||
d := time.Since(t)
|
||||
d := mixClockElapsed(t)
|
||||
w := p.out.Write(p.buf)
|
||||
p.record(d)
|
||||
n++
|
||||
|
||||
@@ -25,6 +25,13 @@ func iconsUsage() string {
|
||||
|
||||
func editorIcons() []module.IconUse {
|
||||
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 {
|
||||
if t.Icon != "" {
|
||||
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 (
|
||||
"bytes"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -31,3 +32,23 @@ func TestIconsCheckReportsMissingEditorIcon(t *testing.T) {
|
||||
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) +
|
||||
`,"dur":` + us(f.Dur) + `,"args":{"gc":` + u(uint64(f.GC)) +
|
||||
`,"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 {
|
||||
name := `""`
|
||||
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)
|
||||
}
|
||||
@@ -27,6 +27,9 @@ type Frame struct {
|
||||
Index uint64
|
||||
Start int64
|
||||
Dur int64
|
||||
// PaceWaitNS is deliberate frame-limiter sleep; IdleWaitNS is its idle-mode part.
|
||||
PaceWaitNS int64
|
||||
IdleWaitNS int64
|
||||
GC uint32
|
||||
Dropped uint32
|
||||
World uint64
|
||||
@@ -158,6 +161,7 @@ func (t *Tracer) BeginFrame() {
|
||||
t.seq++
|
||||
f := &t.frames[t.head]
|
||||
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.Events = f.Events[: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
|
||||
}
|
||||
|
||||
// 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() {
|
||||
if t == nil || t.cur == nil {
|
||||
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.to_end` | engine | 16 | Animation panel: go to end | 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.quit` | engine | 16 | File → Quit | 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.particles` | engine | 16 | Add Component and Create: Effects category; template menu Effects | 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.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 |
|
||||
@@ -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.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.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.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.vcam` | engine | 16 | component camera.VirtualCamera2D | 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.data` | engine | 24 | Assets: data asset tiles | 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.mesh` | engine | 24 | Assets: mesh tiles | 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.pause` | engine | 16 | toolbar: Pause; Animation panel: pause the clip | 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_back` | engine | 16 | Play → Step Back | 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.unpack` | engine | 16 | Hierarchy prefab menu: Unpack | 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.cylinder` | engine | 16 | template render3d.cylinder | ok |
|
||||
| `shape.dice` | engine | 16 | Documentation: random numbers module | ok |
|
||||
|
||||
@@ -135,6 +135,8 @@ type Desc struct {
|
||||
Checked func() bool
|
||||
Enabled func() bool
|
||||
Run func() error
|
||||
// SearchRun optionally opens an item rather than toggling it from Search Everywhere.
|
||||
SearchRun func() error
|
||||
nameKey string
|
||||
tipKey string
|
||||
}
|
||||
@@ -312,6 +314,15 @@ func (r *Registry) Enabled(id string) bool {
|
||||
}
|
||||
|
||||
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]
|
||||
if !ok {
|
||||
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() {
|
||||
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 {
|
||||
r.OnError(id, err)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
r := New()
|
||||
ran := 0
|
||||
|
||||
@@ -20,6 +20,7 @@ func MenuItem(ui *core.Context, reg *Registry, id, label string) bool {
|
||||
Tip: d.TipText(),
|
||||
Enabled: reg.Enabled(id),
|
||||
Checked: d.Checked != nil && d.Checked(),
|
||||
KeepOpen: d.Checked != nil,
|
||||
}
|
||||
if widgets.Entry(ui, it) {
|
||||
reg.Run(id)
|
||||
|
||||
@@ -40,6 +40,7 @@ var Editor = []module.IconUse{
|
||||
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.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.scripts", 16, "Search: script symbols; 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.polygon", 16, "Polygon shape component; Shapes debug overlay"),
|
||||
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.broadphase", 16, "Broadphase Tree debug overlay"),
|
||||
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.autosave", 16, "File → Autosave Scripts"),
|
||||
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.folder", 24, "Assets: folders"),
|
||||
use("file.mesh", 24, "Assets: mesh tiles"),
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"image/png"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"aego/editor/core/code/highlight"
|
||||
)
|
||||
|
||||
type glyph struct {
|
||||
@@ -91,6 +93,51 @@ func (s *Service) card(header string, lines []string) pixels {
|
||||
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) {
|
||||
if p.w < 1 || p.h < 1 {
|
||||
return
|
||||
|
||||
@@ -257,12 +257,15 @@ func (s *Service) startCard(e asset.Entry, it *item, spec tasks.Spec, bad bool)
|
||||
reason = "GPU preview unavailable in this session"
|
||||
}
|
||||
it.task = s.d.Tasks.Work(spec, func(*tasks.Task) error {
|
||||
cached := false
|
||||
if !bad && reason == "" {
|
||||
if cached, err := s.load(key); err == nil {
|
||||
out = cached
|
||||
if old, err := s.load(key); err == nil {
|
||||
out, cached = old, true
|
||||
if !codeEntry(e) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if src == nil && (build == nil || handler.ReadSource) && s.d.Pipeline != nil && !asset.IsBuiltin(e.ID) && textKind(e) {
|
||||
f, err := os.OpenInRoot(s.d.Pipeline.Abs("."), e.Source)
|
||||
@@ -286,6 +289,9 @@ func (s *Service) startCard(e asset.Entry, it *item, spec tasks.Spec, bad bool)
|
||||
}
|
||||
|
||||
lines = cardLines(e, src)
|
||||
if cached {
|
||||
return nil
|
||||
}
|
||||
if len(src) == 0 && len(e.Artifacts) > 0 && s.d.Pipeline != nil {
|
||||
a := e.Artifacts[0]
|
||||
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 != "" {
|
||||
lines = append([]string{"! " + reason}, lines...)
|
||||
}
|
||||
if codeEntry(e) && reason == "" && len(src) > 0 {
|
||||
out = codeCard(lines)
|
||||
} else {
|
||||
out = s.card(string(e.Kind), lines)
|
||||
}
|
||||
if reason == "" {
|
||||
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 {
|
||||
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":
|
||||
@@ -343,21 +357,6 @@ func textKind(e asset.Entry) bool {
|
||||
func cardLines(e asset.Entry, src []byte) []string {
|
||||
if len(src) > 0 && !bytes.ContainsRune(src, 0) && textKind(e) {
|
||||
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 {
|
||||
return ls
|
||||
}
|
||||
|
||||
@@ -174,6 +174,9 @@ func (s *Service) Key(e asset.Entry) (string, bool) {
|
||||
}
|
||||
h := sha256.New()
|
||||
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 {
|
||||
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 {
|
||||
if info.Kind != asset.ArtScript {
|
||||
if !codeEntry(asset.Entry{Kind: info.Kind, Source: info.Path}) {
|
||||
return nil
|
||||
}
|
||||
if it := s.ensure(info); it != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"aego/render/rt"
|
||||
"aego/render/view"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aego/asset"
|
||||
@@ -85,3 +86,23 @@ func TestFirstLinesSkipImports(t *testing.T) {
|
||||
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 bool
|
||||
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 int
|
||||
Owner string
|
||||
@@ -40,6 +42,12 @@ type Preset struct {
|
||||
Base string
|
||||
}
|
||||
|
||||
type PanelSplit struct {
|
||||
Panel, Anchor string
|
||||
Side dock.Side
|
||||
Ratio float32
|
||||
}
|
||||
|
||||
type layout struct {
|
||||
tree []byte
|
||||
toolbar bool
|
||||
@@ -76,6 +84,7 @@ func (w *Workspace) RegisterPreset(p Preset) error {
|
||||
}
|
||||
p.Slots = maps.Clone(p.Slots)
|
||||
p.Front = slices.Clone(p.Front)
|
||||
p.Splits = slices.Clone(p.Splits)
|
||||
w.presets = append(w.presets, p)
|
||||
slices.SortStableFunc(w.presets, func(a, b Preset) int { return a.Order - b.Order })
|
||||
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])
|
||||
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 {
|
||||
t.Activate(id)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ type FieldVisibility interface {
|
||||
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())
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return &Registry{
|
||||
custom: map[schema.TypeID]Custom{},
|
||||
|
||||
@@ -90,6 +90,32 @@ func (s *safeCustom) FieldVisible(ctx *context.Editor, nodes []ids.NodeID, field
|
||||
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 {
|
||||
inner gizmo.Gizmo
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
-59
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}
|
||||
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")
|
||||
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}
|
||||
index := 0
|
||||
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}
|
||||
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.loadModels()
|
||||
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}
|
||||
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)
|
||||
return height
|
||||
|
||||
@@ -6,12 +6,10 @@ import (
|
||||
|
||||
"aego/core/mathx"
|
||||
"aego/editor/core/agentui"
|
||||
"aego/editor/core/follow"
|
||||
"aego/editor/core/presence"
|
||||
"aego/engine/i18n"
|
||||
"aego/ui/core"
|
||||
"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.
|
||||
@@ -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.
|
||||
func (p *Panel) spotLabel(sp presence.Spot) string {
|
||||
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)
|
||||
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.")
|
||||
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)
|
||||
y += ui.LineHeight()
|
||||
}
|
||||
@@ -477,7 +481,7 @@ func (p *Panel) empty(ui *core.Context, body mathx.Rect) {
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ func (p *Panel) refresh() {
|
||||
if changed {
|
||||
p.items = append(pl.Assets(), extras...)
|
||||
p.addCells()
|
||||
p.stacks = nil
|
||||
p.prune()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func (p *Panel) stack(dir string) []int {
|
||||
var out []int
|
||||
for i := range p.items {
|
||||
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
|
||||
}
|
||||
out = append(out, i)
|
||||
@@ -212,12 +212,16 @@ func (p *Panel) wave(a *pipeline.AssetInfo, r mathx.Rect) bool {
|
||||
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) {
|
||||
ui := p.f.UI
|
||||
th := &ui.Theme
|
||||
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}
|
||||
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)
|
||||
n := min(len(list), 3)
|
||||
if img.W < 40 {
|
||||
@@ -230,15 +234,29 @@ func (p *Panel) folderStack(dir string, img mathx.Rect) {
|
||||
it := &p.items[list[k]]
|
||||
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}
|
||||
ui.Rect(cr, th.CardBg)
|
||||
in := mathx.Rect{X: cr.X + 2, Y: cr.Y + 2, W: cr.W - 4, H: cr.H * 0.42}
|
||||
fill, border := folderSheetColors(th)
|
||||
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) {
|
||||
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))
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -3,7 +3,11 @@ package assets
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"aego/asset"
|
||||
"aego/asset/pipeline"
|
||||
"aego/core/ids"
|
||||
"aego/editor/core/code/highlight"
|
||||
"aego/ui/theme"
|
||||
)
|
||||
|
||||
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