diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 549f566b..7270da24 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6.4.0 with: - go-version: '1.26.3' + go-version: '1.26.4' cache: false - name: Get dependencies run: sudo apt-get update && sudo apt-get install 7zip gcc libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev libx11-dev xorg-dev libusb-1.0-0-dev libgtk-3-dev libasound2-dev libftdi1 libftdi1-dev diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 495e9710..033b7b39 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6.4.0 with: - go-version: '1.26.3' + go-version: '1.26.4' cache: false - name: Get dependencies run: sudo apt-get update && sudo apt-get install 7zip gcc libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev libx11-dev xorg-dev libusb-1.0-0-dev libgtk-3-dev libasound2-dev libftdi1 libftdi1-dev diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 6036d745..48245c7f 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -24,14 +24,14 @@ jobs: submodules: recursive - name: Install NSIS - uses: repolevedavaj/install-nsis@v1.2.0 + uses: repolevedavaj/install-nsis@v1.2.1 with: nsis-version: '3.11' - name: Set up Go uses: actions/setup-go@v6.4.0 with: - go-version: '1.26.3' + go-version: '1.26.4' cache: false - name: Install dependencies diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 27afe2fc..0ccd16c9 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -27,14 +27,14 @@ jobs: submodules: recursive - name: Install NSIS - uses: repolevedavaj/install-nsis@v1.2.0 + uses: repolevedavaj/install-nsis@v1.2.1 with: nsis-version: '3.11' - name: Set up Go uses: actions/setup-go@v6.4.0 with: - go-version: '1.26.3' + go-version: '1.26.4' cache: false - name: Install dependencies diff --git a/FyneApp.toml b/FyneApp.toml index 88569b1a..2c0705fe 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -5,7 +5,7 @@ Icon = "Icon.png" Name = "txlogger" ID = "com.roffe.txlogger" Version = "2.1.10" -Build = 689 +Build = 691 [Migrations] fyneDo = true diff --git a/Makefile b/Makefile index ba2b5839..f6eb5f34 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,9 @@ endif default: txlogger +pkg/ota/firmware.bin: /home/roffe/Documents/PlatformIO/Projects/txbridge/.pio/build/esp32dev/firmware.bin + @cp $< $@ + cangateway: go build -tags="j2534" -ldflags '-s -w' -o cangateway ../gocangateway @@ -20,9 +23,24 @@ txlogger: release: fyne package -tags=$(BUILDTAGS) --release -run: clean cangateway +debug: clean cangateway + @echo Using compiler "$(CC)" + -go run -tags=$(BUILDTAGS),debug . 2>&1 | tee run.log + +windows: + CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOARCH=386 GOOS=windows go build -tags="j2534" -ldflags '-s -w' -o cangateway.exe ../gocangateway + CGO_CFLAGS="-Ivcpkg/packages/libusb_x64-windows/include/libusb-1.0" \ + CGO_LDFLAGS="-Lvcpkg/packages/libusb_x64-windows/lib" \ + CGO_ENABLED=1 \ + CC=x86_64-w64-mingw32-gcc \ + GOARCH=amd64 \ + GOOS=windows \ + fyne package -os windows -tags=$(BUILDTAGS) --release +# go build -tags=$(BUILDTAGS) -ldflags '-s -w' -o txlogger.exe . + +run: clean cangateway pkg/ota/firmware.bin @echo Using compiler "$(CC)" - -go run -tags=$(BUILDTAGS) . 2>&1 | tee run.log + -GOEXPERIMENT=simd go run -tags=$(BUILDTAGS) . 2>&1 | tee run.log clean: rm -f cangateway diff --git a/build.ps1 b/build.ps1 index 1479b5e0..1b5794ee 100644 --- a/build.ps1 +++ b/build.ps1 @@ -19,7 +19,7 @@ if ($release) { $setup = $true } -$env:CGO_ENABLED = "1" +$env:CGO_ENABLED = "1" $env:GOGC = "100" $env:CC = "x86_64-w64-mingw32-clang.exe" $env:CXX = "x86_64-w64-mingw32-clang++.exe" @@ -45,12 +45,14 @@ if ($cangateway) { # go build -tags="canlib,j2534" -ldflags '-s -w -H=windowsgui' -o cangateway.exe . # Move-Item -Path ".\cangateway.exe" -Destination "$current_path\cangateway.exe" -Force # Set-Location -Path $current_path - go install -tags="j2534" -ldflags '-s -w -H=windowsgui' github.com/roffe/gocangateway@latest + # console subsystem (no -H=windowsgui): GUI-subsystem console-less exes trip AV heuristics. + # txlogger spawns it with CREATE_NO_WINDOW so no console window shows. See pkg/cangw. + go install -tags="j2534" -ldflags '-s -w' github.com/roffe/gocangateway@latest Move-Item -Path "$Env:USERPROFILE\go\bin\windows_386\gocangateway.exe" -Destination "$current_path\cangateway.exe" -Force } else { #Set-Location -Path "..\gocangateway" - go build -tags="j2534" -ldflags '-s -w -H=windowsgui' -o cangateway.exe ..\gocangateway + go build -tags="j2534" -ldflags '-s -w' -o cangateway.exe ..\gocangateway #Move-Item -Path ".\cangateway.exe" -Destination "$current_path\cangateway.exe" -Force #Set-Location -Path $current_path } diff --git a/console_linux.go b/console_linux.go index dad6f1a4..7c7d7d9e 100644 --- a/console_linux.go +++ b/console_linux.go @@ -1,4 +1,3 @@ package main -func InitConsole() { -} +func InitConsole() {} diff --git a/pkg/widgets/ebusmonitor/ebusmonitor.go b/experiments/ebusmonitor/ebusmonitor.go similarity index 100% rename from pkg/widgets/ebusmonitor/ebusmonitor.go rename to experiments/ebusmonitor/ebusmonitor.go diff --git a/pkg/eventbus/aggregators.go b/experiments/eventbus/aggregators.go similarity index 100% rename from pkg/eventbus/aggregators.go rename to experiments/eventbus/aggregators.go diff --git a/experiments/eventbus/bench_test.go b/experiments/eventbus/bench_test.go new file mode 100644 index 00000000..e7a2ebf5 --- /dev/null +++ b/experiments/eventbus/bench_test.go @@ -0,0 +1,140 @@ +package eventbus_test + +import ( + "strconv" + "testing" + + "github.com/roffe/txlogger/experiments/eventbus" +) + +// BenchmarkPublishNoSubscribers measures the bare cost of enqueueing a message +// when nobody is listening (the run loop still drains and processes it). +func BenchmarkPublishNoSubscribers(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Publish("topic", float64(i)) + } +} + +// BenchmarkPublishOneSubscriber measures publish throughput with a single +// active subscriber that immediately drains its channel. +func BenchmarkPublishOneSubscriber(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("topic") + go func() { + for range ch { + } + }() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Publish("topic", float64(i)) + } +} + +// BenchmarkPublishManySubscribers measures fan-out cost across N subscribers. +func BenchmarkPublishManySubscribers(b *testing.B) { + for _, subs := range []int{1, 4, 16, 64} { + b.Run(strconv.Itoa(subs), func(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + for i := 0; i < subs; i++ { + ch := c.Subscribe("topic") + go func() { + for range ch { + } + }() + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Publish("topic", float64(i)) + } + }) + } +} + +// BenchmarkPublishParallel measures publish throughput under contention from +// multiple concurrent publishers. +func BenchmarkPublishParallel(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("topic") + go func() { + for range ch { + } + }() + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.Publish("topic", 1) + } + }) +} + +// BenchmarkSubscribeUnsubscribe measures the cost of the subscribe/unsubscribe +// round trip through the run loop. +func BenchmarkSubscribeUnsubscribe(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + ch := c.Subscribe("topic") + c.Unsubscribe(ch) + } +} + +// BenchmarkAggregatorPublish measures publishing to topics watched by the +// default DIFF aggregators, exercising the aggregator index path. +func BenchmarkAggregatorPublish(b *testing.B) { + c := eventbus.New(nil) + defer c.Close() + + out := c.Subscribe("VDIFFL") + go func() { + for range out { + } + }() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Publish("ActualIn.v_Vehicle", float64(i)) + c.Publish("ActualIn.v_Vehicle2", float64(i)+1) + } +} + +// BenchmarkUnboundedChan measures round-trip throughput of the unbounded +// channel with a concurrent consumer. +func BenchmarkUnboundedChan(b *testing.B) { + ch := eventbus.NewUnboundedChan[int]() + done := make(chan struct{}) + go func() { + for range ch.Out() { + } + close(done) + }() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + ch.In() <- i + } + b.StopTimer() + ch.Close() + <-done +} diff --git a/pkg/eventbus/eventbus.go b/experiments/eventbus/eventbus.go similarity index 100% rename from pkg/eventbus/eventbus.go rename to experiments/eventbus/eventbus.go diff --git a/experiments/eventbus/eventbus_test.go b/experiments/eventbus/eventbus_test.go new file mode 100644 index 00000000..1c000367 --- /dev/null +++ b/experiments/eventbus/eventbus_test.go @@ -0,0 +1,372 @@ +package eventbus_test + +import ( + "io" + "log" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/roffe/txlogger/experiments/eventbus" +) + +const testTimeout = 2 * time.Second + +// TestMain silences the package's drop logging ("publish channel full" etc.), +// which is expected under load and would otherwise flood benchmark output. +func TestMain(m *testing.M) { + log.SetOutput(io.Discard) + code := m.Run() + log.SetOutput(os.Stderr) + os.Exit(code) +} + +// recvWithin returns the next value from ch, failing if nothing arrives within d. +func recvWithin(t *testing.T, ch <-chan float64, d time.Duration) float64 { + t.Helper() + select { + case v, ok := <-ch: + if !ok { + t.Fatal("channel closed while waiting for a value") + } + return v + case <-time.After(d): + t.Fatal("timed out waiting for a value") + return 0 + } +} + +// publishUntilRecv repeatedly invokes pub until a value shows up on ch. Because +// Subscribe and Publish are processed asynchronously on separate channels, a +// single Publish right after Subscribe may race ahead of the subscription being +// registered. Re-publishing until delivery makes the test deterministic. pub +// must only ever publish the same value to the topic ch is subscribed to so the +// returned value is unambiguous. +func publishUntilRecv(t *testing.T, pub func(), ch <-chan float64) float64 { + t.Helper() + deadline := time.After(testTimeout) + for { + pub() + select { + case v, ok := <-ch: + if !ok { + t.Fatal("channel closed while waiting for delivery") + } + return v + case <-time.After(2 * time.Millisecond): + } + select { + case <-deadline: + t.Fatal("subscription never received a published value") + default: + } + } +} + +// drain discards any buffered values currently sitting on ch. +func drain(ch <-chan float64) { + for { + select { + case <-ch: + default: + return + } + } +} + +func TestNewNilConfigUsesDefaults(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("topic") + got := publishUntilRecv(t, func() { c.Publish("topic", 1.5) }, ch) + if got != 1.5 { + t.Fatalf("got %v, want 1.5", got) + } +} + +func TestNewCustomConfig(t *testing.T) { + cfg := &eventbus.Config{IncomingBuffer: 4, SubscribeBuffer: 2, UnsubscribeBuffer: 2} + c := eventbus.New(cfg) + defer c.Close() + + ch := c.Subscribe("topic") + got := publishUntilRecv(t, func() { c.Publish("topic", 42) }, ch) + if got != 42 { + t.Fatalf("got %v, want 42", got) + } +} + +func TestPublishDeliversToSubscriber(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("rpm") + got := publishUntilRecv(t, func() { c.Publish("rpm", 3000) }, ch) + if got != 3000 { + t.Fatalf("got %v, want 3000", got) + } +} + +func TestPublishToOtherTopicNotDelivered(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("a") + // Make sure the subscription is live first. + publishUntilRecv(t, func() { c.Publish("a", 1) }, ch) + drain(ch) + + c.Publish("b", 99) + select { + case v := <-ch: + t.Fatalf("received %v on topic a from a publish to topic b", v) + case <-time.After(50 * time.Millisecond): + } +} + +func TestMultipleSubscribersSameTopic(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch1 := c.Subscribe("temp") + ch2 := c.Subscribe("temp") + + // Keep publishing until both subscriptions are registered and have each + // received at least one value. + var got1, got2 bool + deadline := time.After(testTimeout) + for !(got1 && got2) { + c.Publish("temp", 7) + select { + case <-ch1: + got1 = true + case <-ch2: + got2 = true + case <-time.After(2 * time.Millisecond): + } + select { + case <-deadline: + t.Fatalf("both subscribers did not receive: got1=%v got2=%v", got1, got2) + default: + } + } +} + +func TestSubscribeFuncReceivesAndCancel(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + var count int64 + cancel := c.SubscribeFunc("boost", func(v float64) { + atomic.AddInt64(&count, 1) + }) + + // Publish until the callback fires at least once. + deadline := time.After(testTimeout) + for atomic.LoadInt64(&count) == 0 { + c.Publish("boost", 1) + time.Sleep(2 * time.Millisecond) + select { + case <-deadline: + t.Fatal("SubscribeFunc callback never fired") + default: + } + } + + cancel() + // Give the unsubscribe time to propagate, then confirm no further calls. + time.Sleep(20 * time.Millisecond) + before := atomic.LoadInt64(&count) + for i := 0; i < 50; i++ { + c.Publish("boost", 1) + } + time.Sleep(50 * time.Millisecond) + if after := atomic.LoadInt64(&count); after != before { + t.Fatalf("callback fired after cancel: before=%d after=%d", before, after) + } +} + +func TestUnsubscribeClosesChannel(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("x") + publishUntilRecv(t, func() { c.Publish("x", 1) }, ch) + drain(ch) + + c.Unsubscribe(ch) + + // The run loop closes the channel on unsubscribe; reading should eventually + // observe a closed channel. + deadline := time.After(testTimeout) + for { + select { + case _, ok := <-ch: + if !ok { + return // closed as expected + } + case <-deadline: + t.Fatal("channel was not closed after Unsubscribe") + } + } +} + +func TestUnsubscribeUnknownChannelNoPanic(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + // A channel that was never registered should be ignored without panicking. + ch := make(chan float64, 1) + c.Unsubscribe(ch) + // Confirm the controller is still functional afterwards. + sub := c.Subscribe("ok") + got := publishUntilRecv(t, func() { c.Publish("ok", 5) }, sub) + if got != 5 { + t.Fatalf("got %v, want 5", got) + } +} + +func TestSetOnMessageInvoked(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + var mu sync.Mutex + var lastTopic string + var lastData float64 + var calls int + + c.SetOnMessage(func(topic string, data float64) { + mu.Lock() + lastTopic = topic + lastData = data + calls++ + mu.Unlock() + }) + + deadline := time.After(testTimeout) + for { + c.Publish("hook", 12.5) + time.Sleep(2 * time.Millisecond) + mu.Lock() + got := calls + mu.Unlock() + if got > 0 { + break + } + select { + case <-deadline: + t.Fatal("onMessage callback never fired") + default: + } + } + + mu.Lock() + defer mu.Unlock() + if lastTopic != "hook" || lastData != 12.5 { + t.Fatalf("got topic=%q data=%v, want hook/12.5", lastTopic, lastData) + } +} + +func TestSetOnMessageNilClears(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + var calls int64 + c.SetOnMessage(func(string, float64) { atomic.AddInt64(&calls, 1) }) + + deadline := time.After(testTimeout) + for atomic.LoadInt64(&calls) == 0 { + c.Publish("hook", 1) + time.Sleep(2 * time.Millisecond) + select { + case <-deadline: + t.Fatal("callback never fired before clearing") + default: + } + } + + c.SetOnMessage(nil) + time.Sleep(20 * time.Millisecond) + before := atomic.LoadInt64(&calls) + for i := 0; i < 50; i++ { + c.Publish("hook", 1) + } + time.Sleep(50 * time.Millisecond) + if after := atomic.LoadInt64(&calls); after != before { + t.Fatalf("callback fired after SetOnMessage(nil): before=%d after=%d", before, after) + } +} + +func TestCloseClosesSubscriberChannels(t *testing.T) { + c := eventbus.New(nil) + + ch := c.Subscribe("y") + publishUntilRecv(t, func() { c.Publish("y", 1) }, ch) + drain(ch) + + c.Close() + + deadline := time.After(testTimeout) + for { + select { + case _, ok := <-ch: + if !ok { + return // closed by cleanup + } + case <-deadline: + t.Fatal("subscriber channel not closed after Close") + } + } +} + +func TestCloseIsIdempotent(t *testing.T) { + c := eventbus.New(nil) + c.Close() + // A second Close must not panic (guarded by sync.Once). + c.Close() +} + +func TestDIFFAggregatorPublishesDifference(t *testing.T) { + c := eventbus.New(nil) + defer c.Close() + + // Default aggregators include DIFFAggregator(v_Vehicle, v_Vehicle2, VDIFFL). + out := c.Subscribe("VDIFFL") + + // Publish both inputs repeatedly until the aggregated diff is delivered. + // Each completed pair yields (second - first) = 30 - 10 = 20. + got := publishUntilRecv(t, func() { + c.Publish("ActualIn.v_Vehicle", 10) + c.Publish("ActualIn.v_Vehicle2", 30) + }, out) + if got != 20 { + t.Fatalf("VDIFFL = %v, want 20", got) + } +} + +func TestConcurrentPublishersNoRace(t *testing.T) { + // Primarily intended to be run with -race. + c := eventbus.New(nil) + defer c.Close() + + ch := c.Subscribe("hot") + go func() { + for range ch { + } + }() + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + for j := 0; j < 1000; j++ { + c.Publish("hot", float64(n)) + } + }(i) + } + wg.Wait() +} diff --git a/pkg/eventbus/unbounded.go b/experiments/eventbus/unbounded.go similarity index 100% rename from pkg/eventbus/unbounded.go rename to experiments/eventbus/unbounded.go diff --git a/experiments/eventbus/unbounded_test.go b/experiments/eventbus/unbounded_test.go new file mode 100644 index 00000000..fc337d04 --- /dev/null +++ b/experiments/eventbus/unbounded_test.go @@ -0,0 +1,109 @@ +package eventbus_test + +import ( + "sync" + "testing" + "time" + + "github.com/roffe/txlogger/experiments/eventbus" +) + +func TestUnboundedChanPreservesOrder(t *testing.T) { + ch := eventbus.NewUnboundedChan[int]() + + const n = 10000 + go func() { + for i := 0; i < n; i++ { + ch.In() <- i + } + }() + + for i := 0; i < n; i++ { + select { + case got := <-ch.Out(): + if got != i { + t.Fatalf("out of order: got %d, want %d", got, i) + } + case <-time.After(testTimeout): + t.Fatalf("timed out waiting for value %d", i) + } + } + ch.Close() +} + +func TestUnboundedChanBuffersBeyondCapacity(t *testing.T) { + ch := eventbus.NewUnboundedChan[int]() + + // Send many more values than the underlying in/out buffers (16 each) + // without anyone reading. The unbounded buffer must absorb them without + // blocking the sender. + const n = 5000 + done := make(chan struct{}) + go func() { + for i := 0; i < n; i++ { + ch.In() <- i + } + close(done) + }() + + select { + case <-done: + case <-time.After(testTimeout): + t.Fatal("sender blocked: unbounded channel did not buffer") + } + + for i := 0; i < n; i++ { + if got := <-ch.Out(); got != i { + t.Fatalf("out of order: got %d, want %d", got, i) + } + } + ch.Close() +} + +func TestUnboundedChanCloseClosesOut(t *testing.T) { + ch := eventbus.NewUnboundedChan[int]() + ch.In() <- 1 + ch.In() <- 2 + + // Read the buffered values, then close and confirm Out() is closed. + if got := <-ch.Out(); got != 1 { + t.Fatalf("got %d, want 1", got) + } + if got := <-ch.Out(); got != 2 { + t.Fatalf("got %d, want 2", got) + } + + ch.Close() + + select { + case _, ok := <-ch.Out(): + if ok { + t.Fatal("expected Out() to be drained/closed after Close") + } + case <-time.After(testTimeout): + t.Fatal("Out() was not closed after Close") + } +} + +func TestUnboundedChanConcurrentProducerConsumer(t *testing.T) { + // Primarily intended to be run with -race. + ch := eventbus.NewUnboundedChan[int]() + + const n = 20000 + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < n; i++ { + ch.In() <- i + } + }() + + for i := 0; i < n; i++ { + if got := <-ch.Out(); got != i { + t.Fatalf("out of order: got %d, want %d", got, i) + } + } + wg.Wait() + ch.Close() +} diff --git a/pkg/widgets/settings/loggingsettings/loggingsettings.go b/experiments/loggingsettings/loggingsettings.go similarity index 100% rename from pkg/widgets/settings/loggingsettings/loggingsettings.go rename to experiments/loggingsettings/loggingsettings.go diff --git a/pkg/mdns/mdns.go b/experiments/mdns/mdns.old similarity index 100% rename from pkg/mdns/mdns.go rename to experiments/mdns/mdns.old diff --git a/go.mod b/go.mod index 972bd6d3..2f211f9b 100644 --- a/go.mod +++ b/go.mod @@ -10,18 +10,17 @@ go 1.26.0 replace go.einride.tech/can => github.com/samuelbrian/can-go v0.0.2 require ( - fyne.io/fyne/v2 v2.7.5-0.20260602200529-2bc01b09a210 + fyne.io/fyne/v2 v2.7.5-0.20260627204512-898abc2d3d41 fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e github.com/avast/retry-go/v4 v4.7.0 github.com/lusingander/colorpicker v0.7.5 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 - github.com/pion/mdns/v2 v2.1.0 - github.com/roffe/ecusymbol v1.1.5 - github.com/roffe/gocan v1.3.9 - go.bug.st/serial v1.6.4 + github.com/roffe/ecusymbol v1.2.5 + github.com/roffe/gocan v1.4.5 + go.bug.st/serial v1.7.1 golang.org/x/image v0.40.0 golang.org/x/mod v0.36.0 - golang.org/x/net v0.54.0 + golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 @@ -32,46 +31,44 @@ require ( github.com/ebitengine/oto/v3 v3.4.0 github.com/godbus/dbus/v5 v5.2.2 github.com/hajimehoshi/go-mp3 v0.3.4 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.46.0 kernel.org/pub/linux/libs/security/libcap/cap v1.2.78 ) require ( - fyne.io/systray v1.12.1 // indirect + fyne.io/systray v1.12.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/FyshOS/fancyfs v0.0.1 // indirect - github.com/albenik/bcd v0.0.0-20170831201648-635201416bc7 // indirect github.com/anthonynsimon/bild v0.13.0 // indirect github.com/bendikro/dl v0.0.0-20190410215913-e41fdb9069d4 // indirect - github.com/creack/goselect v0.1.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/ebitengine/purego v0.9.1 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/fredbi/uri v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect - github.com/fyne-io/glfw-js v0.3.0 // indirect + github.com/fyne-io/glfw-js v0.4.0 // indirect github.com/fyne-io/image v0.1.1 // indirect github.com/fyne-io/oksvg v0.2.0 // indirect github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect + github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260627172858-eb9c312d9d47 // indirect github.com/go-text/render v0.2.1 // indirect github.com/go-text/typesetting v0.3.4 // indirect github.com/golang/mock v1.6.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gousb v1.1.3 // indirect + github.com/gotmc/libusb/v2 v2.6.0 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.1 // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-runewidth v0.0.17 // indirect github.com/mdlayher/netlink v1.8.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect - github.com/pion/logging v0.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/go.sum b/go.sum index 74c03543..ce31fc14 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -fyne.io/fyne/v2 v2.7.5-0.20260602200529-2bc01b09a210 h1:V+6+GZq6jtDw2AzRfKhbBP7Yg7oCQPQKOl+oEhbSGLs= -fyne.io/fyne/v2 v2.7.5-0.20260602200529-2bc01b09a210/go.mod h1:MEWwWTgsffhd9B79f31GXZLPfnrlXJVavETgNPzrsG0= -fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= -fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +fyne.io/fyne/v2 v2.7.5-0.20260627204512-898abc2d3d41 h1:cLKNpbITUjaxNU8RFIVWzlUCRwO3s6PD8i1o4sUuM1Y= +fyne.io/fyne/v2 v2.7.5-0.20260627204512-898abc2d3d41/go.mod h1:J1MHvPeMxAUF5zRWfGNP3rNRHAK6ZBJ/OiQl4BjzUtY= +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e h1:O6Bll+49ZD/09VbG8mon6saRTIm7aqzzR+7a3548t7E= fyne.io/x/fyne v0.0.0-20260404122735-cbbdf562353e/go.mod h1:TyPwb4pDTB8+btHM20AJpPUNAF8FqEq136+vcGQhcI8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -11,8 +11,6 @@ github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg= github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/albenik/bcd v0.0.0-20170831201648-635201416bc7 h1:m3Ayfs5OcAlIMEdLIQKubBsVLGee4YMUr14+d1256WE= -github.com/albenik/bcd v0.0.0-20170831201648-635201416bc7/go.mod h1:QIAMbrwsnQZ2ES3G26RubSrDB5SPyzsp9Hts5NJdTrI= github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8= github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= @@ -26,8 +24,6 @@ github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= -github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= -github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -36,8 +32,8 @@ github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/ github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= @@ -47,16 +43,16 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8= github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= -github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= -github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo= +github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU= github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260627172858-eb9c312d9d47 h1:8gV6hg2D33yhLkJQ7E4eHNLMLw/+SmJItBBjkHVikfo= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260627172858-eb9c312d9d47/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -81,6 +77,8 @@ github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8 github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotmc/libusb/v2 v2.6.0 h1:5HRTO1EqchxnWvUIc7l3YtZN8NewWIiQtpSYvUvKu6w= +github.com/gotmc/libusb/v2 v2.6.0/go.mod h1:rU0mvps+snf/CHvEkISbxrwUlWpt6VluOkjqpKo6TFw= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= @@ -106,8 +104,8 @@ github.com/lusingander/colorpicker v0.7.5/go.mod h1:fSixgf1m1Hx7GZUTZhKfPoSrgqrL github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM= @@ -121,12 +119,6 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= -github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= -github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= -github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= -github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= @@ -136,10 +128,10 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/roffe/ecusymbol v1.1.5 h1:mL/i1k8iY85+GLKOpa7JtkyrfDeQLa++89fIGD7XmpI= -github.com/roffe/ecusymbol v1.1.5/go.mod h1:exejs9+FhPTHhUe+ZKAezRIzjZWFyvrANzF6zZ8h7Y0= -github.com/roffe/gocan v1.3.9 h1:6eQ6K4KSLqIQiYWSX4z64PLMcC3PGxZjWs0lv4+xSS8= -github.com/roffe/gocan v1.3.9/go.mod h1:AFv2PzvjSrxeyy2eJgvyDxpLMTTUf7Hx1HfIYhKySlc= +github.com/roffe/ecusymbol v1.2.5 h1:h1ghjJZcm85+n5P+UjJWCiJDXMgy5BUhaFeKgwRJSss= +github.com/roffe/ecusymbol v1.2.5/go.mod h1:Y6vMPbT3P6nVXfUetMZBJKc6N4jPuzpNJfk8bHAfx5Q= +github.com/roffe/gocan v1.4.5 h1:4dLsm9ulGWmpteyEcKWmebnilhaXn3r740DIKAkN/Wg= +github.com/roffe/gocan v1.4.5/go.mod h1:vayI3roc38RKMq5B/yeG+hQt7HUog5Izx8EvpQRrmtg= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -168,8 +160,8 @@ github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhf github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= -go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc= +go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -213,9 +205,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/main.go b/main.go index 51b6f337..949ede54 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,6 @@ import ( "github.com/roffe/txlogger/pkg/presets" "github.com/roffe/txlogger/pkg/theme" "github.com/roffe/txlogger/pkg/windows" - // _ "net/http/pprof" ) var ( @@ -122,14 +121,6 @@ func main() { mw.ShowAndRun() } -/* -func startpprof() { - go func() { - debug.Log(http.ListenAndServe("localhost:6060", nil)) - }() -} -*/ - func killProcess(p *os.Process) { if p != nil { p.Kill() diff --git a/main_linux.go b/main_linux.go index ba83a19e..b40bb54e 100644 --- a/main_linux.go +++ b/main_linux.go @@ -24,6 +24,7 @@ func runFileChild() { } var path string + var paths []string switch req.Op { case "select_folder": path, err = native.OpenFolderDialog(req.Title) @@ -37,6 +38,11 @@ func runFileChild() { Description: req.Desc, Extensions: req.Exts, }) + case "open_files": + paths, err = native.OpenFilesDialog(req.Title, native.FileFilter{ + Description: req.Desc, + Extensions: req.Exts, + }) case "quit": return default: @@ -44,7 +50,7 @@ func runFileChild() { return } - resp := native.FileResponse{Path: path} + resp := native.FileResponse{Path: path, Paths: paths} if err != nil { resp.Err = err.Error() } diff --git a/main_windows.go b/main_windows.go index fbb883a9..eec22213 100644 --- a/main_windows.go +++ b/main_windows.go @@ -1,5 +1,4 @@ package main -func runFileChild() { - // This is a no-op on Windows and will never be called because the FP environment variable is only set in the Linux build, but we need it to exist to satisfy the linker. -} +// This is a no-op on Windows and will never be called because the FP environment variable is only set in the Linux build, but we need it to exist to satisfy the linker. +func runFileChild() {} diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 2dfb2723..000f1a4a 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -10,41 +10,41 @@ import ( // 75 125 150 180 240 300 360 420 480 540 600 660 720 800 900 1100 1300 1500 var tolerancces []int = []int{ - 5, //75 + 5, // 75 5, - 10, //125 + 10, // 125 10, - 10, //150 + 10, // 150 10, - 10, //180 + 10, // 180 10, - 10, //240 + 10, // 240 10, - 15, //300 + 15, // 300 15, - 15, //360 + 15, // 360 15, - 15, //420 + 15, // 420 15, - 15, //480 + 15, // 480 15, - 15, //540 + 15, // 540 15, - 15, //600 + 15, // 600 15, - 20, //660 + 20, // 660 20, - 20, //720 + 20, // 720 20, - 20, //800 + 20, // 800 30, - 30, //900 + 30, // 900 40, - 40, //1100 + 40, // 1100 40, - 50, //1300 + 50, // 1300 50, - 50, //1500 + 50, // 1500 } // AnalyzeLambda analyzes lambda values based on stable pedal conditions diff --git a/pkg/assets/WHATSNEW.md b/pkg/assets/WHATSNEW.md index 981218fe..f31f4aaf 100644 --- a/pkg/assets/WHATSNEW.md +++ b/pkg/assets/WHATSNEW.md @@ -1,8 +1,35 @@ # 2.1.10 -- Performance optimization for the log plotter and meshgrid -- force layouts to be loaded and saved in users home directory under the txlogger folder -- update ecusymbol to be able to read T5 versions +- Added "Compare symbols with other binary" under the Tools menu. Pick a second binary of the same ECU type and get a list of every symbol whose data differs from the currently loaded one. Double-click a row to open that map in three tabs: Current, the other binary, and a Diff tab showing the per-cell difference (current - other). Logging must be stopped while comparing +- Cut a new logfile from a selection in the logplayer: scrub to a spot and press "In" (or the `i` key) to mark the start, scrub again and press "Out" (or `o`) to mark the end, then press the save button to write just that range to a new log next to your other logs. The clip keeps the same format as the source log (csv/bpl/t5l/t7l/t8l). Leaving the In or Out point unset selects from the start or to the end of the log +- Live tracking marker in the 3d mesh viewer showing where the ECU is reading from, mirroring the crosshair in the map above +- Fixed the 3d mesh showing one cell less than the table in each direction; values are now cell-centered so an 18x16 map renders 18x16 cells +- Performance optimization for the meshgrid +- Force layouts to be loaded and saved in users home directory under the txlogger folder +- Update ecusymbol to be able to read T5 versions - Added new config widget for AD scanner WBL settings inspired by T7's DisplAdap.LamScannerTab +- Refactored the bus implementation to use less CPU and have less allocations +- Added support for BPL files ( binary packed logfile ) +- Removed support for creating legacy TXL log files. (you can still load them but might cause crashes) +- Removed ebusmonitor, it has served it's purpose +- Improved drag handler in logplayer, when zoomed in we drag fewer frames increasing as we zoom out +- We now have 3 render modes for viewing 3d maps, Solid Wireframe, Solid & Wireframe. Press the little square icon in the mesh viewer to switch between them +- WBL reconnect COM port while logging. If the COM port dies for a reason during logging it will try to re-connect +- Performance improvements in many widget to allow slower computers to run txlogger better +- Improved camera handling in the 3d mesh viewer - now behaves like the t5/7/8 suites +- Added 2D graph for viewing flat maps +- Rewrote logplayer plotter to use about 50% less CPU on zoomed out views +- Big refactor of the log writing logic to be simpler to maintain and be more performant +- Improved cell selection in mapviewer +- Improved copy paste in mapviewer, added paste here function +- Added a Matrix builder from logfiles. It learns a 2D map from one or more logs: pick which series drives the X axis, the Y axis and supplies the Z value, and every sample that lands on a cell is averaged into it. The result is shown live in a mapviewer (colored grid + 3D mesh) and the cells can be edited by hand + - Load and merge multiple log files at once (t5l, t7l, t8l, csv, bpl); series are row-aligned across files + - Pick X/Y/Z from a dropdown of the loaded series or type a name by hand + - Adjustable column/row counts and fully editable axis breakpoints, with an "Auto" button that spreads a series' min..max evenly across an axis + - Per-axis Z-hit tolerance sliders: reject samples that sit too far from a breakpoint so only values close to a cell count toward it + - Visual filter / query builder: add rules like "if " and a sample only counts as a hit when it satisfies every rule. Operators: >, >=, <, <=, ==, != and ~ (approximately equal) + - Filter query language: instead of the visual rules you can type a full query with and/or, () grouping and the same operators, e.g. "if (ActualIn.n_Engine > 3000 and Out.X_AccPedal > 50) or boost ~ 1.2". Series can be compared to numbers, to each other or to arithmetic of them; a non-empty query overrides the rules + - Save and load configurations as presets (series, dimensions, axis breakpoints, tolerances and filter rules) +- Added a bunch of TransCal maps under Fueling on T7 # 2.1.9 - Updated default T7 preset to include MAF.m_AirFromp_AirInlet diff --git a/pkg/bus/aggregators.go b/pkg/bus/aggregators.go new file mode 100644 index 00000000..bbebbf2c --- /dev/null +++ b/pkg/bus/aggregators.go @@ -0,0 +1,74 @@ +package bus + +import "sync" + +// Number is the set of value types DIFFAggregator can subtract. +type Number interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 +} + +// DIFFAggregator subscribes to the first and second topics and, once both have +// produced a value, publishes their difference (second - first) to the output +// topic. The internal state resets after each emission, so a fresh value must +// arrive on both inputs before the next diff is published. +// +// Unlike the bus itself, Publish may be called concurrently from several +// goroutines, so the aggregator guards its own state with a mutex. The diff is +// published outside the lock to avoid stalling other publishers and to keep the +// output safe to feed back into the bus. +// +// The returned unsubscribe function removes both input subscriptions; calling +// it more than once is safe and has no further effect. +func DIFFAggregator[K comparable, V Number](c *Controller[K, V], first, second, output K) (unsubscribe func()) { + var ( + mu sync.Mutex + firstUpdated bool + secondUpdated bool + firstValue V + secondValue V + ) + + // combine reports the diff when both inputs are fresh, resetting state so the + // next diff waits for new values. Callers must hold mu. + combine := func() (V, bool) { + if firstUpdated && secondUpdated { + diff := secondValue - firstValue + firstUpdated, secondUpdated = false, false + return diff, true + } + var zero V + return zero, false + } + + stopFirst := c.SubscribeFunc(first, func(v V) { + mu.Lock() + firstValue = v + firstUpdated = true + diff, ok := combine() + mu.Unlock() + if ok { + c.Publish(output, diff) + } + }) + + stopSecond := c.SubscribeFunc(second, func(v V) { + mu.Lock() + secondValue = v + secondUpdated = true + diff, ok := combine() + mu.Unlock() + if ok { + c.Publish(output, diff) + } + }) + + var once sync.Once + return func() { + once.Do(func() { + stopFirst() + stopSecond() + }) + } +} diff --git a/pkg/bus/aggregators_test.go b/pkg/bus/aggregators_test.go new file mode 100644 index 00000000..9aee41f8 --- /dev/null +++ b/pkg/bus/aggregators_test.go @@ -0,0 +1,144 @@ +package bus + +import ( + "sync" + "sync/atomic" + "testing" +) + +func TestDIFFAggregatorPublishesDifference(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "first", "second", "out") + + var got []float64 + b.SubscribeFunc("out", func(v float64) { got = append(got, v) }) + + b.Publish("first", 5) + b.Publish("second", 25) + + if len(got) != 1 { + t.Fatalf("expected one diff, got %v", got) + } + if got[0] != 20 { // second - first + t.Fatalf("diff = %v, want 20", got[0]) + } +} + +// A diff is published only once both inputs have a fresh value; a single input +// updating must not emit anything. +func TestDIFFAggregatorWaitsForBothInputs(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "first", "second", "out") + + var count int + b.SubscribeFunc("out", func(float64) { count++ }) + + b.Publish("first", 1) + b.Publish("first", 2) + b.Publish("first", 3) + + if count != 0 { + t.Fatalf("expected no emission with only one input, got %d", count) + } + + b.Publish("second", 10) + if count != 1 { + t.Fatalf("expected one emission once both inputs seen, got %d", count) + } +} + +// State resets after each emission: a new diff requires fresh values on both +// inputs again, not just one. +func TestDIFFAggregatorResetsAfterEmit(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "first", "second", "out") + + var got []float64 + b.SubscribeFunc("out", func(v float64) { got = append(got, v) }) + + b.Publish("first", 1) + b.Publish("second", 4) // emits 3 + b.Publish("second", 9) // no second emit: first is stale + b.Publish("first", 2) // emits 9 - 2 = 7 + + want := []float64{3, 7} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestDIFFAggregatorUnsubscribeStops(t *testing.T) { + b := NewBus[string, float64]() + unsub := DIFFAggregator(b, "first", "second", "out") + + var count int + b.SubscribeFunc("out", func(float64) { count++ }) + + unsub() + unsub() // idempotent + + b.Publish("first", 1) + b.Publish("second", 2) + + if count != 0 { + t.Fatalf("expected no emissions after unsubscribe, got %d", count) + } +} + +// Two aggregators sharing an input topic and output topic keep independent +// state, mirroring the AirDIFF wiring in package ebus. +func TestDIFFAggregatorIndependentStateOnSharedTopics(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "shared", "a", "out") + DIFFAggregator(b, "shared", "b", "out") + + var got []float64 + b.SubscribeFunc("out", func(v float64) { got = append(got, v) }) + + b.Publish("shared", 10) // primes the shared input of both aggregators + b.Publish("a", 30) // first aggregator emits 20 + b.Publish("b", 100) // second aggregator emits 90 + + want := []float64{20, 90} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +// The aggregator must stay race-free when its inputs are published from +// multiple goroutines; meaningful only under -race. +func TestDIFFAggregatorConcurrentPublish(t *testing.T) { + b := NewBus[string, float64]() + DIFFAggregator(b, "first", "second", "out") + + var emitted atomic.Int64 + b.SubscribeFunc("out", func(float64) { emitted.Add(1) }) + + var wg sync.WaitGroup + for _, topic := range []string{"first", "second"} { + wg.Add(1) + go func() { + defer wg.Done() + for i := range 1000 { + b.Publish(topic, float64(i)) + } + }() + } + wg.Wait() + + // Exact count is non-deterministic under interleaving; just confirm the + // aggregator made progress without racing. + if emitted.Load() == 0 { + t.Fatal("expected at least one diff emission") + } +} diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go new file mode 100644 index 00000000..1b055285 --- /dev/null +++ b/pkg/bus/bus.go @@ -0,0 +1,140 @@ +// Package bus implements a small, type-safe publish/subscribe message bus. +// +// Topics are keyed by any comparable type K and carry values of type V. +// Subscribers register either a channel (Subscribe) or a callback +// (SubscribeFunc) and receive every value published to their topic until +// they unsubscribe. +// +// The bus is optimised for a publish-heavy, subscription-stable workload: the +// subscriber table is held as an immutable snapshot behind an atomic pointer, +// so Publish reads it lock-free and allocation-free and scales linearly across +// goroutines. Subscribe/Unsubscribe copy the table (copy-on-write), so they +// cost O(topics + subscribers) — cheap when subscriptions change rarely, which +// is the intended use. +// +// Callbacks run synchronously in the publishing goroutine, so they must be +// fast and non-blocking. A slow callback stalls that Publish call; if you need +// blocking work, hand off to your own goroutine or use Subscribe's buffered +// channel. +package bus + +import ( + "maps" + "sync" + "sync/atomic" +) + +type subscriber[V any] struct { + id uint64 + fn func(V) +} + +// Controller is a concurrency-safe pub/sub bus. The zero value is not usable; +// create one with NewBus. +type Controller[K comparable, V any] struct { + mu sync.Mutex // serialises writers (Subscribe/Unsubscribe) only + nextID uint64 + state atomic.Pointer[map[K][]subscriber[V]] +} + +// NewBus creates an empty bus for topics of type K carrying values of type V. +func NewBus[K comparable, V any]() *Controller[K, V] { + c := &Controller[K, V]{} + empty := make(map[K][]subscriber[V]) + c.state.Store(&empty) + return c +} + +// SubscribeFunc registers fn to be called for every value published to topic. +// It returns an unsubscribe function that removes the subscription; calling it +// more than once is safe and has no further effect. +// +// fn runs synchronously in the goroutine that calls Publish and must not block. +func (c *Controller[K, V]) SubscribeFunc(topic K, fn func(V)) (unsubscribe func()) { + c.mu.Lock() + id := c.nextID + c.nextID++ + c.replace(func(m map[K][]subscriber[V]) { + m[topic] = append(cloneSlice(m[topic]), subscriber[V]{id: id, fn: fn}) + }) + c.mu.Unlock() + + var once sync.Once + return func() { + once.Do(func() { + c.unsubscribe(topic, id) + }) + } +} + +// Subscribe registers a buffered channel that receives every value published +// to topic. The returned unsubscribe function removes the subscription and +// closes the channel. +// +// buffer sets the channel's capacity. If the channel is full when a value is +// published, Publish skips this subscriber rather than blocking, so size the +// buffer for your expected burst rate or drain the channel promptly. +func (c *Controller[K, V]) Subscribe(topic K, buffer int) (ch <-chan V, unsubscribe func()) { + out := make(chan V, buffer) + stop := c.SubscribeFunc(topic, func(v V) { + // Non-blocking send: a slow consumer must not stall the publisher. + select { + case out <- v: + default: + } + }) + + var once sync.Once + return out, func() { + once.Do(func() { + stop() + close(out) + }) + } +} + +// Publish delivers v to every current subscriber of topic. Callbacks run +// synchronously in the caller's goroutine in an unspecified order. This is the +// hot path: it takes no locks and allocates nothing. +func (c *Controller[K, V]) Publish(topic K, v V) { + for _, s := range (*c.state.Load())[topic] { + s.fn(v) + } +} + +func (c *Controller[K, V]) unsubscribe(topic K, id uint64) { + c.mu.Lock() + defer c.mu.Unlock() + c.replace(func(m map[K][]subscriber[V]) { + subs := m[topic] + out := make([]subscriber[V], 0, len(subs)) + for _, s := range subs { + if s.id != id { + out = append(out, s) + } + } + if len(out) == 0 { + delete(m, topic) + } else { + m[topic] = out + } + }) +} + +// replace builds a shallow copy of the current subscriber table, applies mutate +// to the copy, and atomically swaps it in. Callers must hold c.mu so writers +// don't race each other; readers (Publish) never block. The previous table is +// never mutated, so any in-flight Publish keeps iterating a consistent view. +func (c *Controller[K, V]) replace(mutate func(map[K][]subscriber[V])) { + old := *c.state.Load() + next := make(map[K][]subscriber[V], len(old)+1) + maps.Copy(next, old) // slices are immutable once published; share them + mutate(next) + c.state.Store(&next) +} + +func cloneSlice[V any](s []subscriber[V]) []subscriber[V] { + out := make([]subscriber[V], len(s), len(s)+1) + copy(out, s) + return out +} diff --git a/pkg/bus/bus_bench_test.go b/pkg/bus/bus_bench_test.go new file mode 100644 index 00000000..b5cfaa3c --- /dev/null +++ b/pkg/bus/bus_bench_test.go @@ -0,0 +1,40 @@ +package bus + +import ( + "sync/atomic" + "testing" +) + +func benchPublish(b *testing.B, nSubs int) { + bus := NewBus[string, int]() + var sink atomic.Int64 + for range nSubs { + bus.SubscribeFunc("t", func(v int) { sink.Add(int64(v)) }) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + bus.Publish("t", 1) + } +} + +func BenchmarkPublish1(b *testing.B) { benchPublish(b, 1) } +func BenchmarkPublish10(b *testing.B) { benchPublish(b, 10) } +func BenchmarkPublish100(b *testing.B) { benchPublish(b, 100) } +func BenchmarkPublish1000(b *testing.B) { benchPublish(b, 1000) } + +// Contended: many goroutines publishing to the same topic concurrently. +func BenchmarkPublishParallel(b *testing.B) { + bus := NewBus[string, int]() + var sink atomic.Int64 + for range 100 { + bus.SubscribeFunc("t", func(v int) { sink.Add(int64(v)) }) + } + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + bus.Publish("t", 1) + } + }) +} diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go new file mode 100644 index 00000000..3881b7b0 --- /dev/null +++ b/pkg/bus/bus_test.go @@ -0,0 +1,210 @@ +package bus + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestSubscribeFuncReceivesPublishes(t *testing.T) { + b := NewBus[string, int]() + var got []int + b.SubscribeFunc("t", func(v int) { got = append(got, v) }) + + for _, v := range []int{1, 2, 3} { + b.Publish("t", v) + } + + want := []int{1, 2, 3} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestPublishToUnknownTopicIsNoop(t *testing.T) { + b := NewBus[string, int]() + b.SubscribeFunc("a", func(int) { t.Fatal("subscriber on topic a should not fire") }) + b.Publish("b", 1) // different topic — must not panic or deliver +} + +func TestMultipleSubscribersEachReceive(t *testing.T) { + b := NewBus[string, int]() + var c1, c2 int + b.SubscribeFunc("t", func(int) { c1++ }) + b.SubscribeFunc("t", func(int) { c2++ }) + + b.Publish("t", 1) + b.Publish("t", 1) + + if c1 != 2 || c2 != 2 { + t.Fatalf("each subscriber should see 2 messages, got c1=%d c2=%d", c1, c2) + } +} + +func TestUnsubscribeStopsDelivery(t *testing.T) { + b := NewBus[string, int]() + var count int + unsub := b.SubscribeFunc("t", func(int) { count++ }) + + b.Publish("t", 1) + unsub() + b.Publish("t", 1) + + if count != 1 { + t.Fatalf("expected 1 delivery before unsubscribe, got %d", count) + } +} + +func TestUnsubscribeIsIdempotent(t *testing.T) { + b := NewBus[string, int]() + var count int + unsub := b.SubscribeFunc("t", func(int) { count++ }) + + unsub() + unsub() // second call must be a safe no-op + b.Publish("t", 1) + + if count != 0 { + t.Fatalf("expected no deliveries after unsubscribe, got %d", count) + } +} + +// Unsubscribing one subscriber must not affect the others on the same topic. +func TestUnsubscribeLeavesOthersIntact(t *testing.T) { + b := NewBus[string, int]() + var a, c int + unsubA := b.SubscribeFunc("t", func(int) { a++ }) + b.SubscribeFunc("t", func(int) { c++ }) + + unsubA() + b.Publish("t", 1) + + if a != 0 { + t.Fatalf("unsubscribed handler fired %d times", a) + } + if c != 1 { + t.Fatalf("remaining handler should fire once, got %d", c) + } +} + +// A Publish already iterating its snapshot must complete against a consistent +// view even if a handler unsubscribes mid-dispatch. +func TestUnsubscribeFromWithinCallback(t *testing.T) { + b := NewBus[string, int]() + var unsub func() + var calls int + unsub = b.SubscribeFunc("t", func(int) { + calls++ + unsub() // remove self during dispatch + }) + b.SubscribeFunc("t", func(int) {}) // a second sub keeps the topic alive + + b.Publish("t", 1) + b.Publish("t", 1) + + if calls != 1 { + t.Fatalf("self-unsubscribing handler should fire exactly once, got %d", calls) + } +} + +func TestSubscribeChannelDelivers(t *testing.T) { + b := NewBus[string, string]() + ch, unsub := b.Subscribe("t", 4) + defer unsub() + + b.Publish("t", "hello") + select { + case got := <-ch: + if got != "hello" { + t.Fatalf("got %q, want %q", got, "hello") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel delivery") + } +} + +func TestSubscribeChannelClosesOnUnsubscribe(t *testing.T) { + b := NewBus[string, int]() + ch, unsub := b.Subscribe("t", 1) + unsub() + + if _, ok := <-ch; ok { + t.Fatal("channel should be closed after unsubscribe") + } +} + +// A full channel must not block the publisher; excess messages are dropped. +func TestSubscribeChannelDropsWhenFull(t *testing.T) { + b := NewBus[string, int]() + ch, unsub := b.Subscribe("t", 1) + defer unsub() + + b.Publish("t", 1) // fills the buffer + b.Publish("t", 2) // dropped, must not block + + if got := <-ch; got != 1 { + t.Fatalf("got %d, want 1", got) + } + select { + case v := <-ch: + t.Fatalf("expected no second message, got %d", v) + default: + } +} + +// Hammer subscribe/unsubscribe/publish concurrently; meaningful only under -race. +func TestConcurrentChurn(t *testing.T) { + b := NewBus[int, int]() + var wg sync.WaitGroup + var delivered atomic.Int64 + + const topics = 8 + stop := make(chan struct{}) + + // Publishers. + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + for tpc := range topics { + b.Publish(tpc, 1) + } + } + } + }() + } + + // Subscribers churning in and out. + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + for tpc := range topics { + unsub := b.SubscribeFunc(tpc, func(int) { delivered.Add(1) }) + unsub() + } + } + } + }() + } + + time.Sleep(100 * time.Millisecond) + close(stop) + wg.Wait() +} diff --git a/pkg/cangw/cangw.go b/pkg/cangw/cangw.go index b0e86577..a6107ba5 100644 --- a/pkg/cangw/cangw.go +++ b/pkg/cangw/cangw.go @@ -32,8 +32,9 @@ func Start() (*os.Process, error) { } cmd := exec.Command(filepath.Join(wd, exeName)) - // Uncomment on Windows if you want to hide the console window: - // cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + // cangateway is built as a console exe (GUI-subsystem console-less binaries + // trip AV heuristics); hide the console window at launch instead. + hideWindow(cmd) stderr, err := cmd.StderrPipe() if err != nil { diff --git a/pkg/cangw/hidewindow_other.go b/pkg/cangw/hidewindow_other.go new file mode 100644 index 00000000..4cec078a --- /dev/null +++ b/pkg/cangw/hidewindow_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package cangw + +import "os/exec" + +func hideWindow(*exec.Cmd) {} diff --git a/pkg/cangw/hidewindow_windows.go b/pkg/cangw/hidewindow_windows.go new file mode 100644 index 00000000..e4f44dc1 --- /dev/null +++ b/pkg/cangw/hidewindow_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package cangw + +import ( + "os/exec" + "syscall" +) + +// hideWindow runs the console child without spawning a console window, so +// cangateway can be a console-subsystem exe (no -H=windowsgui, which trips AV +// heuristics) while staying invisible to the user. +func hideWindow(cmd *exec.Cmd) { + const createNoWindow = 0x08000000 // CREATE_NO_WINDOW + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow} +} diff --git a/pkg/common/common.go b/pkg/common/common.go index 20b591cf..bdb8c7d4 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -1,6 +1,7 @@ package common import ( + "cmp" "fmt" "math" "os" @@ -89,6 +90,15 @@ func GetLayoutPath() (string, error) { return layoutPath, createDirIfNotExists(layoutPath) } +func GetMatrixBuilderPath() (string, error) { + dir, err := GetUserHomeDir() + if err != nil { + return "", err + } + matrixBuilderPath := GetComponentPath(dir, "matrixbuilder") + return matrixBuilderPath, createDirIfNotExists(matrixBuilderPath) +} + func GetBinPath() (string, error) { dir, err := GetUserHomeDir() if err != nil { @@ -176,3 +186,42 @@ func SameTextBytes(s string, b []byte) bool { } return true } + +func Abs(n int) int { + if n < 0 { + return -n + } + return n +} + +type Number interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 +} + +// Generic function that works with any numeric type +func FindMinMax[T Number](data []T) (T, T) { + if len(data) == 0 { + panic("empty slice") + } + + min, max := data[0], data[0] + for _, v := range data { + if v < min { + min = v + } + if v > max { + max = v + } + } + return min, max +} + +func Clamp[T cmp.Ordered](value, min, max T) T { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/pkg/widgets/plotter/math.go b/pkg/common/math.go similarity index 70% rename from pkg/widgets/plotter/math.go rename to pkg/common/math.go index a555522e..57a2482f 100644 --- a/pkg/widgets/plotter/math.go +++ b/pkg/common/math.go @@ -1,8 +1,8 @@ //go:build !goexperiment.simd || !amd64 -package plotter +package common -func findMinMaxFloat64(data []float64) (float64, float64) { +func FindMinMaxFloat64(data []float64) (float64, float64) { min, max := data[0], data[0] for _, v := range data { if v < min { diff --git a/pkg/widgets/plotter/math_simd.go b/pkg/common/math_simd.go similarity index 93% rename from pkg/widgets/plotter/math_simd.go rename to pkg/common/math_simd.go index c0ba88ee..8009b1f6 100644 --- a/pkg/widgets/plotter/math_simd.go +++ b/pkg/common/math_simd.go @@ -1,12 +1,12 @@ //go:build goexperiment.simd && amd64 -package plotter +package common import ( "simd/archsimd" ) -func findMinMaxFloat64(data []float64) (float64, float64) { +func FindMinMaxFloat64(data []float64) (float64, float64) { n := len(data) if n == 0 { return 0, 0 diff --git a/pkg/datalogger/baselogger.go b/pkg/datalogger/baselogger.go index 0de07ce1..cabc70e7 100644 --- a/pkg/datalogger/baselogger.go +++ b/pkg/datalogger/baselogger.go @@ -9,6 +9,7 @@ import ( "time" "github.com/roffe/gocan" + "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/pkg/wbl" "github.com/roffe/txlogger/relayserver" ) @@ -90,13 +91,15 @@ func (bl *BaseLogger) GetRAM(address uint32, length uint32) ([]byte, error) { return req.Data, req.Wait() } -// update capture counters -func (bl *BaseLogger) onCapture() { +// update capture counters and emit the per-frame tick so live consumers can +// sample every symbol with this frame's real timestamp. +func (bl *BaseLogger) onCapture(t time.Time) { bl.captureCount++ bl.capturePerSecond++ if bl.captureCount%15 == 0 { bl.CaptureCounter(bl.captureCount) } + ebus.PublishFrame(t) } func (bl *BaseLogger) onError() { @@ -114,6 +117,41 @@ func (bl *BaseLogger) calculateCompensatedTimestamp() time.Time { return bl.firstTime.Add(time.Duration(bl.currtimestamp-bl.firstTimestamp) * time.Millisecond) } +// appendExtraSysvars appends the wideband and AD scanner pseudo-symbol names to +// a sysvar order slice when they are active, in the same order they are +// written to the log. +func (bl *BaseLogger) appendExtraSysvars(order []string) []string { + if bl.lamb != nil { + order = append(order, EXTERNALWBLSYM) + } + if bl.WidebandConfig.ADScanner && bl.WidebandConfig.Name == "ECU" { + order = append(order, LAMBDAADSCANNER) + } + return order +} + +// buildChannels assembles the standard log layout: every current sysvar +// (broadcast/derived values plus the active wideband / AD scanner pseudo-symbols) +// becomes an asynchronous sysvar channel, followed by every polled symbol +// (Number >= 0) as a symbol channel. Symbols with a negative number are either +// replaced by a broadcast/derived sysvar or sourced elsewhere and are not log +// columns. Column order is whatever the channel slice yields; the writer is +// consistent across the header and every row because it iterates this slice. +func (bl *BaseLogger) buildChannels() []Channel { + order := bl.appendExtraSysvars(bl.sysvars.Keys()) + channels := make([]Channel, 0, len(order)+len(bl.Symbols)) + for _, name := range order { + channels = append(channels, newSysvarChannel(bl.sysvars, name)) + } + for _, sym := range bl.Symbols { + if sym.Number < 0 { + continue + } + channels = append(channels, newSymbolChannel(sym)) + } + return channels +} + func (bl *BaseLogger) setupWBL(ctx context.Context, cl *gocan.Client) error { cfg := &wbl.WBLConfig{ WBLType: bl.Config.WidebandConfig.Name, diff --git a/pkg/datalogger/channel.go b/pkg/datalogger/channel.go new file mode 100644 index 00000000..90c91f6b --- /dev/null +++ b/pkg/datalogger/channel.go @@ -0,0 +1,97 @@ +package datalogger + +import ( + "math" + "strconv" + + symbol "github.com/roffe/ecusymbol" +) + +// Channel is one ordered column in a log. It pairs a name with a way to read +// its current value and a way to format that value as text. +// +// Whether the value comes from a polled ECU symbol, an asynchronous broadcast +// frame, the wideband controller or anywhere else is captured in the read +// closure and decided once when logging starts. Writers therefore only ever +// see a flat, ordered list of named channels and never need to know about +// sysvars vs symbols or sync vs async values. +type Channel struct { + Name string + read func() float64 + // appendFmt formats the value into dst and returns the extended slice, + // letting writers format straight into a reused buffer with no per-sample + // string garbage. + appendFmt func(dst []byte, v float64) []byte +} + +// Value returns the current value of the channel. +func (c *Channel) Value() float64 { return c.read() } + +// Append formats the current value as text into dst and returns the extended +// slice. Allocation-free when dst has spare capacity. +func (c *Channel) Append(dst []byte) []byte { return c.appendFmt(dst, c.read()) } + +// String returns the current value formatted as text. +func (c *Channel) String() string { return string(c.appendFmt(nil, c.read())) } + +// newSysvarChannel reads the latest value of a named entry in the shared sysvars +// map. Used for asynchronously updated values such as T7 broadcast frames, the +// wideband and AD scanner lambda and other derived values. +func newSysvarChannel(sysvars *ThreadSafeMap, name string) Channel { + return Channel{ + Name: name, + read: func() float64 { return sysvars.Get(name) }, + appendFmt: sysvarFormat(name), + } +} + +// newSymbolChannel reads the value decoded into the symbol on the most recent +// payload Read. +func newSymbolChannel(sym *symbol.Symbol) Channel { + return Channel{ + Name: sym.Name, + read: sym.Float64, + appendFmt: symbolFormat(sym.Correctionfactor), + } +} + +func newFunctionChannel(name string, read func() float64) Channel { + return Channel{ + Name: name, + read: read, + appendFmt: func(dst []byte, v float64) []byte { return strconv.AppendFloat(dst, v, 'f', 2, 64) }, + } +} + +// sysvarFormat mirrors the precision rules the text writers used for sysvars: +// whole numbers print without decimals, the external wideband lambda prints +// with three decimals and everything else with two. +func sysvarFormat(name string) func(dst []byte, v float64) []byte { + return func(dst []byte, v float64) []byte { + prec := 2 + switch { + case v == math.Trunc(v): + prec = 0 + case name == EXTERNALWBLSYM: + prec = 3 + } + return strconv.AppendFloat(dst, v, 'f', prec, 64) + } +} + +// symbolFormat mirrors symbol.StringValue: the number of decimals is derived +// from the symbol correction factor. +func symbolFormat(correctionfactor float64) func(dst []byte, v float64) []byte { + prec := 0 + switch correctionfactor { + case 0.1: + prec = 1 + case 0.01, 0.0078125, 0.0009765625, 0.00390625, 0.004: + prec = 2 + case 0.001: + prec = 3 + } + return func(dst []byte, v float64) []byte { + return strconv.AppendFloat(dst, v, 'f', prec, 64) + } +} diff --git a/pkg/datalogger/datalogger.go b/pkg/datalogger/datalogger.go index 72eb00ab..402aaf37 100644 --- a/pkg/datalogger/datalogger.go +++ b/pkg/datalogger/datalogger.go @@ -17,7 +17,7 @@ const ( ) type LogWriter interface { - Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error + Write(ts time.Time, channels []Channel) error Close() error } @@ -28,10 +28,6 @@ type IClient interface { Close() } -type Consumer interface { - SetValue(string, float64) -} - type Config struct { FilenamePrefix string ECU string diff --git a/pkg/datalogger/log.go b/pkg/datalogger/log.go index cae00a60..1bb87b76 100644 --- a/pkg/datalogger/log.go +++ b/pkg/datalogger/log.go @@ -7,7 +7,6 @@ import ( "strings" "time" - symbol "github.com/roffe/ecusymbol" "github.com/roffe/txlogger/pkg/common" ) @@ -37,7 +36,7 @@ func NewWriter(cfg Config) (string, LogWriter, error) { func createLog(path, prefix, extension string) (*os.File, string, error) { if _, err := os.Stat(path); os.IsNotExist(err) { - if err := os.Mkdir(path, 0755); err != nil { + if err := os.Mkdir(path, 0o755); err != nil { if err != os.ErrExist { return nil, "", fmt.Errorf("failed to create logs dir: %w", err) } @@ -49,28 +48,9 @@ func createLog(path, prefix, extension string) (*os.File, string, error) { fullFilename := filepath.Join(path, filename) - file, err := os.OpenFile(fullFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + file, err := os.OpenFile(fullFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) if err != nil { return nil, "", fmt.Errorf("failed to open file: %w", err) } return file, fullFilename, nil } - -func replaceDot(s string) string { - return strings.Replace(s, ".", ",", 1) -} - -type TXBinWriter struct { - file *os.File -} - -func NewTXBinWriter(f *os.File) *TXBinWriter { - return &TXBinWriter{ - file: f, - } -} - -func (t *TXBinWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error { - - return nil -} diff --git a/pkg/datalogger/log_bpl.go b/pkg/datalogger/log_bpl.go index c223928e..3fd958f2 100644 --- a/pkg/datalogger/log_bpl.go +++ b/pkg/datalogger/log_bpl.go @@ -6,8 +6,6 @@ import ( "math" "os" "time" - - symbol "github.com/roffe/ecusymbol" ) // BPL (Binary Packed Logfile) on-disk layout. All multi-byte integers are @@ -48,9 +46,9 @@ type BPLWriter struct { buf []byte // reusable per-record encode buffer } -func (b *BPLWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error { +func (b *BPLWriter) Write(ts time.Time, channels []Channel) error { if !b.headerWritten { - if err := b.writeHeader(vars, sysvarOrder); err != nil { + if err := b.writeHeader(channels); err != nil { return err } } @@ -59,15 +57,8 @@ func (b *BPLWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []* binary.LittleEndian.PutUint64(b.buf[off:], uint64(ts.UnixNano())) off += 8 - for _, k := range sysvarOrder { - binary.LittleEndian.PutUint32(b.buf[off:], math.Float32bits(float32(sysvars.Get(k)))) - off += 4 - } - for _, va := range vars { - if va.Number < 0 { - continue - } - binary.LittleEndian.PutUint32(b.buf[off:], math.Float32bits(float32(va.Float64()))) + for i := range channels { + binary.LittleEndian.PutUint32(b.buf[off:], math.Float32bits(float32(channels[i].Value()))) off += 4 } @@ -75,14 +66,10 @@ func (b *BPLWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []* return err } -func (b *BPLWriter) writeHeader(vars []*symbol.Symbol, sysvarOrder []string) error { - cols := make([]string, 0, len(sysvarOrder)+len(vars)) - cols = append(cols, sysvarOrder...) - for _, va := range vars { - if va.Number < 0 { - continue - } - cols = append(cols, va.Name) +func (b *BPLWriter) writeHeader(channels []Channel) error { + cols := make([]string, 0, len(channels)) + for i := range channels { + cols = append(cols, channels[i].Name) } if _, err := b.bw.WriteString(bplMagic); err != nil { diff --git a/pkg/datalogger/log_csv.go b/pkg/datalogger/log_csv.go index bc247f01..03352f94 100644 --- a/pkg/datalogger/log_csv.go +++ b/pkg/datalogger/log_csv.go @@ -2,12 +2,9 @@ package datalogger import ( "encoding/csv" - "math" "os" - "strconv" "time" - symbol "github.com/roffe/ecusymbol" "github.com/roffe/txlogger/pkg/logfile" ) @@ -22,46 +19,27 @@ type CSVWriter struct { file *os.File headerWritten bool cw *csv.Writer - precission int } -func (c *CSVWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error { +func (c *CSVWriter) Write(ts time.Time, channels []Channel) error { if !c.headerWritten { - if err := c.writeHeader(vars, sysvarOrder); err != nil { + if err := c.writeHeader(channels); err != nil { return err } } - var record []string + record := make([]string, 0, len(channels)+1) record = append(record, ts.Format(logfile.ISONICO)) - for _, k := range sysvarOrder { - val := sysvars.Get(k) - if val == math.Trunc(val) { - c.precission = 0 - } else if k == "Lambda.External" { - c.precission = 3 - } else { - c.precission = 2 - } - record = append(record, strconv.FormatFloat(val, 'f', c.precission, 64)) - } - for _, va := range vars { - if va.Number < 0 { - continue - } - record = append(record, va.StringValue()) + for i := range channels { + record = append(record, channels[i].String()) } return c.cw.Write(record) } -func (c *CSVWriter) writeHeader(vars []*symbol.Symbol, sysvarOrder []string) error { - var header []string +func (c *CSVWriter) writeHeader(channels []Channel) error { + header := make([]string, 0, len(channels)+1) header = append(header, "Time") - header = append(header, sysvarOrder...) - for _, va := range vars { - if va.Number < 0 { - continue - } - header = append(header, va.Name) + for i := range channels { + header = append(header, channels[i].Name) } c.headerWritten = true return c.cw.Write(header) diff --git a/pkg/datalogger/log_export.go b/pkg/datalogger/log_export.go new file mode 100644 index 00000000..dac69d32 --- /dev/null +++ b/pkg/datalogger/log_export.go @@ -0,0 +1,83 @@ +package datalogger + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/roffe/txlogger/pkg/logfile" +) + +// ExportRecords writes the given records to a new logfile in dir, picking the +// writer from ext (one of "csv", "bpl", "t5l", "t7l", "t8l") so the exported +// clip keeps the same format as the log it was cut from. The filename is built +// from prefix plus a timestamp by createLog. It returns the full path of the +// created file. +func ExportRecords(dir, prefix, ext string, records []logfile.Record) (string, error) { + if len(records) == 0 { + return "", errors.New("no records to export") + } + + ext = strings.ToLower(strings.TrimPrefix(ext, ".")) + + file, filename, err := createLog(dir, prefix, ext) + if err != nil { + return "", err + } + + var w LogWriter + switch ext { + case "csv": + w = NewCSVWriter(file) + case "bpl": + w = NewBPLWriter(file) + case "t5l", "t7l", "t8l": + w = NewTXLWriter(file) + default: + file.Close() + return "", fmt.Errorf("unsupported export format: %s", ext) + } + + cols := recordColumns(records[0]) + + // One channel set is built up front; the backing values are rewritten for + // each record so we don't allocate a closure per cell. + values := make([]float64, len(cols)) + channels := make([]Channel, len(cols)) + for i, name := range cols { + i := i + channels[i] = Channel{ + Name: name, + read: func() float64 { return values[i] }, + appendFmt: sysvarFormat(name), + } + } + + for i := range records { + rec := records[i] + for j, name := range cols { + values[j] = rec.Values[name] + } + if err := w.Write(rec.Time, channels); err != nil { + w.Close() + return "", fmt.Errorf("failed to write record: %w", err) + } + } + + if err := w.Close(); err != nil { + return "", fmt.Errorf("failed to finalize log: %w", err) + } + return filename, nil +} + +// recordColumns returns the value column names of a record in a stable +// (alphabetical) order so the exported log has a deterministic layout. +func recordColumns(rec logfile.Record) []string { + cols := make([]string, 0, len(rec.Values)) + for k := range rec.Values { + cols = append(cols, k) + } + sort.Strings(cols) + return cols +} diff --git a/pkg/datalogger/log_txl.go b/pkg/datalogger/log_txl.go index 922c861e..b10019ad 100644 --- a/pkg/datalogger/log_txl.go +++ b/pkg/datalogger/log_txl.go @@ -1,12 +1,8 @@ package datalogger import ( - "math" "os" - "strconv" "time" - - symbol "github.com/roffe/ecusymbol" ) func NewTXLWriter(f *os.File) *TXWriter { @@ -16,37 +12,30 @@ func NewTXLWriter(f *os.File) *TXWriter { } type TXWriter struct { - file *os.File - precission int + file *os.File + buf []byte // reusable per-line buffer } -func (t *TXWriter) Write(sysvars *ThreadSafeMap, sysvarOrder []string, vars []*symbol.Symbol, ts time.Time) error { - _, err := t.file.Write([]byte(ts.Format("02-01-2006 15:04:05.999") + "|")) - if err != nil { - return err - } - for _, k := range sysvarOrder { - val := sysvars.Get(k) - if val == math.Trunc(val) { - t.precission = 0 - } else if k == "Lambda.External" { - t.precission = 3 - } else { - t.precission = 2 - } - if _, err := t.file.Write([]byte(k + "=" + replaceDot(strconv.FormatFloat(val, 'f', t.precission, 64)) + "|")); err != nil { - return err - } - } - for _, va := range vars { - if va.Number < 0 { - continue - } - if _, err := t.file.Write([]byte(va.Name + "=" + replaceDot(va.StringValue()) + "|")); err != nil { - return err +func (t *TXWriter) Write(ts time.Time, channels []Channel) error { + t.buf = ts.AppendFormat(t.buf[:0], "02-01-2006 15:04:05.999") + t.buf = append(t.buf, '|') + for i := range channels { + t.buf = append(t.buf, channels[i].Name...) + t.buf = append(t.buf, '=') + // Format the value straight into buf, then swap the first decimal '.' + // for ',' in place (the TXL/European separator). No per-sample string. + start := len(t.buf) + t.buf = channels[i].Append(t.buf) + for j := start; j < len(t.buf); j++ { + if t.buf[j] == '.' { + t.buf[j] = ',' + break + } } + t.buf = append(t.buf, '|') } - _, err = t.file.Write([]byte("IMPORTANTLINE=0|\n")) + t.buf = append(t.buf, "IMPORTANTLINE=0|\n"...) + _, err := t.file.Write(t.buf) return err } diff --git a/pkg/datalogger/log_txl_test.go b/pkg/datalogger/log_txl_test.go new file mode 100644 index 00000000..f53becdf --- /dev/null +++ b/pkg/datalogger/log_txl_test.go @@ -0,0 +1,41 @@ +package datalogger + +import ( + "os" + "strings" + "testing" + "time" +) + +// Verifies the append-style formatting still produces the TXL line format: +// "ts|Name=value|...|IMPORTANTLINE=0|" with the first decimal '.' swapped for ','. +func TestTXWriterLineFormat(t *testing.T) { + sv := NewThreadSafeMap() + sv.Set("Rpm", 3000) // whole number -> no decimals + sv.Set("Lambda", 0.987) // decimal -> comma separator + sv.Set("Lambda.External", 0.987) + chans := []Channel{newSysvarChannel(sv, "Rpm"), newSysvarChannel(sv, "Lambda"), newSysvarChannel(sv, "Lambda.External")} + + f, err := os.CreateTemp(t.TempDir(), "*.t7l") + if err != nil { + t.Fatal(err) + } + w := NewTXLWriter(f) + ts := time.Date(2026, 6, 25, 12, 30, 0, 0, time.UTC) + if err := w.Write(ts, chans); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + line := strings.TrimRight(string(got), "\n") + want := "25-06-2026 12:30:00|Rpm=3000|Lambda=0,99|Lambda.External=0,987|IMPORTANTLINE=0|" + if line != want { + t.Fatalf("got %q\nwant %q", line, want) + } +} diff --git a/pkg/datalogger/remotelogger.go b/pkg/datalogger/remotelogger.go index 68661083..866ccd59 100644 --- a/pkg/datalogger/remotelogger.go +++ b/pkg/datalogger/remotelogger.go @@ -3,6 +3,7 @@ package datalogger import ( "fmt" "log" + "time" "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/relayserver" @@ -91,7 +92,7 @@ func (c *RemoteClient) Start() error { for _, va := range values { ebus.Publish(va.Name, va.Value) } - c.onCapture() + c.onCapture(time.Now()) default: log.Println("Unknown message kind:", msg.Kind.String()) } diff --git a/pkg/datalogger/t5logger.go b/pkg/datalogger/t5logger.go index 8a828ec8..d0481642 100644 --- a/pkg/datalogger/t5logger.go +++ b/pkg/datalogger/t5logger.go @@ -7,7 +7,6 @@ import ( "math" "time" - symbol "github.com/roffe/ecusymbol" "github.com/roffe/gocan" "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/pkg/t5can" @@ -35,20 +34,26 @@ func (c *T5Client) Start() error { } } - cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventHandler(eventHandler)) + cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventFunc(eventHandler)) if err != nil { return err } defer cl.Close() + // Drive everything below off the client's context so a fatal adapter error + // (or Close) cancels the polling loop and aborts in-flight requests directly. + ctx = cl.Context() + t := time.NewTicker(time.Second / time.Duration(c.Rate)) defer t.Stop() t5 := t5can.NewClient(cl) - sysvarOrder := make([]string, len(c.Symbols)) - for n, s := range c.Symbols { - sysvarOrder[n] = s.Name + // T5 decodes every value into sysvars (see newT5Converter), so all columns + // are sysvar channels. + channels := make([]Channel, 0, len(c.Symbols)+2) + for _, s := range c.Symbols { s.Correctionfactor = 0.1 + channels = append(channels, newSysvarChannel(c.sysvars, s.Name)) } if err := c.setupWBL(ctx, cl); err != nil { @@ -57,11 +62,9 @@ func (c *T5Client) Start() error { if c.lamb != nil { defer c.lamb.Stop() - sysvarOrder = append(sysvarOrder, EXTERNALWBLSYM) } - - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - sysvarOrder = append(sysvarOrder, LAMBDAADSCANNER) + for _, name := range c.appendExtraSysvars(nil) { + channels = append(channels, newSysvarChannel(c.sysvars, name)) } tx := cl.Subscribe(ctx, gocan.SystemMsgDataResponse) @@ -131,11 +134,11 @@ func (c *T5Client) Start() error { ebus.Publish(EXTERNALWBLSYM, lambda) } - if err := c.lw.Write(c.sysvars, sysvarOrder, []*symbol.Symbol{}, ts); err != nil { + if err := c.lw.Write(ts, channels); err != nil { c.OnMessage("failed to write log: " + err.Error()) return } - c.onCapture() + c.onCapture(ts) } } }() diff --git a/pkg/datalogger/t7logger.go b/pkg/datalogger/t7logger.go index fe7f2b04..c5e3fec4 100644 --- a/pkg/datalogger/t7logger.go +++ b/pkg/datalogger/t7logger.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "log" - "sort" "strings" "time" @@ -26,7 +25,7 @@ func NewT7(cfg Config, lw LogWriter) (IClient, error) { } func t7broadcastListener(ctx context.Context, cl *gocan.Client, sysvars *ThreadSafeMap) { - broadcast := cl.Subscribe(ctx, 0x1A0, 0x280, 0x3A0) + broadcast := cl.Subscribe(ctx, 0x1A0, 0x280, 0x3A0 /*, 0x5C0*/) defer broadcast.Close() var speed uint16 var rpm uint16 @@ -51,7 +50,6 @@ func t7broadcastListener(ctx context.Context, cl *gocan.Client, sysvars *ThreadS limp = msg.Data[3] & 0x01 cel = msg.Data[4] & 0x80 >> 7 cruise = msg.Data[4] & 0x20 >> 5 - gear = msg.Data[1] sysvars.Set("Out.X_ActualGear", float64(gear)) brakeLight = msg.Data[2] & 0x02 >> 1 @@ -62,11 +60,16 @@ func t7broadcastListener(ctx context.Context, cl *gocan.Client, sysvars *ThreadS ebus.Publish("LIMP", float64(limp)) ebus.Publish("CRUISE", float64(cruise)) ebus.Publish("CEL", float64(cel)) - case 0x3A0: speed = uint16(msg.Data[4]) | uint16(msg.Data[3])<<8 realSpeed = float64(speed) * 0.1 sysvars.Set("In.v_Vehicle", realSpeed) + case 0x5C0: + // 0x5C0 COTE_ECS: Data[1]=coolant. byte=(u8)(V+40), + coolant := float64(msg.Data[1]) - 40 + sysvars.Set("ActualIn.T_Engine", coolant) + // log.Printf("0x5C0: % X valid=%t coolant=%v", msg.Data, msg.Data[0]&0x10 != 0, coolant) + } } } @@ -86,18 +89,22 @@ func (c *T7Client) Start() error { } } - cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventHandler(eventHandler)) + cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventFunc(eventHandler)) if err != nil { return fmt.Errorf("failed to create t7 client: %w", err) } defer cl.Close() + // Drive everything below off the client's context so a fatal adapter error + // (or Close) cancels the polling loop and aborts in-flight requests directly, + // instead of relying on cl.Wait returning and the deferred cancel bouncing back. + ctx = cl.Context() + checkBroadcast := true if strings.Contains(c.Device.Name(), "OBDLink") || strings.Contains(c.Device.Name(), "STN") || strings.Contains(c.Device.Name(), "ELM") { checkBroadcast = false } - var sysvarOrder []string if checkBroadcast { bctx, bcancel := context.WithCancel(ctx) defer bcancel() @@ -105,13 +112,9 @@ func (c *T7Client) Start() error { c.OnMessage("Watching for broadcast messages") <-time.After(1550 * time.Millisecond) - sysvarOrder = c.sysvars.Keys() - sort.StringSlice(sysvarOrder).Sort() - if len(sysvarOrder) > 0 { - c.OnMessage(fmt.Sprintf("Found %s", sysvarOrder)) - } - - if len(sysvarOrder) == 0 { + if found := c.sysvars.Keys(); len(found) > 0 { + c.OnMessage(fmt.Sprintf("Found: %s", strings.Join(found, ", "))) + } else { c.OnMessage("No broadcast messages found, stopping broadcast listener") bcancel() } @@ -122,11 +125,6 @@ func (c *T7Client) Start() error { if c.lamb != nil { defer c.lamb.Stop() - sysvarOrder = append(sysvarOrder, EXTERNALWBLSYM) - } - - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - sysvarOrder = append(sysvarOrder, LAMBDAADSCANNER) } for _, sym := range c.Symbols { @@ -137,6 +135,10 @@ func (c *T7Client) Start() error { } } + // Broadcast/derived values resolved above become async sysvar channels; + // the remaining symbols (Number >= 0) are polled each tick. + channels := c.buildChannels() + kwp := kwp2000.New(cl) adConverter := NewWBLInterpolator(c.WidebandConfig) @@ -182,6 +184,9 @@ func (c *T7Client) Start() error { } } } + // In.v_Vehicle Left front wheel speed + // In.v_Vehicle2 Vehicle speed, measured on the rear wheel + // In.v_Vehicle3 Right front wheel speed specialFN := map[string]func(string, float64){ "In.v_Vehicle": wheelSlipFN, @@ -219,6 +224,17 @@ func (c *T7Client) Start() error { } } + /* + if c.lamb != nil { + lambdbaChan := newFunctionChannel(EXTERNALWBLSYM, func() float64 { + lambda := c.lamb.GetLambda() + ebus.Publish(EXTERNALWBLSYM, lambda) + return lambda + }) + channels = append(channels, lambdbaChan) + } + */ + go func() { defer cl.Close() defer func() { @@ -226,10 +242,6 @@ func (c *T7Client) Start() error { time.Sleep(50 * time.Millisecond) }() - // In.v_Vehicle Left front wheel speed - // In.v_Vehicle2 Vehicle speed, measured on the rear wheel - // In.v_Vehicle3 Right front wheel speed - for { select { case <-ctx.Done(): @@ -333,35 +345,11 @@ func (c *T7Client) Start() error { ebus.Publish(EXTERNALWBLSYM, lambda) } - /* - // New shit ----- - if c.r != nil { - var values relayserver.LogValues - for _, name := range sysvarOrder { - val := c.sysvars.Get(name) - values = append(values, relayserver.LogValue{Name: name, Value: val}) - } - for _, va := range c.Symbols { - if va.Number < 0 { - continue - } - values = append(values, relayserver.LogValue{Name: va.Name, Value: va.Float64()}) - } - if err := c.r.Send(relayserver.Message{ - Kind: relayserver.MsgTypeData, - Body: values, - }); err != nil { - c.onError() - c.OnMessage("failed to send relay message: " + err.Error()) - } - } - */ - - if err := c.lw.Write(c.sysvars, sysvarOrder, c.Symbols, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.onError() c.OnMessage("failed to write log: " + err.Error()) } - c.onCapture() + c.onCapture(timeStamp) } } }() @@ -370,7 +358,7 @@ func (c *T7Client) Start() error { func initT7logging(ctx context.Context, kwp *kwp2000.Client, symbols []*symbol.Symbol, onMessage func(string)) error { if err := kwp.StartSession(ctx, kwp2000.INIT_MSG_ID, kwp2000.INIT_RESP_ID); err != nil { - return errors.New("failed to start session") + return fmt.Errorf("failed to start session: %w", err) } onMessage("Connected to ECU") @@ -380,7 +368,7 @@ func initT7logging(ctx context.Context, kwp *kwp2000.Client, symbols []*symbol.S } if !granted { - onMessage("Security access not granted!") + return errors.New("security access not granted") } else { onMessage("Security access granted") } @@ -398,6 +386,9 @@ func initT7logging(ctx context.Context, kwp *kwp2000.Client, symbols []*symbol.S continue } onMessage("Defining " + sym.Name) + + // log.Println("Define:", sym.String()) + if err := kwp.DynamicallyDefineLocalIdBySymbolNumber(ctx, index, sym.Number); err != nil { return errors.New("failed to define dynamic register") } diff --git a/pkg/datalogger/t8logger.go b/pkg/datalogger/t8logger.go index 7415b659..266d01c6 100644 --- a/pkg/datalogger/t8logger.go +++ b/pkg/datalogger/t8logger.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "math" - "sort" "time" symbol "github.com/roffe/ecusymbol" @@ -53,13 +52,15 @@ func (c *T8Client) Start() error { } } - cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventHandler(eventHandler)) + cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventFunc(eventHandler)) if err != nil { return err } defer cl.Close() - order := c.sysvars.Keys() + // Drive everything below off the client's context so a fatal adapter error + // (or Close) cancels the polling loop and aborts in-flight requests directly. + ctx = cl.Context() if err := c.setupWBL(ctx, cl); err != nil { return err @@ -67,15 +68,9 @@ func (c *T8Client) Start() error { if c.lamb != nil { defer c.lamb.Stop() - order = append(order, EXTERNALWBLSYM) } - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - order = append(order, LAMBDAADSCANNER) - } - - // sort order - sort.StringSlice(order).Sort() + channels := c.buildChannels() opts := []gmlan.GMLanOption{gmlan.WithCanID(0x7E0), gmlan.WithRecvID(0x7E8)} if cl.AdapterName() == "ELM327" { @@ -88,12 +83,12 @@ func (c *T8Client) Start() error { return fmt.Errorf("failed to init t8 logging: %w", err) } - go c.run(ctx, cl, gm, order) + go c.run(ctx, cl, gm, channels) return cl.Wait(ctx) } -func (c *T8Client) run(ctx context.Context, cl *gocan.Client, gm *gmlan.Client, order []string) { +func (c *T8Client) run(ctx context.Context, cl *gocan.Client, gm *gmlan.Client, channels []Channel) { defer cl.Close() var timeStamp time.Time @@ -215,12 +210,12 @@ func (c *T8Client) run(ctx context.Context, cl *gocan.Client, gm *gmlan.Client, c.sysvars.Set(EXTERNALWBLSYM, c.lamb.GetLambda()) } - if err := c.lw.Write(c.sysvars, order, c.Symbols, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.onError() c.OnMessage("failed to write log: " + err.Error()) } testerPresent() - c.onCapture() + c.onCapture(timeStamp) } } } diff --git a/pkg/datalogger/txbridgelogger.go b/pkg/datalogger/txbridgelogger.go index 8a31bda0..cf26dbc0 100644 --- a/pkg/datalogger/txbridgelogger.go +++ b/pkg/datalogger/txbridgelogger.go @@ -8,6 +8,11 @@ import ( "github.com/roffe/gocan" ) +// dataTimeout aborts a txbridge logging session if no log frame arrives for +// this long. The txbridge loggers wait passively on the autonomous stream, so +// without this a dead stream would hang the session forever instead of erroring. +const dataTimeout = 5 * time.Second + var _ IClient = (*TxBridge)(nil) type TxBridge struct { @@ -35,12 +40,17 @@ func (c *TxBridge) Start() error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventHandler(eventHandler)) + cl, err := gocan.NewWithOpts(ctx, c.Device, gocan.WithEventFunc(eventHandler)) if err != nil { return err } defer cl.Close() + // Drive everything below (incl. the per-ECU loops, which derive their ctx + // from this one) off the client's context so a fatal adapter error or Close + // cancels logging and aborts in-flight requests directly. + ctx = cl.Context() + if err := c.setupWBL(ctx, cl); err != nil { return err } @@ -71,9 +81,31 @@ func (c *TxBridge) setECU(cl *gocan.Client, ecuType string) error { return err } time.Sleep(75 * time.Millisecond) + // Setting the ECU above applies a per-ECU default delay. Override it with the + // configured rate now: delayTime is the firmware's ms between reads = 1000/Hz. + // Framed command: 'D' . + if c.Config.Rate > 0 { + delay := 1000 / c.Config.Rate + if delay < 1 { + delay = 1 + } else if delay > 255 { + delay = 255 + } + if err := cl.Send(gocan.SystemMsg, []byte{'D', 0x01, byte(delay), byte(delay)}, gocan.Outgoing); err != nil { + return err + } + time.Sleep(10 * time.Millisecond) + } return nil } func (c *TxBridge) startLogging(cl *gocan.Client) error { return cl.Send(gocan.SystemMsg, []byte("r"), gocan.Outgoing) } + +// stopLogging tells the dongle to stop its autonomous read loop. Send this before +// ending the ECU session (StopSession / ReturnToNormalMode): otherwise the dongle +// keeps issuing reads against an ended session and work() logs a spurious timeout. +func (c *TxBridge) stopLogging(cl *gocan.Client) error { + return cl.Send(gocan.SystemMsg, []byte("s"), gocan.Outgoing) +} diff --git a/pkg/datalogger/txbridgelogger_t5.go b/pkg/datalogger/txbridgelogger_t5.go index fbf2b7da..4b518588 100644 --- a/pkg/datalogger/txbridgelogger_t5.go +++ b/pkg/datalogger/txbridgelogger_t5.go @@ -8,7 +8,6 @@ import ( "log" "time" - symbol "github.com/roffe/ecusymbol" "github.com/roffe/gocan" "github.com/roffe/gocan/pkg/serialcommand" "github.com/roffe/txlogger/pkg/ebus" @@ -18,19 +17,19 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { ctx, cancel := context.WithCancel(pctx) defer cancel() - sysvarOrder := make([]string, len(c.Symbols)) - for n, s := range c.Symbols { - sysvarOrder[n] = s.Name + // T5 decodes every value into sysvars (see newT5Converter), so all columns + // are sysvar channels. + channels := make([]Channel, 0, len(c.Symbols)+2) + for _, s := range c.Symbols { s.Correctionfactor = 0.1 + channels = append(channels, newSysvarChannel(c.sysvars, s.Name)) } if c.lamb != nil { defer c.lamb.Stop() - sysvarOrder = append(sysvarOrder, EXTERNALWBLSYM) } - - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - sysvarOrder = append(sysvarOrder, LAMBDAADSCANNER) + for _, name := range c.appendExtraSysvars(nil) { + channels = append(channels, newSysvarChannel(c.sysvars, name)) } expectedPayloadSize, err := c.configureT5Symbols(cl) @@ -50,6 +49,11 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { go func() { defer cl.Close() + defer func() { + _ = c.stopLogging(cl) // stop the dongle's read loop before closing the connection + time.Sleep(50 * time.Millisecond) + }() + lastData := time.Now() for { select { case <-ctx.Done(): @@ -63,6 +67,10 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { c.OnMessage("too many errors, aborting logging") return } + if time.Since(lastData) > dataTimeout { + c.OnMessage("no data for 5s, aborting logging") + return + } c.resetPerSecond() case read := <-c.readChan: toRead := min(234, read.Length) @@ -149,6 +157,7 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { c.OnMessage("txbridge sub closed") return } + lastData = time.Now() if msg.DLC() != (expectedPayloadSize + 4) { c.onError() @@ -191,11 +200,11 @@ func (c *TxBridge) t5(pctx context.Context, cl *gocan.Client) error { ebus.Publish(EXTERNALWBLSYM, lambda) } - if err := c.lw.Write(c.sysvars, sysvarOrder, []*symbol.Symbol{}, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.OnMessage("failed to write log: " + err.Error()) return } - c.onCapture() + c.onCapture(timeStamp) } } }() diff --git a/pkg/datalogger/txbridgelogger_t7.go b/pkg/datalogger/txbridgelogger_t7.go index a0e3812e..4f8a28f4 100644 --- a/pkg/datalogger/txbridgelogger_t7.go +++ b/pkg/datalogger/txbridgelogger_t7.go @@ -6,9 +6,10 @@ import ( "encoding/binary" "fmt" "log" - "sort" + "strings" "time" + symbol "github.com/roffe/ecusymbol" "github.com/roffe/gocan" "github.com/roffe/gocan/pkg/serialcommand" "github.com/roffe/txlogger/pkg/ebus" @@ -25,21 +26,15 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.OnMessage("Watching for broadcast messages") <-time.After(1550 * time.Millisecond) - sysvarOrder := c.sysvars.Keys() - sort.StringSlice(sysvarOrder).Sort() - c.OnMessage(fmt.Sprintf("Found %s", sysvarOrder)) - - if len(sysvarOrder) == 0 { + if found := c.sysvars.Keys(); len(found) > 0 { + c.OnMessage(fmt.Sprintf("Found: %s", strings.Join(found, ", "))) + } else { + c.OnMessage("No broadcast messages found, stopping broadcast listener") bcancel() } if c.lamb != nil { defer c.lamb.Stop() - sysvarOrder = append(sysvarOrder, EXTERNALWBLSYM) - } - - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - sysvarOrder = append(sysvarOrder, LAMBDAADSCANNER) } for _, sym := range c.Symbols { @@ -50,6 +45,8 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { } } + channels := c.buildChannels() + kwp := kwp2000.New(cl) if err := initT7logging(ctx, kwp, c.Symbols, c.OnMessage); err != nil { return fmt.Errorf("failed to init t7 logging: %w", err) @@ -72,12 +69,43 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { adConverter := NewWBLInterpolator(c.WidebandConfig) + router := map[string]func(s *symbol.Symbol) bool{ + "IgnKnk.fi_Offset": func(s *symbol.Symbol) bool { + data := s.Bytes() + if len(data) != 8 { + return false + } + + ioffCyl1 := int16(binary.BigEndian.Uint16(data[0:2])) + ioffCyl2 := int16(binary.BigEndian.Uint16(data[2:4])) + ioffCyl3 := int16(binary.BigEndian.Uint16(data[4:6])) + ioffCyl4 := int16(binary.BigEndian.Uint16(data[6:8])) + + ebus.Publish("IgnKnk.fi_Offset.Cyl1", float64(ioffCyl1)/10) + ebus.Publish("IgnKnk.fi_Offset.Cyl2", float64(ioffCyl2)/10) + ebus.Publish("IgnKnk.fi_Offset.Cyl3", float64(ioffCyl3)/10) + ebus.Publish("IgnKnk.fi_Offset.Cyl4", float64(ioffCyl4)/10) + return true + }, + } + + if c.WidebandConfig.ADScanner { + router[c.WidebandConfig.ADScannerSymbol] = func(s *symbol.Symbol) bool { + lambda := adConverter(s.Int()) + c.sysvars.Set(LAMBDAADSCANNER, lambda) + ebus.Publish(LAMBDAADSCANNER, lambda) + return true + } + } + go func() { defer cl.Close() defer func() { + _ = c.stopLogging(cl) // stop the dongle's read loop before ending the session _ = kwp.StopSession(ctx) time.Sleep(75 * time.Millisecond) }() + lastData := time.Now() for { select { case <-ctx.Done(): @@ -91,6 +119,10 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.OnMessage("too many errors, aborting logging") return } + if time.Since(lastData) > dataTimeout { + c.OnMessage("no data for 5s, aborting logging") + return + } c.resetPerSecond() case read := <-c.readChan: toRead := min(245, read.Length) @@ -177,6 +209,7 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.OnMessage("txbridge recv channel closed") return } + lastData = time.Now() if msg.DLC() != int(expectedPayloadSize+4) { c.onError() c.OnMessage(fmt.Sprintf("expected %d bytes, got %d", expectedPayloadSize+4, msg.DLC())) @@ -211,10 +244,9 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { c.OnMessage(err.Error()) break } - if c.WidebandConfig.ADScanner && va.Name == c.WidebandConfig.ADScannerSymbol { - lambda := adConverter(va.Int()) - c.sysvars.Set(LAMBDAADSCANNER, lambda) - ebus.Publish(LAMBDAADSCANNER, lambda) + + if fn, ok := router[va.Name]; ok && fn(va) { + continue } ebus.Publish(va.Name, va.Float64()) @@ -230,11 +262,11 @@ func (c *TxBridge) t7(pctx context.Context, cl *gocan.Client) error { ebus.Publish(EXTERNALWBLSYM, lambda) } - if err := c.lw.Write(c.sysvars, sysvarOrder, c.Symbols, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.onError() c.OnMessage("failed to write log: " + err.Error()) } - c.onCapture() + c.onCapture(timeStamp) } } }() diff --git a/pkg/datalogger/txbridgelogger_t8.go b/pkg/datalogger/txbridgelogger_t8.go index ef945071..7df62416 100644 --- a/pkg/datalogger/txbridgelogger_t8.go +++ b/pkg/datalogger/txbridgelogger_t8.go @@ -6,7 +6,6 @@ import ( "encoding/binary" "fmt" "log" - "sort" "time" "github.com/roffe/gocan" @@ -19,17 +18,11 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { ctx, cancel := context.WithCancel(pctx) defer cancel() - order := c.sysvars.Keys() if c.lamb != nil { defer c.lamb.Stop() - order = append(order, EXTERNALWBLSYM) } - if c.WidebandConfig.ADScanner && c.WidebandConfig.Name == "ECU" { - order = append(order, LAMBDAADSCANNER) - } - - sort.StringSlice(order).Sort() + channels := c.buildChannels() gm := gmlan.New(cl, 0x7e0, 0x7e8) @@ -69,10 +62,12 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { adConverter := NewWBLInterpolator(c.WidebandConfig) defer func() { + _ = c.stopLogging(cl) // stop the dongle's read loop before ending the session _ = gm.ReturnToNormalMode(ctx) time.Sleep(100 * time.Millisecond) }() + lastData := time.Now() for { select { case <-ctx.Done(): @@ -86,6 +81,10 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { c.OnMessage("too many errors, aborting logging") return } + if time.Since(lastData) > dataTimeout { + c.OnMessage("no data for 5s, aborting logging") + return + } c.resetPerSecond() case read := <-c.readChan: if err := c.handleReadTxbridge(ctx, cl, read); err != nil { @@ -101,6 +100,7 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { c.OnMessage("txbridge recv channel closed") return } + lastData = time.Now() if msg.DLC() != int(expectedPayloadSize+4) { c.OnMessage(fmt.Sprintf("expected %d bytes, got %d", expectedPayloadSize+4, msg.DLC())) return @@ -145,11 +145,11 @@ func (c *TxBridge) t8(pctx context.Context, cl *gocan.Client) error { ebus.Publish(EXTERNALWBLSYM, lambda) } - if err := c.lw.Write(c.sysvars, order, c.Symbols, timeStamp); err != nil { + if err := c.lw.Write(timeStamp, channels); err != nil { c.onError() c.OnMessage("failed to write log: " + err.Error()) } - c.onCapture() + c.onCapture(timeStamp) testerPresent() } } diff --git a/pkg/ebus/ebus.go b/pkg/ebus/ebus.go index 32e091f4..b6db15ad 100644 --- a/pkg/ebus/ebus.go +++ b/pkg/ebus/ebus.go @@ -1,26 +1,36 @@ package ebus import ( - "context" "sync" + "time" "fyne.io/fyne/v2" - "github.com/roffe/txlogger/pkg/eventbus" + "github.com/roffe/txlogger/pkg/bus" ) var ( once sync.Once - CONTROLLER *eventbus.Controller + CONTROLLER *bus.Controller[string, float64] ) const ( TOPIC_COLORBLINDMODE = "color_blind_mode" TOPIC_ECU = "selected_ecu" + // TOPIC_FRAME fires once per completed log frame, carrying the frame's + // timestamp as Unix milliseconds (float64). Subscribers use it as the frame + // boundary to sample the latest value of every symbol with a shared, real + // timestamp (see the live plotter). + TOPIC_FRAME = "__frame__" ) func init() { once.Do(func() { - CONTROLLER = eventbus.New(eventbus.DefaultConfig) + CONTROLLER = bus.NewBus[string, float64]() + + // AirDIFF: m_AirInlet vs the requested air mass. Two instances cover the + // differing request topic names across ECU types; both publish AirDIFF. + bus.DIFFAggregator(CONTROLLER, "MAF.m_AirInlet", "m_Request", "AirDIFF") + bus.DIFFAggregator(CONTROLLER, "MAF.m_AirInlet", "AirMassMast.m_Request", "AirDIFF") }) } @@ -28,19 +38,12 @@ func Publish(topic string, data float64) { CONTROLLER.Publish(topic, data) } -/* - func SubscribeAll() chan eventbus.EBusMessage { - return eb.SubscribeAll() - } - - func SubscribeAllFunc(f func(topic string, value float64)) func() { - return eb.SubscribeAllFunc(f) - } +// PublishFrame signals that a log frame completed at time t. The timestamp is +// carried as Unix milliseconds, which fits exactly in a float64. +func PublishFrame(t time.Time) { + CONTROLLER.Publish(TOPIC_FRAME, float64(t.UnixMilli())) +} - func UnsubscribeAll(channel chan eventbus.EBusMessage) { - eb.UnsubscribeAll(channel) - } -*/ func SubscribeFunc(topic string, f func(float64)) func() { wrapFN := func(v float64) { fyne.Do(func() { @@ -50,23 +53,7 @@ func SubscribeFunc(topic string, f func(float64)) func() { return CONTROLLER.SubscribeFunc(topic, wrapFN) } -func Subscribe(topic string) chan float64 { - return CONTROLLER.Subscribe(topic) -} - -func SubscribeWithContext(ctx context.Context, topic string) (chan float64, error) { - ch := CONTROLLER.Subscribe(topic) - go func() { - <-ctx.Done() - CONTROLLER.Unsubscribe(ch) - }() - return ch, nil -} - -func Unsubscribe(channel chan float64) { - CONTROLLER.Unsubscribe(channel) -} - func SetOnMessage(f func(string, float64)) { - CONTROLLER.SetOnMessage(f) + // CONTROLLER.SetOnMessage(f) + // noop for now, the bus doesn't support this and we don't need it yet. If we do, we can add it to the bus package and call it here. } diff --git a/pkg/ebus/ebus.old b/pkg/ebus/ebus.old new file mode 100644 index 00000000..32e091f4 --- /dev/null +++ b/pkg/ebus/ebus.old @@ -0,0 +1,72 @@ +package ebus + +import ( + "context" + "sync" + + "fyne.io/fyne/v2" + "github.com/roffe/txlogger/pkg/eventbus" +) + +var ( + once sync.Once + CONTROLLER *eventbus.Controller +) + +const ( + TOPIC_COLORBLINDMODE = "color_blind_mode" + TOPIC_ECU = "selected_ecu" +) + +func init() { + once.Do(func() { + CONTROLLER = eventbus.New(eventbus.DefaultConfig) + }) +} + +func Publish(topic string, data float64) { + CONTROLLER.Publish(topic, data) +} + +/* + func SubscribeAll() chan eventbus.EBusMessage { + return eb.SubscribeAll() + } + + func SubscribeAllFunc(f func(topic string, value float64)) func() { + return eb.SubscribeAllFunc(f) + } + + func UnsubscribeAll(channel chan eventbus.EBusMessage) { + eb.UnsubscribeAll(channel) + } +*/ +func SubscribeFunc(topic string, f func(float64)) func() { + wrapFN := func(v float64) { + fyne.Do(func() { + f(v) + }) + } + return CONTROLLER.SubscribeFunc(topic, wrapFN) +} + +func Subscribe(topic string) chan float64 { + return CONTROLLER.Subscribe(topic) +} + +func SubscribeWithContext(ctx context.Context, topic string) (chan float64, error) { + ch := CONTROLLER.Subscribe(topic) + go func() { + <-ctx.Done() + CONTROLLER.Unsubscribe(ch) + }() + return ch, nil +} + +func Unsubscribe(channel chan float64) { + CONTROLLER.Unsubscribe(channel) +} + +func SetOnMessage(f func(string, float64)) { + CONTROLLER.SetOnMessage(f) +} diff --git a/pkg/ecu/t7/erase.go b/pkg/ecu/t7/erase.go index 34013380..d32a06f2 100644 --- a/pkg/ecu/t7/erase.go +++ b/pkg/ecu/t7/erase.go @@ -7,11 +7,12 @@ import ( "time" "github.com/roffe/gocan" + "github.com/roffe/txlogger/pkg/kwp2000" ) func (t *Client) EraseECU(ctx context.Context) error { data := make([]byte, 8) - eraseMsg := []byte{0x40, 0xA1, 0x02, 0x31, 0x52, 0x00, 0x00, 0x00} + eraseMsg := []byte{0x40, 0xA1, 0x02, kwp2000.START_ROUTINE_BY_IDENTIFIER, kwp2000.RLI_EOL_START, 0x00, 0x00, 0x00} confirmMsg := []byte{0x40, 0xA1, 0x01, 0x3E, 0x00, 0x00, 0x00, 0x00} t.cfg.OnProgress(-float64(17)) @@ -41,7 +42,7 @@ func (t *Client) EraseECU(ctx context.Context) error { // Start erase routine data[3] = 0 i = 0 - eraseMsg[4] = 0x53 + eraseMsg[4] = kwp2000.RLI_ERASE for data[3] != 0x71 && i < 200 { f, err := t.c.SendAndWait(ctx, gocan.NewFrame(0x240, eraseMsg, gocan.ResponseRequired), t.defaultTimeout, 0x258) if err != nil { diff --git a/pkg/logfile/baselog.go b/pkg/logfile/baselog.go index 4406ce3d..d3f015f1 100644 --- a/pkg/logfile/baselog.go +++ b/pkg/logfile/baselog.go @@ -53,6 +53,22 @@ func (l *BaseLogfile) Pos() int { return max(l.pos, 0) } +// RecordAt returns the record at the given index without changing the playback +// position. The index is clamped to the valid range. It is safe to call +// concurrently with playback as it only reads the immutable records slice. +func (l *BaseLogfile) RecordAt(i int) Record { + if l.length == 0 { + return Record{EOF: true} + } + if i < 0 { + i = 0 + } + if i >= l.length { + i = l.length - 1 + } + return l.records[i] +} + func (l *BaseLogfile) Len() int { return l.length } diff --git a/pkg/logfile/logfile.go b/pkg/logfile/logfile.go index 937bed7d..e7d3e4af 100644 --- a/pkg/logfile/logfile.go +++ b/pkg/logfile/logfile.go @@ -20,6 +20,7 @@ type Logfile interface { Seek(int) Pos() int Len() int + RecordAt(int) Record Start() time.Time End() time.Time Close() diff --git a/pkg/native/dialog_linux.go b/pkg/native/dialog_linux.go index d955fc5b..da214046 100644 --- a/pkg/native/dialog_linux.go +++ b/pkg/native/dialog_linux.go @@ -46,9 +46,17 @@ func buildPortalFilters(filters []FileFilter) dbus.Variant { } func portalCall(method string, options map[string]dbus.Variant, args ...interface{}) (string, error) { + paths, err := portalCallMulti(method, options, args...) + if err != nil { + return "", err + } + return paths[0], nil +} + +func portalCallMulti(method string, options map[string]dbus.Variant, args ...interface{}) ([]string, error) { conn, err := dbus.SessionBus() if err != nil { - return "", fmt.Errorf("failed to connect to session bus: %w", err) + return nil, fmt.Errorf("failed to connect to session bus: %w", err) } defer conn.Close() @@ -65,7 +73,7 @@ func portalCall(method string, options map[string]dbus.Variant, args ...interfac var handle dbus.ObjectPath if err := obj.Call(method, 0, callArgs...).Store(&handle); err != nil { - return "", fmt.Errorf("portal call %s failed: %w", method, err) + return nil, fmt.Errorf("portal call %s failed: %w", method, err) } if err := conn.AddMatchSignal( @@ -73,7 +81,7 @@ func portalCall(method string, options map[string]dbus.Variant, args ...interfac dbus.WithMatchInterface("org.freedesktop.portal.Request"), dbus.WithMatchMember("Response"), ); err != nil { - return "", fmt.Errorf("failed to add match signal: %w", err) + return nil, fmt.Errorf("failed to add match signal: %w", err) } c := make(chan *dbus.Signal, 10) @@ -85,27 +93,35 @@ func portalCall(method string, options map[string]dbus.Variant, args ...interfac continue } if len(sig.Body) < 2 { - return "", errors.New("unexpected signal body") + return nil, errors.New("unexpected signal body") } response, ok := sig.Body[0].(uint32) if !ok { - return "", fmt.Errorf("unexpected response type: %T", sig.Body[0]) + return nil, fmt.Errorf("unexpected response type: %T", sig.Body[0]) } if response != 0 { - return "", ErrCancelled + return nil, ErrCancelled } results, ok := sig.Body[1].(map[string]dbus.Variant) if !ok { - return "", fmt.Errorf("unexpected results type: %T", sig.Body[1]) + return nil, fmt.Errorf("unexpected results type: %T", sig.Body[1]) } uris, ok := results["uris"].Value().([]string) if !ok || len(uris) == 0 { - return "", errors.New("no files selected") + return nil, errors.New("no files selected") } - return uriToPath(uris[0]) + paths := make([]string, 0, len(uris)) + for _, uri := range uris { + p, err := uriToPath(uri) + if err != nil { + return nil, err + } + paths = append(paths, p) + } + return paths, nil } - return "", errors.New("signal channel closed unexpectedly") + return nil, errors.New("signal channel closed unexpectedly") } func uriToPath(uri string) (string, error) { @@ -131,6 +147,21 @@ func OpenFileDialog(title string, filters ...FileFilter) (string, error) { ) } +func OpenFilesDialog(title string, filters ...FileFilter) ([]string, error) { + options := map[string]dbus.Variant{ + "handle_token": dbus.MakeVariant(GenerateDBusToken()), + "multiple": dbus.MakeVariant(true), + } + if len(filters) > 0 { + options["filters"] = buildPortalFilters(filters) + } + return portalCallMulti( + "org.freedesktop.portal.FileChooser.OpenFile", + options, + title, + ) +} + func OpenFolderDialog(title string) (string, error) { options := map[string]dbus.Variant{ "handle_token": dbus.MakeVariant(GenerateDBusToken()), diff --git a/pkg/native/dialog_windows.go b/pkg/native/dialog_windows.go index 1fa25846..ac3c1619 100644 --- a/pkg/native/dialog_windows.go +++ b/pkg/native/dialog_windows.go @@ -2,6 +2,7 @@ package native import ( "fmt" + "path/filepath" "reflect" "strings" "syscall" @@ -26,12 +27,13 @@ var ( ) const ( - MAX_PATH = 260 - OFN_EXPLORER = 0x00080000 - OFN_FILEMUSTEXIST = 0x00001000 - OFN_PATHMUSTEXIST = 0x00000800 - OFN_OVERWRITEPROMPT = 0x00000002 - OFN_NOCHANGEDIR = 0x00000008 + MAX_PATH = 260 + OFN_EXPLORER = 0x00080000 + OFN_FILEMUSTEXIST = 0x00001000 + OFN_PATHMUSTEXIST = 0x00000800 + OFN_OVERWRITEPROMPT = 0x00000002 + OFN_NOCHANGEDIR = 0x00000008 + OFN_ALLOWMULTISELECT = 0x00000200 ) type openfilenameW struct { @@ -108,6 +110,86 @@ func OpenFileDialog(title string, filters ...FileFilter) (string, error) { return windows.UTF16PtrToString(ofn.lpstrFile), nil } +// OpenFilesDialog shows a native open dialog allowing multiple selections and +// returns the chosen paths. +func OpenFilesDialog(title string, filters ...FileFilter) ([]string, error) { + // Multi-select needs a large buffer: the result holds the directory plus + // every selected filename, NUL-separated, terminated by a double NUL. + fileBuf := make([]uint16, 64*1024) + + var filter []uint16 + for _, filt := range filters { + desc := fmt.Sprintf("%s (%s)", filt.Description, strings.Join(filt.Extensions, ",")) + filter = append(filter, utf16.Encode([]rune(desc))...) + filter = append(filter, 0x00) + for _, ext := range filt.Extensions { + s := fmt.Sprintf("*.%s;", ext) + filter = append(filter, utf16.Encode([]rune(s))...) + } + filter = append(filter, 0x00) + } + + filterPtr := utf16ptr(filter) + titlePtr, err := windows.UTF16PtrFromString(title) + if err != nil { + return nil, err + } + + ofn := openfilenameW{ + lStructSize: uint32(unsafe.Sizeof(openfilenameW{})), + lpstrFilter: filterPtr, + lpstrFile: &fileBuf[0], + nMaxFile: uint32(len(fileBuf)), + lpstrTitle: titlePtr, + Flags: OFN_EXPLORER | OFN_ALLOWMULTISELECT | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR, + nFilterIndex: 1, + } + + ret, _, err := procGetOpenFileNameW.Call(uintptr(unsafe.Pointer(&ofn))) + if ret == 0 { + if err != syscall.Errno(0) { + return nil, err + } + return nil, ErrCancelled + } + + paths := parseMultiSelect(fileBuf) + if len(paths) == 0 { + return nil, ErrCancelled + } + return paths, nil +} + +// parseMultiSelect decodes the OFN_ALLOWMULTISELECT result buffer. When a single +// file is chosen the buffer holds its full path; when several are chosen the +// first entry is the directory and the rest are bare filenames to join onto it. +func parseMultiSelect(buf []uint16) []string { + var parts []string + start := 0 + for i := 0; i < len(buf); i++ { + if buf[i] == 0 { + if i == start { + break // empty entry => terminating double NUL + } + parts = append(parts, string(utf16.Decode(buf[start:i]))) + start = i + 1 + } + } + switch len(parts) { + case 0: + return nil + case 1: + return parts + default: + dir := parts[0] + out := make([]string, 0, len(parts)-1) + for _, name := range parts[1:] { + out = append(out, filepath.Join(dir, name)) + } + return out + } +} + type browseinfoW struct { HwndOwner uintptr PidlRoot uintptr diff --git a/pkg/native/native.go b/pkg/native/native.go index f950ac59..a52de2f0 100644 --- a/pkg/native/native.go +++ b/pkg/native/native.go @@ -22,6 +22,7 @@ type FileRequest struct { } type FileResponse struct { - Path string - Err string + Path string + Paths []string + Err string } diff --git a/pkg/ota/firmware.bin b/pkg/ota/firmware.bin index a23cca3a..8710acd4 100644 Binary files a/pkg/ota/firmware.bin and b/pkg/ota/firmware.bin differ diff --git a/pkg/ota/ota.go b/pkg/ota/ota.go index d666be6b..5c1c7510 100644 --- a/pkg/ota/ota.go +++ b/pkg/ota/ota.go @@ -8,7 +8,6 @@ import ( "io" "log" "net" - "os" "strings" "time" @@ -24,7 +23,7 @@ var firmware []byte const ( COM_SPEED = 1000000 - MinimumtxbridgeVersion = "1.1.3" + MinimumtxbridgeVersion = "1.1.4" ) type Config struct { @@ -57,7 +56,6 @@ func UpdateOTA(cfg Config) error { } else { sp, err = openSerialPort(cfg.Port) } - if err != nil { if sp != nil { sp.Close() @@ -71,7 +69,7 @@ func UpdateOTA(cfg Config) error { // return err //} - //cfg.Logfunc("Firmware size: ", len(firmware)) + // cfg.Logfunc("Firmware size: ", len(firmware)) cmd := serialcommand.NewSerialCommand('v', []byte{0x10}) buf, err := cmd.MarshalBinary() @@ -204,15 +202,6 @@ func openSerialPort(port string) (io.ReadWriteCloser, error) { func openTcpPort(address string) (io.ReadWriteCloser, error) { d := net.Dialer{Timeout: 2 * time.Second} - if address == "" { - address = "192.168.4.1:1337" - } - if value := os.Getenv("TXBRIDGE_ADDRESS"); value != "" { - address = value - } - if !strings.HasSuffix(address, ":1337") { - address += ":1337" // Ensure the port is always set - } p, err := d.Dial("tcp", address) if err != nil { return nil, err @@ -226,7 +215,6 @@ func openTcpPort(address string) (io.ReadWriteCloser, error) { // readSerialCommand reads a single command from the serial port with timeout func readSerialCommand(port io.ReadWriteCloser, timeout time.Duration) (*serialcommand.SerialCommand, error) { - var ( parsingCommand bool command byte diff --git a/pkg/txbridge/client.go b/pkg/txbridge/client.go index e679ff59..9052e74b 100644 --- a/pkg/txbridge/client.go +++ b/pkg/txbridge/client.go @@ -1,28 +1,51 @@ package txbridge import ( - "context" "errors" - "fmt" - "log" "net" - "os" "strings" "time" "github.com/roffe/gocan/pkg/serialcommand" - "github.com/roffe/txlogger/pkg/mdns" ) -var ErrNotConnected = errors.New("not connected") -var ErrNoData = errors.New("no data read") +const DefaultAddress = "192.168.4.1:1337" -func NewClient() *Client { - return &Client{} +var ( + ErrNotConnected = errors.New("not connected") + ErrNoData = errors.New("no data read") +) + +// ResolveAddress returns the host:port to dial for a txbridge connection. +// The host comes from a "tcp://host[:port]" port string; the port is always +// forced to 1337 (appended if missing, overwritten if wrong). Anything else +// falls back to DefaultAddress. +func ResolveAddress(port string) string { + addr, ok := strings.CutPrefix(port, "tcp://") + if !ok || addr == "" { + return DefaultAddress + } + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr // no port present + } + return net.JoinHostPort(host, "1337") +} + +func NewClient(address string) *Client { + return &Client{ + address: address, + } +} + +// SetAddress updates the address used by the next Connect call. +func (c *Client) SetAddress(address string) { + c.address = address } type Client struct { - conn net.Conn + conn net.Conn + address string } func (c *Client) Connect() error { @@ -30,28 +53,19 @@ func (c *Client) Connect() error { return nil // Already connected } dialer := net.Dialer{Timeout: 2 * time.Second} + //ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + //defer cancel() + //if src, err := mdns.Query(ctx, "txbridge.local"); err != nil { + // log.Printf("failed to query mDNS: %v", err) + //} else { + // if src.IsValid() { + // address = fmt.Sprintf("%s:%d", src.String(), 1337) + // } else { + // log.Printf("No mDNS response, using address: %s", address) + // } + //} - address := "192.168.4.1:1337" - if value := os.Getenv("TXBRIDGE_ADDRESS"); value != "" { - address = value - } - if !strings.HasSuffix(address, ":1337") { - address += ":1337" // Ensure the port is always set - } - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - if src, err := mdns.Query(ctx, "txbridge.local"); err != nil { - log.Printf("failed to query mDNS: %v", err) - } else { - if src.IsValid() { - address = fmt.Sprintf("%s:%d", src.String(), 1337) - } else { - log.Printf("No mDNS response, using address: %s", address) - } - } - - conn, err := dialer.Dial("tcp", address) + conn, err := dialer.Dial("tcp", c.address) if err != nil { return err } diff --git a/pkg/txbridge/client_test.go b/pkg/txbridge/client_test.go new file mode 100644 index 00000000..f3969f81 --- /dev/null +++ b/pkg/txbridge/client_test.go @@ -0,0 +1,19 @@ +package txbridge + +import "testing" + +func TestResolveAddress(t *testing.T) { + cases := map[string]string{ + "": DefaultAddress, + "COM3": DefaultAddress, // serial port, not tcp + "tcp://": DefaultAddress, + "tcp://10.0.0.5": "10.0.0.5:1337", // port appended + "tcp://10.0.0.5:8080": "10.0.0.5:1337", // wrong port overwritten + "tcp://192.168.4.1:1337": "192.168.4.1:1337", + } + for in, want := range cases { + if got := ResolveAddress(in); got != want { + t.Errorf("ResolveAddress(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/pkg/wbl/aem/aem_uego.go b/pkg/wbl/aem/aem_uego.go index 98379771..30e88324 100644 --- a/pkg/wbl/aem/aem_uego.go +++ b/pkg/wbl/aem/aem_uego.go @@ -7,6 +7,7 @@ import ( "fmt" "strconv" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -14,6 +15,10 @@ import ( const ProductString = "AEM Uego" +// reconnectDelay is how long run() waits between reconnect attempts after the +// serial port drops (a common occurrence on Windows). +const reconnectDelay = time.Second + type AEMuego struct { port string sp serial.Port @@ -25,6 +30,8 @@ type AEMuego struct { log func(string) closeOnce sync.Once + closed atomic.Bool + done chan struct{} mu sync.Mutex dataBuff []byte @@ -41,13 +48,26 @@ func NewAEMuegoClient(port string, logFunc func(string)) (*AEMuego, error) { return &AEMuego{ port: port, log: logFunc, + done: make(chan struct{}), dataBuff: make([]byte, 8), //debugLog: f, }, nil } func (a *AEMuego) Start(ctx context.Context) error { + // Fail fast if we can't open the port at all on the initial attempt so the + // caller gets immediate feedback. Later drops are handled by run(). + if err := a.openPort(); err != nil { + return err + } + go a.run(ctx) + + return nil +} + +// openPort opens the serial port and stores it on the client. +func (a *AEMuego) openPort() error { mode := &serial.Mode{ BaudRate: 9600, } @@ -55,13 +75,59 @@ func (a *AEMuego) Start(ctx context.Context) error { if err != nil { return err } + sp.SetReadTimeout(5 * time.Millisecond) + + a.mu.Lock() a.sp = sp + a.mu.Unlock() + return nil +} - a.sp.SetReadTimeout(5 * time.Millisecond) +// getPort returns the current serial port, or nil if none is open. +func (a *AEMuego) getPort() serial.Port { + a.mu.Lock() + defer a.mu.Unlock() + return a.sp +} - go a.run(ctx) +// closePort closes the current serial port if open. It is safe to call +// multiple times. +func (a *AEMuego) closePort() { + a.mu.Lock() + sp := a.sp + a.sp = nil + a.mu.Unlock() + if sp != nil { + if err := sp.Close(); err != nil { + a.log("AEM: " + err.Error()) + } + } +} - return nil +// reconnect keeps trying to reopen the serial port until it succeeds or the +// client is stopped. It returns true when the port was reopened, false when +// the client is shutting down. +func (a *AEMuego) reconnect(ctx context.Context) bool { + for { + if ctx.Err() != nil || a.closed.Load() { + return false + } + a.log("AEM: reconnecting to " + a.port) + if err := a.openPort(); err != nil { + a.log("AEM: reconnect failed: " + err.Error()) + select { + case <-ctx.Done(): + return false + case <-a.done: + return false + case <-time.After(reconnectDelay): + } + continue + } + a.log("AEM: reconnected to " + a.port) + a.dataPos = 0 + return true + } } func (a *AEMuego) GetLambda() float64 { @@ -77,16 +143,35 @@ func (a *AEMuego) SetLambda(value float64) { } func (a *AEMuego) run(ctx context.Context) { + // Make sure any port we (re)opened gets cleaned up when run() exits. + defer a.closePort() + buf := make([]byte, 8) for { + if ctx.Err() != nil || a.closed.Load() { + return + } + + sp := a.getPort() + if sp == nil { + if !a.reconnect(ctx) { + return + } + continue + } + // read from serial - n, err := a.sp.Read(buf) - if ctx.Err() != nil { + n, err := sp.Read(buf) + if ctx.Err() != nil || a.closed.Load() { return } if err != nil { a.log("AEM: " + err.Error()) - return + a.closePort() + if !a.reconnect(ctx) { + return + } + continue } if n == 0 { continue @@ -126,12 +211,12 @@ func (a *AEMuego) run(ctx context.Context) { func (a *AEMuego) Stop() { a.closeOnce.Do(func() { - if a.sp != nil { - a.log("Stopping AEM serial client") - if err := a.sp.Close(); err != nil { - a.log(err.Error()) - } - } + a.log("Stopping AEM serial client") + // Signal run()/reconnect() to stop before closing the port so a port + // drop isn't mistaken for a reconnect opportunity. + a.closed.Store(true) + close(a.done) + a.closePort() //if a.debugLog != nil { // a.debugLog.Sync() // a.debugLog.Close() diff --git a/pkg/wbl/innovate/innovate.go b/pkg/wbl/innovate/innovate.go index 56cf884e..0918cfb0 100644 --- a/pkg/wbl/innovate/innovate.go +++ b/pkg/wbl/innovate/innovate.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -16,6 +17,10 @@ const ( ProductString = "Innovate Serial Protocol v2" ) +// reconnectDelay is how long run() waits between reconnect attempts after the +// serial port drops (a common occurrence on Windows). +const reconnectDelay = time.Second + const ( ISP2_NORMAL uint8 = iota ISP2_O2 @@ -57,7 +62,10 @@ type ISP2Client struct { syncBuffer []byte // New field for synchronization - mu sync.Mutex + closeOnce sync.Once + closed atomic.Bool + done chan struct{} + mu sync.Mutex log func(string) } @@ -66,39 +74,107 @@ func NewISP2Client(port string, logFunc func(string)) (*ISP2Client, error) { return &ISP2Client{ port: port, buff: bytes.NewBuffer(nil), + done: make(chan struct{}), log: logFunc, }, nil } func (c *ISP2Client) Start(ctx context.Context) error { - if c.port != "txbridge" { - c.log("Starting ISP2 client") - mode := &serial.Mode{ - BaudRate: 9600, - } - sp, err := serial.Open(c.port, mode) - if err != nil { - return err - } - c.sp = sp - - c.sp.SetReadTimeout(20 * time.Millisecond) + if c.port == "txbridge" { + return nil + } + c.log("Starting ISP2 client") + // Fail fast if we can't open the port at all on the initial attempt so the + // caller gets immediate feedback. Later drops are handled by run(). + if err := c.openPort(); err != nil { + return err + } + go c.run(ctx) + return nil +} - // c.syncBuffer = make([]byte, 4) // Initialize syncBuffer - go c.run(ctx) +// openPort opens the serial port and stores it on the client. +func (c *ISP2Client) openPort() error { + mode := &serial.Mode{ + BaudRate: 19200, } + sp, err := serial.Open(c.port, mode) + if err != nil { + return fmt.Errorf("failed to open serial port: %w", err) + } + if err := sp.SetReadTimeout(5 * time.Millisecond); err != nil { + sp.Close() + return fmt.Errorf("failed to set read timeout: %w", err) + } + + c.mu.Lock() + c.sp = sp + c.mu.Unlock() return nil } -func (c *ISP2Client) Stop() { - if c.sp != nil { - c.log("Stopping ISP2 client") - if err := c.sp.Close(); err != nil { - c.log(err.Error()) +// getPort returns the current serial port, or nil if none is open. +func (c *ISP2Client) getPort() serial.Port { + c.mu.Lock() + defer c.mu.Unlock() + return c.sp +} + +// closePort closes the current serial port if open. It is safe to call +// multiple times. +func (c *ISP2Client) closePort() { + c.mu.Lock() + sp := c.sp + c.sp = nil + c.mu.Unlock() + if sp != nil { + if err := sp.Close(); err != nil { + c.log("isp2: failed to close serial port: " + err.Error()) } } } +// reconnect keeps trying to reopen the serial port until it succeeds or the +// client is stopped. It returns true when the port was reopened, false when +// the client is shutting down. +func (c *ISP2Client) reconnect(ctx context.Context) bool { + for { + if ctx.Err() != nil || c.closed.Load() { + return false + } + c.log("isp2: reconnecting to " + c.port) + if err := c.openPort(); err != nil { + c.log("isp2: reconnect failed: " + err.Error()) + select { + case <-ctx.Done(): + return false + case <-c.done: + return false + case <-time.After(reconnectDelay): + } + continue + } + c.log("isp2: reconnected to " + c.port) + // Drop any partially-parsed frame from before the drop. + c.mu.Lock() + c.syncBuffer = nil + c.wordIndex = 0 + c.mu.Unlock() + return true + } +} + +func (c *ISP2Client) Stop() { + c.closeOnce.Do(func() { + c.log("Stopping ISP2 client") + // Signal run()/reconnect() to stop before closing the port so a port + // drop isn't mistaken for a reconnect opportunity. + c.closed.Store(true) + close(c.done) + c.closePort() + }) +} + func (c *ISP2Client) SetData(data []byte) { c.processBytes(data) } @@ -163,16 +239,35 @@ func (c *ISP2Client) String() string { } func (c *ISP2Client) run(ctx context.Context) { + // Make sure any port we (re)opened gets cleaned up when run() exits. + defer c.closePort() + buf := make([]byte, 16) for { + if ctx.Err() != nil || c.closed.Load() { + return + } + + sp := c.getPort() + if sp == nil { + if !c.reconnect(ctx) { + return + } + continue + } + // read from serial - n, err := c.sp.Read(buf) - if ctx.Err() != nil { + n, err := sp.Read(buf) + if ctx.Err() != nil || c.closed.Load() { return } if err != nil { c.log("isp2: " + err.Error()) - return + c.closePort() + if !c.reconnect(ctx) { + return + } + continue } if n == 0 { continue diff --git a/pkg/wbl/stag/stag.go b/pkg/wbl/stag/stag.go index 2ba85287..04169696 100644 --- a/pkg/wbl/stag/stag.go +++ b/pkg/wbl/stag/stag.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -11,6 +12,10 @@ import ( const ProductString = "Stag AFR" +// reconnectDelay is how long run() waits between reconnect attempts after the +// serial port drops (a common occurrence on Windows). +const reconnectDelay = time.Second + type STAG struct { port string sp serial.Port @@ -21,19 +26,33 @@ type STAG struct { log func(string) closeOnce sync.Once + closed atomic.Bool + done chan struct{} mu sync.Mutex - worker *workerInfo } func NewSTAGClient(port string, logFunc func(string)) (*STAG, error) { return &STAG{ port: port, + done: make(chan struct{}), log: logFunc, }, nil } func (a *STAG) Start(ctx context.Context) error { + // Fail fast if we can't open the port at all on the initial attempt so the + // caller gets immediate feedback. Later drops are handled by run(). + if err := a.openPort(); err != nil { + return err + } + + go a.run(ctx) + return nil +} + +// openPort opens the serial port and stores it on the client. +func (a *STAG) openPort() error { mode := &serial.Mode{ BaudRate: 57600, } @@ -41,21 +60,58 @@ func (a *STAG) Start(ctx context.Context) error { if err != nil { return err } + sp.SetReadTimeout(5 * time.Millisecond) + + a.mu.Lock() a.sp = sp + a.mu.Unlock() + return nil +} - a.sp.SetReadTimeout(5 * time.Millisecond) +// getPort returns the current serial port, or nil if none is open. +func (a *STAG) getPort() serial.Port { + a.mu.Lock() + defer a.mu.Unlock() + return a.sp +} - ctx, cancel := context.WithCancel(context.Background()) - a.worker = &workerInfo{ - cancel: cancel, - done: make(chan struct{}), +// closePort closes the current serial port if open. It is safe to call +// multiple times. +func (a *STAG) closePort() { + a.mu.Lock() + sp := a.sp + a.sp = nil + a.mu.Unlock() + if sp != nil { + if err := sp.Close(); err != nil { + a.log(err.Error()) + } } - go func() { - a.run(ctx) - close(a.worker.done) - }() +} - return nil +// reconnect keeps trying to reopen the serial port until it succeeds or the +// client is stopped. It returns true when the port was reopened, false when +// the client is shutting down. +func (a *STAG) reconnect(ctx context.Context) bool { + for { + if ctx.Err() != nil || a.closed.Load() { + return false + } + a.log("Stag: reconnecting to " + a.port) + if err := a.openPort(); err != nil { + a.log("Stag: reconnect failed: " + err.Error()) + select { + case <-ctx.Done(): + return false + case <-a.done: + return false + case <-time.After(reconnectDelay): + } + continue + } + a.log("Stag: reconnected to " + a.port) + return true + } } func (a *STAG) GetLambda() float64 { @@ -65,6 +121,42 @@ func (a *STAG) GetLambda() float64 { } func (a *STAG) run(ctx context.Context) { + // Make sure any port we (re)opened gets cleaned up when run() exits. + defer a.closePort() + + for { + if ctx.Err() != nil || a.closed.Load() { + return + } + + sp := a.getPort() + if sp == nil { + if !a.reconnect(ctx) { + return + } + continue + } + + // session() runs until the port errors or the client is stopped. + a.session(ctx, sp) + + if ctx.Err() != nil || a.closed.Load() { + return + } + a.closePort() + if !a.reconnect(ctx) { + return + } + } +} + +// session drives one connection: it reads from sp and parses packets until the +// port errors or the client is stopped. +func (a *STAG) session(ctx context.Context, sp serial.Port) { + // sessionCtx tears down the reader goroutine when this session ends. + sessionCtx, cancel := context.WithCancel(ctx) + defer cancel() + packetContentBuffer := make([]byte, 0, 64) buf := make([]byte, 8) packetStarted := false @@ -80,19 +172,26 @@ func (a *STAG) run(ctx context.Context) { go func() { for { // read from serial - n, err := a.sp.Read(buf) - if ctx.Err() != nil { + n, err := sp.Read(buf) + if sessionCtx.Err() != nil { + return + } + if err != nil { + select { + case errChan <- err: + case <-sessionCtx.Done(): + } return } if n == 0 { continue } - - if err != nil { - errChan <- err - } for _, b := range buf[:n] { - byteChan <- b + select { + case byteChan <- b: + case <-sessionCtx.Done(): + return + } } } }() @@ -101,9 +200,11 @@ func (a *STAG) run(ctx context.Context) { select { case <-ctx.Done(): return + case <-a.done: + return case err := <-errChan: a.log(err.Error()) - // Handle errorfunc + // Port dropped; let run() handle reconnection. return case aByte := <-byteChan: if !packetStarted && aByte == 0x32 { @@ -128,12 +229,12 @@ func (a *STAG) run(ctx context.Context) { func (a *STAG) Stop() { a.closeOnce.Do(func() { - if a.sp != nil { - a.log("Stopping Stag serial client") - if err := a.sp.Close(); err != nil { - a.log(err.Error()) - } - } + a.log("Stopping Stag serial client") + // Signal run()/session()/reconnect() to stop before closing the port so + // a port drop isn't mistaken for a reconnect opportunity. + a.closed.Store(true) + close(a.done) + a.closePort() }) } func (a *STAG) processPacket(packetContentBuffer []byte) { @@ -158,7 +259,9 @@ func (a *STAG) processPacket(packetContentBuffer []byte) { func (a *STAG) sendRequest(data []byte) { time.Sleep(100 * time.Millisecond) - a.sp.Write(data) + if sp := a.getPort(); sp != nil { + sp.Write(data) + } } func (a *STAG) SetData(data []byte) error { @@ -184,8 +287,3 @@ func (a *STAG) SetData(data []byte) error { func (a *STAG) String() string { return fmt.Sprintf("Lambda: %.4f, Oxygen: %.3f", a.lambda, a.oxygen) } - -type workerInfo struct { - cancel context.CancelFunc - done chan struct{} -} diff --git a/pkg/wbl/zeitronix/zeitronix.go b/pkg/wbl/zeitronix/zeitronix.go index 7cbb53de..c979d1de 100644 --- a/pkg/wbl/zeitronix/zeitronix.go +++ b/pkg/wbl/zeitronix/zeitronix.go @@ -4,8 +4,8 @@ import ( "context" "errors" "fmt" - "log" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -13,6 +13,10 @@ import ( const ProductString = "Zeitronix ZT-2" +// reconnectDelay is how long the serial handler waits between reconnect +// attempts after the serial port drops (a common occurrence on Windows). +const reconnectDelay = time.Second + /* Zeitronix Packet format, []byte [0] always 0 @@ -41,18 +45,34 @@ type Zeitronix struct { p serial.Port closeOnce sync.Once + closed atomic.Bool + done chan struct{} + mu sync.Mutex logFunc func(string) } func NewZeitronixClient(port string, logFunc func(string)) (*Zeitronix, error) { z := &Zeitronix{ Port: port, + done: make(chan struct{}), logFunc: logFunc, } return z, nil } func (z *Zeitronix) Start(ctx context.Context) error { + // Fail fast if we can't open the port at all on the initial attempt so the + // caller gets immediate feedback. Later drops are handled by serialHandler(). + if err := z.openPort(); err != nil { + return err + } + go z.serialHandler(ctx) + + return nil +} + +// openPort opens the serial port and stores it on the client. +func (z *Zeitronix) openPort() error { mode := &serial.Mode{ BaudRate: 9600, Parity: serial.NoParity, @@ -63,23 +83,94 @@ func (z *Zeitronix) Start(ctx context.Context) error { if err != nil { return err } - z.p = sp - z.p.SetReadTimeout(500 * time.Millisecond) - go z.serialHandler() + sp.SetReadTimeout(5 * time.Millisecond) + z.mu.Lock() + z.p = sp + z.mu.Unlock() return nil } -func (z *Zeitronix) serialHandler() { +// getPort returns the current serial port, or nil if none is open. +func (z *Zeitronix) getPort() serial.Port { + z.mu.Lock() + defer z.mu.Unlock() + return z.p +} + +// closePort closes the current serial port if open. It is safe to call +// multiple times. +func (z *Zeitronix) closePort() { + z.mu.Lock() + sp := z.p + z.p = nil + z.mu.Unlock() + if sp != nil { + if err := sp.Close(); err != nil { + z.logFunc("Zeitronix: " + err.Error()) + } + } +} + +// reconnect keeps trying to reopen the serial port until it succeeds or the +// client is stopped. It returns true when the port was reopened, false when +// the client is shutting down. +func (z *Zeitronix) reconnect(ctx context.Context) bool { + for { + if ctx.Err() != nil || z.closed.Load() { + return false + } + z.logFunc("Zeitronix: reconnecting to " + z.Port) + if err := z.openPort(); err != nil { + z.logFunc("Zeitronix: reconnect failed: " + err.Error()) + select { + case <-ctx.Done(): + return false + case <-z.done: + return false + case <-time.After(reconnectDelay): + } + continue + } + z.logFunc("Zeitronix: reconnected to " + z.Port) + return true + } +} + +func (z *Zeitronix) serialHandler(ctx context.Context) { + // Make sure any port we (re)opened gets cleaned up when this returns. + defer z.closePort() + buff := make([]byte, 14) cmd := make([]byte, 14) step := 0 for { - n, err := z.p.Read(buff) - if err != nil { - log.Println("Zeitronix read error:", err) + if ctx.Err() != nil || z.closed.Load() { + return + } + + sp := z.getPort() + if sp == nil { + if !z.reconnect(ctx) { + return + } + step = 0 + continue + } + + n, err := sp.Read(buff) + if ctx.Err() != nil || z.closed.Load() { return } + if err != nil { + z.logFunc("Zeitronix read error: " + err.Error()) + z.closePort() + step = 0 + if !z.reconnect(ctx) { + return + } + continue + } if n == 0 { continue } @@ -128,9 +219,11 @@ func (z *Zeitronix) SetData(data []byte) error { func (z *Zeitronix) Stop() { z.closeOnce.Do(func() { z.logFunc("Closing Zeitronix client") - if z.p != nil { - z.p.Close() - } + // Signal serialHandler()/reconnect() to stop before closing the port so + // a port drop isn't mistaken for a reconnect opportunity. + z.closed.Store(true) + close(z.done) + z.closePort() }) } diff --git a/pkg/widgets/boosttuner/boosttuner.go b/pkg/widgets/boosttuner/boosttuner.go new file mode 100644 index 00000000..dde8a718 --- /dev/null +++ b/pkg/widgets/boosttuner/boosttuner.go @@ -0,0 +1,444 @@ +// Package boosttuner provides a widget that helps auto-tune the Trionic 7 boost +// (APC) controller from logged data and writes the result back into the loaded +// binary. +// +// The T7 boost controller is a feedforward + PID loop (see Boost.c in the EU03 +// source): +// +// PWMCalc = RegConValue // feedforward: BoostCal.RegMap[SetValue, rpm] +// + Adaption // learned offset (BoostAdap.Adaption) +// + PFac + IFac + DFac // PID on LoadDiff = SetValue - m_AirInlet +// + env compensation // temp / altitude / E85 / noise reduction +// +// PWMCalc is the wastegate solenoid duty cycle in 0.1% units, clamped 2..98%. +// +// RegMap is the feedforward map and can be genuinely learned: at samples where +// the loop is settled and on target, the duty the feedforward *should* have +// supplied equals RegConValue plus everything the loop was adding to correct it +// (PFac + IFac + DFac + Adaption). Folding that sum back into RegMap leaves the +// loop with less to correct. The PID maps cannot be cleanly learned this way, so +// they are handled separately (heuristic suggestions + a replay simulator). +// +// Units note: the controller internals (RegConValue, P/I/D, Adaption, PWMCalc) +// are logged in raw 0.1% units (correction factor 1, e.g. 450 == 45.0%), while +// the BoostCal.RegMap symbol stores % (correction factor 0.1, e.g. 45.0). We +// learn in raw units and divide by dutyRawPerPct to land in the % the map uses. +package boosttuner + +import ( + "fmt" + "io" + "log" + "math" + "path/filepath" + "sort" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + symbol "github.com/roffe/ecusymbol" + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/logfile" + "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/meshgrid" + "github.com/roffe/txlogger/pkg/widgets/progressmodal" +) + +// dutyRawPerPct converts the controller's raw 0.1% duty units into the % units +// the BoostCal.RegMap symbol stores (450 raw -> 45.0%). +const dutyRawPerPct = 10.0 + +var _ fyne.Widget = (*BoostTuner)(nil) + +// Config wires the tuner to the loaded binary. Symbols is read for the current +// maps and their axes; Save persists an edited map back into the binary (and to +// disk). Both are supplied by the main window, which owns mw.fw and the filename. +type Config struct { + // Symbols is the currently loaded binary (mw.fw). Used read-only here. + Symbols symbol.SymbolCollection + // Save writes data (in engineering units) into the named symbol and persists + // the binary to disk, taking a one-time backup on first write. nil disables + // the "Apply to binary" buttons. + Save func(symbolName string, data []float64) error + + MeshRenderer meshgrid.RenderBackend + Colorblind colors.ColorBlindMode +} + +// channel is a logical signal the tuner needs, with the candidate series names to +// look for in a log (first present wins) and a human description for the checklist. +type channel struct { + key string + candidates []string + desc string +} + +// requiredChannels lists the log signals the RegMap learner and simulator rely +// on. Boost.SetValue is the exact load value the ECU feeds into the RegMap X +// lookup; m_Request is the same quantity under its airmass-master name and is a +// fallback. m_AirInletBoost is the airmass the loop regulates against. +var requiredChannels = []channel{ + {"rpm", []string{"ActualIn.n_Engine"}, "Engine speed (RegMap Y axis)"}, + {"setValue", []string{"Boost.SetValue", "m_Request", "AirMassMast.m_Request"}, "Load set value (RegMap X axis)"}, + {"regCon", []string{"BoostProt.RegConValue"}, "Feedforward duty from RegMap"}, + {"pFac", []string{"BoostProt.PFac"}, "P part"}, + {"iFac", []string{"BoostProt.IFac"}, "I part"}, + {"dFac", []string{"BoostProt.DFac"}, "D part"}, + {"adaption", []string{"BoostAdap.Adaption"}, "Adaption offset"}, + {"loadDiff", []string{"BoostProt.LoadDiff"}, "Load error (SetValue - airmass)"}, + {"pwmCalc", []string{"BoostProt.PWMCalc"}, "Total calculated duty"}, + {"pwm2pct", []string{"BoostProt.ST_PWM2Perc"}, "Open-loop/2% flag"}, + {"airInlet", []string{"MAF.m_AirInletBoost", "MAF.m_AirInlet"}, "Actual airmass"}, +} + +type BoostTuner struct { + widget.BaseWidget + cfg Config + + // values holds every series merged across all loaded log files, row-aligned + // with NaN padding (same scheme as the matrix builder). + values map[string][]float64 + order []string + loadedFiles []string + nrecords int + + // resolved maps each logical channel key to the actual series name found in + // the loaded logs (empty when missing). + resolved map[string]string + + // RegMap state (see regmap.go). + rmAxisX, rmAxisY []float64 // breakpoints read from the binary + rmCols, rmRows int + rmCurrent, rmLearned, rmDelta []float64 // engineering units (%) + rmCounts []int + rmBuilt bool + + // RegMap tuning parameters. + onTarget float64 // accept samples with |LoadDiff| <= this (mg/c) + rpmStab float64 // reject when |rpm step| exceeds this (rpm) + loadStab float64 // reject when |SetValue step| exceeds this (mg/c) + minSamples int // cells with fewer hits keep their current value + blend float64 // fraction (0..1) of the learned change to apply + + // PID editors keyed by "P"/"I"/"D" (see pid.go). + pidEditors map[string]*pidEditor + + // widgets + logsLabel *widget.Label + channelList *fyne.Container + rmStatus *widget.Label + rmView *widget.Select + rmDisplay *fyne.Container + + tabs *container.AppTabs + content fyne.CanvasObject +} + +// New builds an empty tuner bound to the loaded binary in cfg. +func New(cfg Config) *BoostTuner { + bt := &BoostTuner{ + cfg: cfg, + values: make(map[string][]float64), + resolved: make(map[string]string), + onTarget: 30, + rpmStab: 150, + loadStab: 50, + minSamples: 5, + blend: 1.0, + } + bt.ExtendBaseWidget(bt) + bt.buildUI() + return bt +} + +func (bt *BoostTuner) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(bt.content) +} + +func (bt *BoostTuner) buildUI() { + bt.tabs = container.NewAppTabs( + container.NewTabItemWithIcon("Logs", theme.FolderOpenIcon(), bt.buildLogsTab()), + container.NewTabItemWithIcon("RegMap", theme.GridIcon(), bt.buildRegMapTab()), + container.NewTabItemWithIcon("PID maps", theme.GridIcon(), bt.buildPIDTab()), + container.NewTabItemWithIcon("Simulator", theme.MediaPlayIcon(), bt.buildSimTab()), + ) + bt.content = bt.tabs +} + +// --- Logs tab --- + +func (bt *BoostTuner) buildLogsTab() fyne.CanvasObject { + bt.logsLabel = widget.NewLabel("No log files loaded") + bt.logsLabel.Wrapping = fyne.TextWrapWord + + addBtn := widget.NewButtonWithIcon("Add log files", theme.FolderOpenIcon(), bt.openLogDialog) + clearBtn := widget.NewButtonWithIcon("Clear", theme.ContentClearIcon(), bt.clearLogs) + + bt.channelList = container.NewVBox() + bt.refreshChannelList() + + intro := widget.NewLabel( + "Load logs from boost pulls (ideally full-throttle runs across the rev range), " + + "then use the RegMap tab to learn the feedforward map. The channels below " + + "must be present in the logs.") + intro.Wrapping = fyne.TextWrapWord + + return container.NewBorder( + container.NewVBox( + intro, + container.NewGridWithColumns(2, addBtn, clearBtn), + bt.logsLabel, + widget.NewSeparator(), + widget.NewLabelWithStyle("Required channels", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + ), + nil, nil, nil, + container.NewVScroll(bt.channelList), + ) +} + +// refreshChannelList redraws the per-channel present/missing checklist against the +// currently loaded logs. +func (bt *BoostTuner) refreshChannelList() { + bt.channelList.Objects = bt.channelList.Objects[:0] + for _, ch := range requiredChannels { + name := bt.resolved[ch.key] + var icon fyne.Resource + var detail string + if name != "" { + icon = theme.ConfirmIcon() + detail = name + } else { + icon = theme.CancelIcon() + detail = "missing: " + strings.Join(ch.candidates, " / ") + } + row := container.NewBorder(nil, nil, + widget.NewIcon(icon), + widget.NewLabel(detail), + widget.NewLabel(ch.desc), + ) + bt.channelList.Add(row) + } + bt.channelList.Refresh() +} + +// resolveChannels picks, for each logical channel, the first candidate series +// present in the loaded logs. +func (bt *BoostTuner) resolveChannels() { + bt.resolved = make(map[string]string) + for _, ch := range requiredChannels { + for _, cand := range ch.candidates { + if _, ok := bt.values[cand]; ok { + bt.resolved[ch.key] = cand + break + } + } + } +} + +// series returns the merged log data for a resolved channel key. +func (bt *BoostTuner) series(key string) ([]float64, bool) { + name := bt.resolved[key] + if name == "" { + return nil, false + } + v, ok := bt.values[name] + return v, ok +} + +// missingChannels lists the descriptions of any required channels not found. +func (bt *BoostTuner) missingChannels() []string { + var out []string + for _, ch := range requiredChannels { + if bt.resolved[ch.key] == "" { + out = append(out, ch.desc) + } + } + return out +} + +// --- log loading (mirrors the matrix builder's pipeline) --- + +func (bt *BoostTuner) openLogDialog() { + widgets.SelectFiles(func(readers []fyne.URIReadCloser) { + c := fyne.CurrentApp().Driver().CanvasForObject(bt) + if c == nil { + if wins := fyne.CurrentApp().Driver().AllWindows(); len(wins) > 0 { + c = wins[0].Canvas() + } + } + var pm *progressmodal.ProgressModal + if c != nil { + pm = progressmodal.New(c, fmt.Sprintf("Parsing %d log file(s)...", len(readers))) + pm.Show() + } + + go func() { + type parsed struct { + name string + local map[string][]float64 + n int + } + var ok []parsed + var failed int + for _, r := range readers { + name := r.URI().Name() + local, n, err := parseLog(name, r) + r.Close() + if err != nil { + log.Println("boosttuner:", err) + failed++ + continue + } + ok = append(ok, parsed{name, local, n}) + } + + fyne.Do(func() { + if pm != nil { + pm.Hide() + } + for _, p := range ok { + bt.mergeLog(p.name, p.local, p.n) + } + bt.rebuildOrder() + bt.resolveChannels() + bt.refreshChannelList() + bt.refreshLogList() + if failed > 0 { + bt.logStatus(fmt.Sprintf("Loaded %d file(s), %d failed", len(ok), failed)) + } + }) + }() + }, "logfile", "t5l", "t7l", "t8l", "csv", "bpl") +} + +// parseLog reads a single log into a row-aligned series map, padding gaps with +// NaN. It touches no shared state, so it is safe off the UI goroutine. +func parseLog(name string, r io.Reader) (map[string][]float64, int, error) { + lf, err := logfile.Open(name, r) + if err != nil { + return nil, 0, err + } + defer lf.Close() + + local := make(map[string][]float64) + n := 0 + for { + rec := lf.Next() + if rec.EOF { + break + } + for k, v := range rec.Values { + if k == "Pgm_status" { + continue + } + arr := local[k] + for len(arr) < n { // back-fill records before this key first appeared + arr = append(arr, math.NaN()) + } + local[k] = append(arr, v) + } + n++ + for k, arr := range local { // forward-fill keys missing from this record + for len(arr) < n { + arr = append(arr, math.NaN()) + } + local[k] = arr + } + } + if n == 0 { + return nil, 0, fmt.Errorf("%s contains no records", name) + } + return local, n, nil +} + +// mergeLog appends a parsed log to the merged series set, keeping every series +// row-aligned. Must run on the UI goroutine. +func (bt *BoostTuner) mergeLog(name string, local map[string][]float64, n int) { + base := bt.nrecords + for k, arr := range local { + cur, ok := bt.values[k] + if !ok { + cur = nanSlice(base) + } + bt.values[k] = append(cur, arr...) + } + for k, cur := range bt.values { + if _, ok := local[k]; !ok { + bt.values[k] = append(cur, nanSlice(n)...) + } + } + bt.nrecords = base + n + bt.loadedFiles = append(bt.loadedFiles, name) +} + +func (bt *BoostTuner) clearLogs() { + bt.values = make(map[string][]float64) + bt.order = nil + bt.loadedFiles = nil + bt.nrecords = 0 + bt.resolveChannels() + bt.refreshChannelList() + bt.refreshLogList() +} + +func (bt *BoostTuner) rebuildOrder() { + bt.order = make([]string, 0, len(bt.values)) + for k := range bt.values { + bt.order = append(bt.order, k) + } + sort.Slice(bt.order, func(i, j int) bool { + return strings.ToLower(bt.order[i]) < strings.ToLower(bt.order[j]) + }) +} + +func (bt *BoostTuner) refreshLogList() { + if len(bt.loadedFiles) == 0 { + bt.logsLabel.SetText("No log files loaded") + return + } + var b strings.Builder + for i, f := range bt.loadedFiles { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(filepath.Base(f)) + } + bt.logsLabel.SetText(fmt.Sprintf("%d file(s), %d records:\n%s", + len(bt.loadedFiles), bt.nrecords, b.String())) +} + +// logStatus appends a one-off message to the log list label. +func (bt *BoostTuner) logStatus(msg string) { + bt.refreshLogList() + bt.logsLabel.SetText(bt.logsLabel.Text + "\n" + msg) +} + +func nanSlice(n int) []float64 { + s := make([]float64, n) + for i := range s { + s[i] = math.NaN() + } + return s +} + +// --- shared helpers --- + +// nearestIndex returns the index of the axis breakpoint closest to v. +func nearestIndex(axis []float64, v float64) int { + best := 0 + bestDist := math.Abs(axis[0] - v) + for i := 1; i < len(axis); i++ { + if d := math.Abs(axis[i] - v); d < bestDist { + bestDist = d + best = i + } + } + return best +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} diff --git a/pkg/widgets/boosttuner/integration_test.go b/pkg/widgets/boosttuner/integration_test.go new file mode 100644 index 00000000..7118e1a6 --- /dev/null +++ b/pkg/widgets/boosttuner/integration_test.go @@ -0,0 +1,53 @@ +package boosttuner + +import ( + "math" + "os" + "testing" + + symbol "github.com/roffe/ecusymbol" +) + +const testBinary = "/home/roffe/temp/bosse.bin" + +// TestRegMapBilerp_RealBinary checks that, against a real T7 binary, the RegMap is +// read row-major [rpm][load], the %->raw conversion is right, and bilerp returns +// the stored cell values at the axis breakpoints. Skips when the binary is absent. +func TestRegMapBilerp_RealBinary(t *testing.T) { + data, err := os.ReadFile(testBinary) + if err != nil { + t.Skipf("test binary not available: %v", err) + } + _, syms, err := symbol.Load(testBinary, data, func(string) {}) + if err != nil { + t.Fatalf("load: %v", err) + } + + x := syms.GetByName(symSetLoadXSP).Float64s() + y := syms.GetByName(symNEngSP).Float64s() + regPct := syms.GetByName(symRegMap).Float64s() + if len(x)*len(y) != len(regPct) { + t.Fatalf("RegMap %d != %d x %d", len(regPct), len(x), len(y)) + } + raw := make([]float64, len(regPct)) + for i, v := range regPct { + raw[i] = v * dutyRawPerPct + } + + // First cell is [rpm[0]][load[0]]; last is [rpm[last]][load[last]]. + first := regPct[0] * dutyRawPerPct + last := regPct[len(regPct)-1] * dutyRawPerPct + if got := bilerp(x, y, raw, x[0], y[0]); math.Abs(got-first) > 1e-6 { + t.Errorf("bilerp at first breakpoint = %v, want %v", got, first) + } + if got := bilerp(x, y, raw, x[len(x)-1], y[len(y)-1]); math.Abs(got-last) > 1e-6 { + t.Errorf("bilerp at last breakpoint = %v, want %v", got, last) + } + + // An interior breakpoint must equal its exact stored cell (row-major index). + r, c := len(y)/2, len(x)/2 + want := regPct[r*len(x)+c] * dutyRawPerPct + if got := bilerp(x, y, raw, x[c], y[r]); math.Abs(got-want) > 1e-6 { + t.Errorf("bilerp at interior breakpoint = %v, want %v", got, want) + } +} diff --git a/pkg/widgets/boosttuner/pid.go b/pkg/widgets/boosttuner/pid.go new file mode 100644 index 00000000..67302e1b --- /dev/null +++ b/pkg/widgets/boosttuner/pid.go @@ -0,0 +1,179 @@ +package boosttuner + +import ( + "fmt" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" +) + +// PID maps and their shared axes. All three are row-major [rpm][loadDiff]: +// rows follow PIDYSP (rpm), columns follow PIDXSP (load error mg/c). +const ( + symPMap = "BoostCal.PMap" + symIMap = "BoostCal.IMap" + symDMap = "BoostCal.DMap" + symPIDXSP = "BoostCal.PIDXSP" // X axis: load error (mg/c) + symPIDYSP = "BoostCal.PIDYSP" // Y axis: engine speed (rpm) +) + +// pidEditor is one editable PID gain map. data is the same slice the mapviewer +// edits in place, so hand-edits flow back without a copy; Apply writes data into +// the binary. +type pidEditor struct { + bt *BoostTuner + name string // "P" / "I" / "D" + symbolName string + axisX, axisY []float64 + cols, rows int + data []float64 + mv *mapviewer.MapViewer + status *widget.Label + suggestBox *fyne.Container // populated by the heuristics in pidtune.go +} + +func (bt *BoostTuner) buildPIDTab() fyne.CanvasObject { + x, errX := bt.readSymbol(symPIDXSP) + y, errY := bt.readSymbol(symPIDYSP) + if errX != nil || errY != nil { + return container.NewCenter(widget.NewLabel("BoostCal PID axes not found in this binary.")) + } + + bt.pidEditors = map[string]*pidEditor{} + tabs := container.NewAppTabs() + for _, m := range []struct{ name, sym string }{ + {"P", symPMap}, {"I", symIMap}, {"D", symDMap}, + } { + ed := bt.newPIDEditor(m.name, m.sym, x, y) + bt.pidEditors[m.name] = ed + tabs.Append(container.NewTabItem(m.name+" map", ed.object())) + } + + intro := widget.NewLabel( + "Edit the PID gain maps directly, or use the per-rpm-band suggestions " + + "(from logged boost transients) to scale a map. Validate changes in the " + + "Simulator tab before flashing.") + intro.Wrapping = fyne.TextWrapWord + + suggestStatus := widget.NewLabel("") + computeBtn := widget.NewButtonWithIcon("Compute suggestions from logs", theme.SearchIcon(), func() { + suggestStatus.SetText(bt.computePIDSuggestions()) + }) + + header := container.NewVBox( + intro, + container.NewBorder(nil, nil, nil, suggestStatus, computeBtn), + ) + return container.NewBorder(header, nil, nil, nil, tabs) +} + +func (bt *BoostTuner) newPIDEditor(name, symName string, x, y []float64) *pidEditor { + ed := &pidEditor{ + bt: bt, name: name, symbolName: symName, + axisX: x, axisY: y, cols: len(x), rows: len(y), + } + ed.status = widget.NewLabel("") + ed.suggestBox = container.NewVBox() + ed.reload() + return ed +} + +func (ed *pidEditor) object() fyne.CanvasObject { + display := container.NewStack() + ed.rebuildViewer(display) + + applyBtn := widget.NewButtonWithIcon("Apply to binary", theme.DocumentSaveIcon(), func() { + if ed.bt.cfg.Save == nil { + ed.status.SetText("No binary to write to.") + return + } + if err := ed.bt.cfg.Save(ed.symbolName, ed.data); err != nil { + ed.status.SetText("Save failed: " + err.Error()) + return + } + ed.status.SetText("Wrote " + ed.symbolName) + }) + if ed.bt.cfg.Save == nil { + applyBtn.Disable() + } + reloadBtn := widget.NewButtonWithIcon("Reload from binary", theme.ViewRefreshIcon(), func() { + ed.reload() + ed.rebuildViewer(display) + ed.status.SetText("Reloaded " + ed.symbolName) + }) + + controls := container.NewVBox( + container.NewGridWithColumns(2, applyBtn, reloadBtn), + ed.status, + widget.NewSeparator(), + widget.NewLabelWithStyle("Suggestions", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.NewVScroll(ed.suggestBox), + ) + side := container.NewBorder(nil, nil, nil, nil, controls) + + split := container.NewHSplit(display, side) + split.Offset = 0.72 + return split +} + +// reload reads the map from the binary into a fresh editable slice. +func (ed *pidEditor) reload() { + z, err := ed.bt.readSymbol(ed.symbolName) + if err != nil { + ed.data = make([]float64, ed.cols*ed.rows) + ed.status.SetText(err.Error()) + return + } + ed.data = z +} + +// rebuildViewer swaps a fresh editable map viewer (bound to ed.data) into host. +func (ed *pidEditor) rebuildViewer(host *fyne.Container) { + mv, err := mapviewer.New(&mapviewer.Config{ + Name: ed.symbolName, + XData: ed.axisX, + YData: ed.axisY, + ZData: ed.data, + XPrecision: 0, + YPrecision: 0, + ZPrecision: 0, + XLabel: "Load error (mg/c)", + YLabel: "Engine speed (rpm)", + ZLabel: ed.name + " constant", + MeshView: true, + MeshRenderer: ed.bt.cfg.MeshRenderer, + Editable: true, + ColorblindMode: ed.bt.cfg.Colorblind, + SaveECUFunc: func([]float64) {}, + OnUpdateCell: func(int, []float64) {}, + }) + if err != nil { + host.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel(err.Error()))} + host.Refresh() + return + } + ed.mv = mv + host.Objects = []fyne.CanvasObject{mv} + host.Refresh() +} + +// scaleRows multiplies whole rpm rows (indexed by PIDYSP) by per-row factors and +// refreshes the viewer. Used by the heuristic suggestions in pidtune.go. +func (ed *pidEditor) scaleRows(factors map[int]float64) { + for row, f := range factors { + if row < 0 || row >= ed.rows { + continue + } + for c := 0; c < ed.cols; c++ { + ed.data[row*ed.cols+c] *= f + } + } + if ed.mv != nil { + _ = ed.mv.SetZData(ed.data) + ed.mv.Refresh() + } + ed.status.SetText(fmt.Sprintf("Scaled %d row(s); review then Apply.", len(factors))) +} diff --git a/pkg/widgets/boosttuner/pidtune.go b/pkg/widgets/boosttuner/pidtune.go new file mode 100644 index 00000000..0828864b --- /dev/null +++ b/pkg/widgets/boosttuner/pidtune.go @@ -0,0 +1,299 @@ +package boosttuner + +import ( + "fmt" + "math" + "sort" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" +) + +// PID heuristics. The PID gain maps cannot be cleanly auto-learned from arbitrary +// logs, so instead we measure the *quality* of logged boost transients and +// suggest bounded per-rpm-band scalings of the maps. These are starting points to +// review and validate in the Simulator, never an automatic flash. +// +// Error convention: loadDiff = SetValue - airmass, so a positive error means we +// are under the request (still spooling) and a negative error means airmass has +// exceeded the request (overshoot). + +// transientCfg tunes the boost-onset detector. +type transientCfg struct { + startErr float64 // onset: error above this (mg/c) with the loop engaged + settleBand float64 // |error| within this counts as "on target" (mg/c) + minLen int // ignore events shorter than this many samples +} + +func defaultTransientCfg() transientCfg { + return transientCfg{startErr: 100, settleBand: 20, minLen: 5} +} + +// transient summarises one boost-onset event. +type transient struct { + rpm float64 // mean rpm over the event + overshoot float64 // max airmass-over-request after onset (mg/c, >=0) + crossings int // sign changes of the error (oscillation) + riseSamples int // samples from onset until first settle (or event length) + settled bool + ssError float64 // mean error in the settled tail (mg/c) +} + +// pidSuggestInputs holds the row-aligned channels the detector consumes. +type pidSuggestInputs struct { + n int + rpm, loadDiff, pwm2pct []float64 +} + +// detectTransients walks the log and extracts boost-onset events: a stretch where +// the loop is engaged and the error starts large-positive, tracked until the loop +// disengages or the log ends. +func detectTransients(in pidSuggestInputs, cfg transientCfg) []transient { + var out []transient + active := false + var onset, settleAt int + var sumRPM, maxNeg, prevSign float64 + var crossings, length int + + finalize := func(end int) { + if !active { + return + } + active = false + if length < cfg.minLen { + return + } + t := transient{ + rpm: sumRPM / float64(length), + overshoot: maxNeg, + crossings: crossings, + settled: settleAt >= 0, + } + if settleAt >= 0 { + t.riseSamples = settleAt - onset + // Steady-state error: mean over the settled tail. + var s float64 + var c int + for i := settleAt; i < end; i++ { + if !math.IsNaN(in.loadDiff[i]) { + s += in.loadDiff[i] + c++ + } + } + if c > 0 { + t.ssError = s / float64(c) + } + } else { + t.riseSamples = length + } + out = append(out, t) + } + + for i := 0; i < in.n; i++ { + e, r, p := in.loadDiff[i], in.rpm[i], in.pwm2pct[i] + if math.IsNaN(e) || math.IsNaN(r) || math.IsNaN(p) { + finalize(i) + continue + } + engaged := p == 0 + if !active { + if engaged && e > cfg.startErr { + active = true + onset, settleAt = i, -1 + sumRPM, maxNeg, crossings, length = 0, 0, 0, 0 + prevSign = 1 // onset error is positive + } else { + continue + } + } + if !engaged { + finalize(i) + continue + } + sumRPM += r + length++ + if sign := signOf(e); sign != 0 && sign != prevSign { + crossings++ + prevSign = sign + } + if e < 0 && -e > maxNeg { + maxNeg = -e + } + if settleAt < 0 && math.Abs(e) <= cfg.settleBand { + settleAt = i + } + } + finalize(in.n) + return out +} + +// suggestCfg tunes how metrics map to scaling factors. +type suggestCfg struct { + overshootHi float64 // mg/c above which we trim P & D + riseHi int // rise samples above which we add P + ssHi float64 // |steady error| above which we add I + trim float64 // factor applied when trimming (e.g. 0.85) + boost float64 // factor applied when adding (e.g. 1.15) +} + +func defaultSuggestCfg() suggestCfg { + return suggestCfg{overshootHi: 50, riseHi: 25, ssHi: 20, trim: 0.85, boost: 1.15} +} + +// bandSuggestion is a per-rpm-band scaling recommendation for the PID maps. +type bandSuggestion struct { + row int // PIDYSP index + rpm float64 // band breakpoint + factorP float64 + factorI float64 + factorD float64 + reason string + events int +} + +// factorFor returns the suggested factor for the named map ("P"/"I"/"D"). +func (b bandSuggestion) factorFor(name string) float64 { + switch name { + case "P": + return b.factorP + case "I": + return b.factorI + case "D": + return b.factorD + } + return 1 +} + +// suggestPID aggregates transients into per-rpm-band scaling factors. +func suggestPID(ts []transient, pidYSP []float64, cfg suggestCfg) []bandSuggestion { + type acc struct { + overshoot, rise, ss float64 + crossings, n int + } + bands := make(map[int]*acc) + for _, t := range ts { + row := nearestIndex(pidYSP, t.rpm) + a := bands[row] + if a == nil { + a = &acc{} + bands[row] = a + } + a.overshoot += t.overshoot + a.rise += float64(t.riseSamples) + a.ss += t.ssError + a.crossings += t.crossings + a.n++ + } + + var out []bandSuggestion + for row, a := range bands { + n := float64(a.n) + avgOver := a.overshoot / n + avgRise := a.rise / n + avgSS := a.ss / n + avgCross := float64(a.crossings) / n + + b := bandSuggestion{row: row, rpm: pidYSP[row], factorP: 1, factorI: 1, factorD: 1, events: a.n} + switch { + case avgOver > cfg.overshootHi || avgCross >= 2: + b.factorP = cfg.trim + b.factorD = cfg.trim + b.reason = fmt.Sprintf("overshoot %.0f mg/c, %.1f crossings", avgOver, avgCross) + case avgRise > float64(cfg.riseHi): + b.factorP = cfg.boost + b.reason = fmt.Sprintf("slow rise (%.0f samples)", avgRise) + } + if math.Abs(avgSS) > cfg.ssHi { + b.factorI = cfg.boost + if b.reason != "" { + b.reason += "; " + } + b.reason += fmt.Sprintf("steady error %.0f mg/c", avgSS) + } + if b.factorP != 1 || b.factorI != 1 || b.factorD != 1 { + out = append(out, b) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].rpm < out[j].rpm }) + return out +} + +func signOf(v float64) float64 { + switch { + case v > 0: + return 1 + case v < 0: + return -1 + default: + return 0 + } +} + +// --- UI wiring --- + +// computePIDSuggestions runs the transient analysis over the loaded logs and +// fills each editor's suggestion panel. Returns a user-facing status string. +func (bt *BoostTuner) computePIDSuggestions() string { + if len(bt.values) == 0 { + return "Load logs first (Logs tab)." + } + rpm, ok1 := bt.series("rpm") + loadDiff, ok2 := bt.series("loadDiff") + pwm2pct, ok3 := bt.series("pwm2pct") + if !ok1 || !ok2 || !ok3 { + return "Need rpm, LoadDiff and the 2% flag channels." + } + ts := detectTransients(pidSuggestInputs{ + n: bt.nrecords, rpm: rpm, loadDiff: loadDiff, pwm2pct: pwm2pct, + }, defaultTransientCfg()) + if len(ts) == 0 { + bt.renderSuggestions(nil) + return "No boost transients detected." + } + var ysp []float64 + if ed := bt.pidEditors["P"]; ed != nil { + ysp = ed.axisY + } + if len(ysp) == 0 { + return "PID maps/axes not found in this binary." + } + sugg := suggestPID(ts, ysp, defaultSuggestCfg()) + bt.renderSuggestions(sugg) + return fmt.Sprintf("%d transients, %d band suggestion(s).", len(ts), len(sugg)) +} + +// renderSuggestions populates every editor's suggestion box with the rows +// relevant to its map. +func (bt *BoostTuner) renderSuggestions(sugg []bandSuggestion) { + for name, ed := range bt.pidEditors { + ed.suggestBox.Objects = ed.suggestBox.Objects[:0] + var rows []bandSuggestion + for _, b := range sugg { + if b.factorFor(name) != 1 { + rows = append(rows, b) + } + } + if len(rows) == 0 { + ed.suggestBox.Add(widget.NewLabel("No suggestions for this map.")) + ed.suggestBox.Refresh() + continue + } + factors := map[int]float64{} + for _, b := range rows { + b := b + factors[b.row] = b.factorFor(name) + lbl := widget.NewLabel(fmt.Sprintf("%.0f rpm: ×%.2f (%s)", b.rpm, b.factorFor(name), b.reason)) + lbl.Wrapping = fyne.TextWrapWord + apply := widget.NewButton("Apply", func() { + ed.scaleRows(map[int]float64{b.row: b.factorFor(name)}) + }) + apply.Importance = widget.LowImportance + ed.suggestBox.Add(container.NewBorder(nil, nil, nil, apply, lbl)) + } + allBtn := widget.NewButton("Apply all", func() { ed.scaleRows(factors) }) + allBtn.Importance = widget.LowImportance + ed.suggestBox.Add(allBtn) + ed.suggestBox.Refresh() + } +} diff --git a/pkg/widgets/boosttuner/pidtune_test.go b/pkg/widgets/boosttuner/pidtune_test.go new file mode 100644 index 00000000..9bbfb1f5 --- /dev/null +++ b/pkg/widgets/boosttuner/pidtune_test.go @@ -0,0 +1,79 @@ +package boosttuner + +import "testing" + +// buildTrace turns a sequence of (rpm, loadDiff, pwm2pct) samples into inputs. +func buildTrace(samples [][3]float64) pidSuggestInputs { + in := pidSuggestInputs{n: len(samples)} + for _, s := range samples { + in.rpm = append(in.rpm, s[0]) + in.loadDiff = append(in.loadDiff, s[1]) + in.pwm2pct = append(in.pwm2pct, s[2]) + } + return in +} + +// TestDetectTransients_OvershootEvent feeds a spool-up that overshoots and +// oscillates, and checks the detector measures it. +func TestDetectTransients_OvershootEvent(t *testing.T) { + // error: large positive -> crosses 0 -> negative (overshoot) -> back up. + trace := [][3]float64{ + {3000, 150, 0}, // onset (err>startErr, engaged) + {3000, 90, 0}, + {3000, 30, 0}, + {3000, -40, 0}, // crossing 1, overshoot 40 + {3000, -60, 0}, // overshoot 60 + {3000, 10, 0}, // crossing 2, settled (|err|<=20) + {3000, 5, 0}, + {3000, 4, 0}, + } + ts := detectTransients(buildTrace(trace), defaultTransientCfg()) + if len(ts) != 1 { + t.Fatalf("got %d transients, want 1", len(ts)) + } + got := ts[0] + if got.overshoot < 59 || got.overshoot > 61 { + t.Errorf("overshoot = %v, want ~60", got.overshoot) + } + if got.crossings < 2 { + t.Errorf("crossings = %d, want >=2", got.crossings) + } + if !got.settled { + t.Errorf("expected event to settle") + } +} + +// TestSuggestPID_TrimsOnOvershoot checks an overshooting band yields a P/D trim. +func TestSuggestPID_TrimsOnOvershoot(t *testing.T) { + ysp := []float64{1000, 2000, 3000, 4000, 5000, 6000} + ts := []transient{ + {rpm: 3000, overshoot: 80, crossings: 3, riseSamples: 6, settled: true, ssError: 2}, + {rpm: 3100, overshoot: 70, crossings: 2, riseSamples: 5, settled: true, ssError: 1}, + } + sugg := suggestPID(ts, ysp, defaultSuggestCfg()) + if len(sugg) != 1 { + t.Fatalf("got %d suggestions, want 1", len(sugg)) + } + b := sugg[0] + if b.factorP >= 1 || b.factorD >= 1 { + t.Errorf("expected P/D trim (<1), got P=%.2f D=%.2f", b.factorP, b.factorD) + } + if b.factorI != 1 { + t.Errorf("expected no I change for small steady error, got %.2f", b.factorI) + } +} + +// TestSuggestPID_AddsIOnSteadyError checks a persistent steady error raises I. +func TestSuggestPID_AddsIOnSteadyError(t *testing.T) { + ysp := []float64{1000, 2000, 3000, 4000} + ts := []transient{ + {rpm: 2000, overshoot: 5, crossings: 0, riseSamples: 8, settled: true, ssError: 40}, + } + sugg := suggestPID(ts, ysp, defaultSuggestCfg()) + if len(sugg) != 1 { + t.Fatalf("got %d suggestions, want 1", len(sugg)) + } + if sugg[0].factorI <= 1 { + t.Errorf("expected I boost (>1), got %.2f", sugg[0].factorI) + } +} diff --git a/pkg/widgets/boosttuner/regmap.go b/pkg/widgets/boosttuner/regmap.go new file mode 100644 index 00000000..e17afbaf --- /dev/null +++ b/pkg/widgets/boosttuner/regmap.go @@ -0,0 +1,375 @@ +package boosttuner + +import ( + "fmt" + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/layout" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" +) + +// RegMap symbol and its axes in the binary. RegMap is row-major [rpm][load]: +// rows follow n_EngSP, columns follow SetLoadXSP. +const ( + symRegMap = "BoostCal.RegMap" + symSetLoadXSP = "BoostCal.SetLoadXSP" // X axis: load set value (mg/c) + symNEngSP = "BoostCal.n_EngSP" // Y axis: engine speed (rpm) +) + +// regMapView lists the selectable views for the learned map. +const ( + viewCurrent = "Current" + viewLearned = "Learned (what gets written)" + viewDelta = "Delta" + viewCoverage = "Coverage (samples)" +) + +func (bt *BoostTuner) buildRegMapTab() fyne.CanvasObject { + bt.rmStatus = widget.NewLabel("Load logs, then click Analyze.") + bt.rmStatus.Wrapping = fyne.TextWrapWord + + analyzeBtn := widget.NewButtonWithIcon("Analyze", theme.SearchIcon(), func() { + if err := bt.analyzeRegMap(); err != nil { + bt.rmStatus.SetText(err.Error()) + return + } + bt.refreshRegMapDisplay() + }) + analyzeBtn.Importance = widget.HighImportance + + bt.rmView = widget.NewSelect( + []string{viewCurrent, viewLearned, viewDelta, viewCoverage}, + func(string) { bt.refreshRegMapDisplay() }, + ) + bt.rmView.SetSelected(viewLearned) + + applyBtn := widget.NewButtonWithIcon("Apply to binary", theme.DocumentSaveIcon(), bt.applyRegMap) + if bt.cfg.Save == nil { + applyBtn.Disable() + } + + controls := container.NewVBox( + analyzeBtn, + labeledRow("View", bt.rmView), + widget.NewSeparator(), + widget.NewLabelWithStyle("Sample filter", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + bt.slider("On-target |error| (mg/c)", 5, 200, 5, bt.onTarget, func(v float64) { bt.onTarget = v }), + bt.slider("Max rpm step (rpm)", 20, 500, 10, bt.rpmStab, func(v float64) { bt.rpmStab = v }), + bt.slider("Max load step (mg/c)", 10, 300, 10, bt.loadStab, func(v float64) { bt.loadStab = v }), + widget.NewSeparator(), + widget.NewLabelWithStyle("Apply", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + bt.slider("Min samples / cell", 1, 50, 1, float64(bt.minSamples), func(v float64) { bt.minSamples = int(v); bt.refreshRegMapDisplay() }), + bt.slider("Blend %", 0, 100, 5, bt.blend*100, func(v float64) { bt.blend = v / 100; bt.refreshRegMapDisplay() }), + applyBtn, + widget.NewSeparator(), + bt.rmStatus, + ) + + bt.rmDisplay = container.NewStack(container.NewCenter( + widget.NewLabel("Learn BoostCal.RegMap from the loaded logs."), + )) + + controlScroll := container.NewVScroll(controls) + controlScroll.SetMinSize(fyne.NewSize(250, 0)) + split := container.NewHSplit(controlScroll, bt.rmDisplay) + split.Offset = 0.28 + return split +} + +// slider builds a labelled slider with a live value readout that calls onChange. +func (bt *BoostTuner) slider(label string, min, max, step, init float64, onChange func(float64)) fyne.CanvasObject { + val := widget.NewLabel(formatFloat(init)) + s := widget.NewSlider(min, max) + s.Step = step + s.SetValue(init) + s.OnChanged = func(v float64) { + val.SetText(formatFloat(v)) + onChange(v) + } + return container.NewVBox( + widget.NewLabel(label), + container.NewBorder(nil, nil, nil, layout.NewFixedWidth(48, val), s), + ) +} + +// analyzeRegMap reads the current RegMap and its axes from the binary, then learns +// a new map from the logs by folding the loop's corrections back into the +// feedforward. See the package doc for the control-law rationale. +func (bt *BoostTuner) analyzeRegMap() error { + if len(bt.values) == 0 { + return fmt.Errorf("load a log file first (Logs tab)") + } + if missing := bt.missingChannels(); len(missing) > 0 { + return fmt.Errorf("missing channels: %v", missing) + } + if err := bt.loadRegMapFromBinary(); err != nil { + return err + } + + in := regMapInputs{ + n: bt.nrecords, + rpm: mustSeries(bt.series("rpm")), + setv: mustSeries(bt.series("setValue")), + regCon: mustSeries(bt.series("regCon")), + pFac: mustSeries(bt.series("pFac")), + iFac: mustSeries(bt.series("iFac")), + dFac: mustSeries(bt.series("dFac")), + adap: mustSeries(bt.series("adaption")), + loadDiff: mustSeries(bt.series("loadDiff")), + pwmCalc: mustSeries(bt.series("pwmCalc")), + pwm2pct: mustSeries(bt.series("pwm2pct")), + } + p := regMapParams{ + axisX: bt.rmAxisX, axisY: bt.rmAxisY, + cols: bt.rmCols, rows: bt.rmRows, + current: bt.rmCurrent, + onTarget: bt.onTarget, rpmStab: bt.rpmStab, loadStab: bt.loadStab, + } + + learned, cnt, used, filtered := learnRegMap(in, p) + bt.rmLearned, bt.rmCounts = learned, cnt + filled := 0 + for _, c := range cnt { + if c > 0 { + filled++ + } + } + bt.rmBuilt = true + bt.rmStatus.SetText(fmt.Sprintf("Used %d samples (%d filtered). %d/%d cells have data.", + used, filtered, filled, bt.rmCols*bt.rmRows)) + return nil +} + +// regMapInputs holds the row-aligned log channels the learner consumes. All +// slices have length n (NaN where a sample is missing). +type regMapInputs struct { + n int + rpm, setv, regCon, pFac, iFac, dFac, adap, loadDiff, pwmCalc, pwm2pct []float64 +} + +// regMapParams holds the map geometry (from the binary) and the sample filter. +type regMapParams struct { + axisX, axisY []float64 + cols, rows int + current []float64 + onTarget, rpmStab, loadStab float64 +} + +// learnRegMap folds the loop's corrections back into the feedforward map. For +// each accepted sample it adds RegConValue+PFac+IFac+DFac+Adaption (converted to +// %) to the cell its (load, rpm) lands on, then averages per cell. Empty cells +// return the current value so they contribute a zero delta. Pure: no UI/state. +func learnRegMap(in regMapInputs, p regMapParams) (learned []float64, counts []int, used, filtered int) { + size := p.cols * p.rows + sum := make([]float64, size) + counts = make([]int, size) + + prevRPM, prevLoad := math.NaN(), math.NaN() + for i := 0; i < in.n; i++ { + r, s := in.rpm[i], in.setv[i] + if anyNaN(r, s, in.regCon[i], in.pFac[i], in.iFac[i], in.dFac[i], in.adap[i], in.loadDiff[i], in.pwmCalc[i], in.pwm2pct[i]) { + prevRPM, prevLoad = r, s + continue + } + ok := acceptSample(p, r, s, in.loadDiff[i], in.pwmCalc[i], in.pwm2pct[i], prevRPM, prevLoad) + prevRPM, prevLoad = r, s + if !ok { + filtered++ + continue + } + target := (in.regCon[i] + in.pFac[i] + in.iFac[i] + in.dFac[i] + in.adap[i]) / dutyRawPerPct + c := nearestIndex(p.axisX, s) + row := nearestIndex(p.axisY, r) + idx := row*p.cols + c + sum[idx] += target + counts[idx]++ + used++ + } + + learned = make([]float64, size) + for i := range sum { + if counts[i] > 0 { + learned[i] = sum[i] / float64(counts[i]) + } else { + learned[i] = p.current[i] + } + } + return learned, counts, used, filtered +} + +// acceptSample reports whether a sample is trustworthy for learning the +// feedforward: on target, steady (not mid-transient), in closed loop, and not at +// the duty rails. +func acceptSample(p regMapParams, rpm, load, loadDiff, pwmCalc, pwm2pct, prevRPM, prevLoad float64) bool { + if math.Abs(loadDiff) > p.onTarget { + return false + } + if pwm2pct != 0 { // open-loop / forced 2% + return false + } + if pwmCalc <= 20 || pwmCalc >= 980 { // clamped at a rail + return false + } + if !math.IsNaN(prevRPM) && math.Abs(rpm-prevRPM) > p.rpmStab { + return false + } + if !math.IsNaN(prevLoad) && math.Abs(load-prevLoad) > p.loadStab { + return false + } + return true +} + +// mustSeries unwraps a resolved channel; analyzeRegMap guarantees presence via +// missingChannels before calling, so the bool is discarded here. +func mustSeries(s []float64, _ bool) []float64 { return s } + +// loadRegMapFromBinary reads BoostCal.RegMap and its axis breakpoints from the +// loaded binary into the tuner's state. +func (bt *BoostTuner) loadRegMapFromBinary() error { + if bt.cfg.Symbols == nil { + return fmt.Errorf("no binary loaded") + } + x, err := bt.readSymbol(symSetLoadXSP) + if err != nil { + return err + } + y, err := bt.readSymbol(symNEngSP) + if err != nil { + return err + } + z, err := bt.readSymbol(symRegMap) + if err != nil { + return err + } + if len(x)*len(y) != len(z) { + return fmt.Errorf("RegMap size %d != %d x %d axes", len(z), len(x), len(y)) + } + bt.rmAxisX, bt.rmAxisY = x, y + bt.rmCols, bt.rmRows = len(x), len(y) + bt.rmCurrent = z + return nil +} + +// readSymbol returns a symbol's values in engineering units. +func (bt *BoostTuner) readSymbol(name string) ([]float64, error) { + if bt.cfg.Symbols == nil { + return nil, fmt.Errorf("no binary loaded") + } + s := bt.cfg.Symbols.GetByName(name) + if s == nil { + return nil, fmt.Errorf("symbol %s not found in binary", name) + } + return s.Float64s(), nil +} + +// writeTarget returns the map that "Apply" would write: filled cells blended +// toward the learned value, everything else left at its current value. +func (bt *BoostTuner) writeTarget() []float64 { + out := make([]float64, len(bt.rmCurrent)) + copy(out, bt.rmCurrent) + for i := range out { + if bt.rmCounts[i] >= bt.minSamples { + out[i] = bt.rmCurrent[i] + bt.blend*(bt.rmLearned[i]-bt.rmCurrent[i]) + } + } + return out +} + +func (bt *BoostTuner) applyRegMap() { + if !bt.rmBuilt { + bt.rmStatus.SetText("Analyze first.") + return + } + if bt.cfg.Save == nil { + bt.rmStatus.SetText("No binary to write to.") + return + } + data := bt.writeTarget() + changed := 0 + for i := range data { + if data[i] != bt.rmCurrent[i] { + changed++ + } + } + if err := bt.cfg.Save(symRegMap, data); err != nil { + bt.rmStatus.SetText("Save failed: " + err.Error()) + return + } + bt.rmCurrent = data // the binary now holds these values + bt.rmStatus.SetText(fmt.Sprintf("Wrote %s: %d cells changed.", symRegMap, changed)) + bt.refreshRegMapDisplay() +} + +// refreshRegMapDisplay rebuilds the map viewer for the selected view. +func (bt *BoostTuner) refreshRegMapDisplay() { + if !bt.rmBuilt { + return + } + var z []float64 + var label string + zPrec := 1 + switch bt.rmView.Selected { + case viewCurrent: + z, label = bt.rmCurrent, "Current duty %" + case viewDelta: + target := bt.writeTarget() + z = make([]float64, len(target)) + for i := range z { + z[i] = target[i] - bt.rmCurrent[i] + } + label = "Delta duty %" + case viewCoverage: + z = make([]float64, len(bt.rmCounts)) + for i := range z { + z[i] = float64(bt.rmCounts[i]) + } + label, zPrec = "Samples", 0 + default: // viewLearned + z, label = bt.writeTarget(), "Learned duty %" + } + + mv, err := mapviewer.New(&mapviewer.Config{ + Name: symRegMap, + XData: bt.rmAxisX, + YData: bt.rmAxisY, + ZData: z, + XPrecision: 0, + YPrecision: 0, + ZPrecision: zPrec, + XLabel: "Load set value (mg/c)", + YLabel: "Engine speed (rpm)", + ZLabel: label, + MeshView: true, + MeshRenderer: bt.cfg.MeshRenderer, + Editable: false, + ColorblindMode: bt.cfg.Colorblind, + SaveECUFunc: func([]float64) {}, + OnUpdateCell: func(int, []float64) {}, + }) + if err != nil { + bt.rmDisplay.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel(err.Error()))} + bt.rmDisplay.Refresh() + return + } + bt.rmDisplay.Objects = []fyne.CanvasObject{mv} + bt.rmDisplay.Refresh() +} + +// --- small helpers --- + +func labeledRow(label string, obj fyne.CanvasObject) fyne.CanvasObject { + return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj) +} + +func anyNaN(vals ...float64) bool { + for _, v := range vals { + if math.IsNaN(v) { + return true + } + } + return false +} diff --git a/pkg/widgets/boosttuner/regmap_test.go b/pkg/widgets/boosttuner/regmap_test.go new file mode 100644 index 00000000..c0065edf --- /dev/null +++ b/pkg/widgets/boosttuner/regmap_test.go @@ -0,0 +1,83 @@ +package boosttuner + +import ( + "math" + "testing" +) + +// TestLearnRegMap_FoldsCorrectionsToPercent checks the core learning behaviour: +// the learned cell value is (RegConValue + P + I + D + Adaption) averaged and +// converted from raw 0.1% units to %, and that the sample filter rejects +// off-target, clamped, open-loop and transient samples. +func TestLearnRegMap_FoldsCorrectionsToPercent(t *testing.T) { + axisX := []float64{800, 900, 1000} // load + axisY := []float64{1000, 2000} // rpm + cols, rows := len(axisX), len(axisY) + current := make([]float64, cols*rows) // all zero + + // Helper to append one sample to every channel. + var in regMapInputs + add := func(rpm, load, regCon, p, i, d, adap, loadDiff, pwm, twopct float64) { + in.rpm = append(in.rpm, rpm) + in.setv = append(in.setv, load) + in.regCon = append(in.regCon, regCon) + in.pFac = append(in.pFac, p) + in.iFac = append(in.iFac, i) + in.dFac = append(in.dFac, d) + in.adap = append(in.adap, adap) + in.loadDiff = append(in.loadDiff, loadDiff) + in.pwmCalc = append(in.pwmCalc, pwm) + in.pwm2pct = append(in.pwm2pct, twopct) + } + + // Two good samples at cell (load=900 -> col1, rpm=2000 -> row1). + // raw sum = 400 + 30 + 20 + 0 + 50 = 500 -> 50.0% ; pwmCalc 500 (in band). + add(2000, 900, 400, 30, 20, 0, 50, 5, 500, 0) + add(2000, 905, 400, 30, 20, 0, 50, -5, 500, 0) // steady (small steps) + + // Rejected: off target (loadDiff beyond onTarget). + add(2000, 900, 400, 30, 20, 0, 50, 500, 500, 0) + // Rejected: open-loop flag set. + add(2000, 900, 400, 30, 20, 0, 50, 5, 500, 1) + // Rejected: clamped at the upper rail. + add(2000, 900, 400, 30, 20, 0, 50, 5, 980, 0) + in.n = len(in.rpm) + + p := regMapParams{ + axisX: axisX, axisY: axisY, cols: cols, rows: rows, current: current, + onTarget: 30, rpmStab: 150, loadStab: 50, + } + learned, counts, used, filtered := learnRegMap(in, p) + + if used != 2 { + t.Fatalf("used = %d, want 2", used) + } + if filtered != 3 { + t.Fatalf("filtered = %d, want 3", filtered) + } + cell := 1*cols + 1 // row1 (rpm 2000), col1 (load 900) + if counts[cell] != 2 { + t.Fatalf("counts[cell] = %d, want 2", counts[cell]) + } + if math.Abs(learned[cell]-50.0) > 1e-9 { + t.Fatalf("learned[cell] = %v, want 50.0", learned[cell]) + } + // Untouched cells fall back to current (0 here). + if learned[0] != current[0] { + t.Fatalf("empty cell learned = %v, want current %v", learned[0], current[0]) + } +} + +// TestAcceptSample_Steadiness verifies the transient gate uses the previous +// sample's rpm/load steps. +func TestAcceptSample_Steadiness(t *testing.T) { + p := regMapParams{onTarget: 30, rpmStab: 150, loadStab: 50} + // Big rpm jump from the previous sample -> rejected. + if acceptSample(p, 3000, 900, 0, 500, 0, 2000, 900) { + t.Fatal("expected rejection on large rpm step") + } + // First sample (prev = NaN) with everything in band -> accepted. + if !acceptSample(p, 3000, 900, 0, 500, 0, math.NaN(), math.NaN()) { + t.Fatal("expected acceptance for first in-band sample") + } +} diff --git a/pkg/widgets/boosttuner/sim.go b/pkg/widgets/boosttuner/sim.go new file mode 100644 index 00000000..cc00ab91 --- /dev/null +++ b/pkg/widgets/boosttuner/sim.go @@ -0,0 +1,278 @@ +package boosttuner + +import ( + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/widgets/plotter" +) + +// The replay simulator re-runs the ECU boost control law (from Boost.c) over a +// logged session using the *current* maps, so the effect of map edits on the +// controller's duty output can be inspected before flashing. +// +// IMPORTANT limitation: it drives the law with the *logged* error (LoadDiff), +// because predicting the resulting airmass would need a turbo/engine plant model +// we do not have. So it shows how the control effort (PWM and the P/I/D split) +// would differ for the same measured error — useful for spotting instability or +// I-windup — but it does NOT predict the resulting boost. + +// bracket returns the two indices of an ascending table that surround v and the +// interpolation fraction between them, clamping to the ends (so values outside +// the table hold the edge value, matching the ECU's TAB/MAT routines). +func bracket(tab []float64, v float64) (i0, i1 int, frac float64) { + n := len(tab) + if n <= 1 { + return 0, 0, 0 + } + if v <= tab[0] { + return 0, 0, 0 + } + if v >= tab[n-1] { + return n - 1, n - 1, 0 + } + for i := 0; i < n-1; i++ { + if v >= tab[i] && v <= tab[i+1] { + span := tab[i+1] - tab[i] + if span == 0 { + return i, i, 0 + } + return i, i + 1, (v - tab[i]) / span + } + } + return n - 1, n - 1, 0 +} + +// bilerp bilinearly interpolates a row-major map z (rows follow ytab, cols follow +// xtab) at (x, y), clamping outside the axis ranges. This is the Go stand-in for +// the ECU's MATs16 routines (faithful, not bit-exact). +func bilerp(xtab, ytab, z []float64, x, y float64) float64 { + cols := len(xtab) + x0, x1, xf := bracket(xtab, x) + y0, y1, yf := bracket(ytab, y) + z00 := z[y0*cols+x0] + z10 := z[y0*cols+x1] + z01 := z[y1*cols+x0] + z11 := z[y1*cols+x1] + top := z00 + (z10-z00)*xf + bot := z01 + (z11-z01)*xf + return top + (bot-top)*yf +} + +// simInputs holds the row-aligned log channels the replay consumes (all length n, +// raw units: duty terms in 0.1%, errors in mg/c). +type simInputs struct { + n int + rpm, setv, loadDiff, regCon, pFac, iFac, dFac, adap, pwmCalc, pwm2pct []float64 +} + +// simMaps holds the maps and constants the law uses. regMapRaw is in raw 0.1% +// units (the % map symbol scaled by dutyRawPerPct). +type simMaps struct { + setLoadXSP, nEngSP, regMapRaw []float64 + pidXSP, pidYSP []float64 + pMap, iMap, dMap []float64 + iFacMax, filterFactor float64 +} + +// simOutput is the replay result, all in raw 0.1% duty units. +type simOutput struct { + predicted, regCon, pFac, iFac, dFac []float64 +} + +// simulate replays the control law. It recomputes RegConValue and the P/I/D terms +// from the maps while carrying the logged environment residual (E85/altitude/temp/ +// noise + any rounding) unchanged, so only map-driven differences show. +func simulate(in simInputs, m simMaps) simOutput { + out := simOutput{ + predicted: make([]float64, in.n), + regCon: make([]float64, in.n), + pFac: make([]float64, in.n), + iFac: make([]float64, in.n), + dFac: make([]float64, in.n), + } + var iBuff, iFacAcc, dFacState, loadDiffOld float64 + haveOld := false + + for i := 0; i < in.n; i++ { + if anyNaN(in.rpm[i], in.setv[i], in.loadDiff[i], in.regCon[i], in.pFac[i], in.iFac[i], in.dFac[i], in.adap[i], in.pwmCalc[i], in.pwm2pct[i]) { + out.predicted[i], out.regCon[i] = math.NaN(), math.NaN() + out.pFac[i], out.iFac[i], out.dFac[i] = math.NaN(), math.NaN(), math.NaN() + haveOld = false + continue + } + // Residual = everything in the logged PWMCalc the maps don't drive. + env := in.pwmCalc[i] - (in.regCon[i] + in.pFac[i] + in.iFac[i] + in.dFac[i] + in.adap[i]) + regConSim := bilerp(m.setLoadXSP, m.nEngSP, m.regMapRaw, in.setv[i], in.rpm[i]) + out.regCon[i] = regConSim + + if in.pwm2pct[i] != 0 { // open loop: PID ramped off + iBuff, iFacAcc, dFacState = 0, 0, 0 + haveOld = false + out.predicted[i] = regConSim + in.adap[i] + env + continue + } + + ld := in.loadDiff[i] + pConst := bilerp(m.pidXSP, m.pidYSP, m.pMap, ld, in.rpm[i]) + iConst := bilerp(m.pidXSP, m.pidYSP, m.iMap, ld, in.rpm[i]) + dConst := bilerp(m.pidXSP, m.pidYSP, m.dMap, ld, in.rpm[i]) + + pFac := ld * pConst / 100 + iBuff += iConst * ld + if iBuff > 1000 { + iFacAcc += iBuff / 1000 + iBuff = 0 + if iFacAcc > m.iFacMax { + iFacAcc = m.iFacMax + } + } else if iBuff < -1000 { + iFacAcc += iBuff / 1000 + iBuff = 0 + if iFacAcc < -m.iFacMax { + iFacAcc = -m.iFacMax + } + } + if !haveOld { + loadDiffOld = ld + haveOld = true + } + dFacState = ((ld-loadDiffOld)*dConst + dFacState*m.filterFactor) / (20 + m.filterFactor) + loadDiffOld = ld + + out.pFac[i], out.iFac[i], out.dFac[i] = pFac, iFacAcc, dFacState + out.predicted[i] = regConSim + pFac + iFacAcc + dFacState + in.adap[i] + env + } + return out +} + +// --- UI --- + +func (bt *BoostTuner) buildSimTab() fyne.CanvasObject { + status := widget.NewLabel("") + status.Wrapping = fyne.TextWrapWord + + intro := widget.NewLabel( + "Replays the ECU boost control law over the loaded logs using the current " + + "maps (RegMap from the binary; P/I/D from the editors). It predicts the " + + "controller's duty output for the logged error — it does NOT predict the " + + "resulting boost (no engine/turbo model). Use it to check edits don't make " + + "the duty oscillate or wind up.") + intro.Wrapping = fyne.TextWrapWord + + display := container.NewStack(container.NewCenter( + widget.NewLabel("Load logs, then click Simulate."), + )) + + simBtn := widget.NewButtonWithIcon("Simulate", theme.MediaPlayIcon(), func() { + values, err := bt.runSimulation() + if err != nil { + status.SetText(err.Error()) + return + } + order := []string{"PWMCalc logged", "PWMCalc predicted", "P (sim)", "I (sim)", "D (sim)"} + p := plotter.NewPlotter(values, plotter.WithOrder(order)) + display.Objects = []fyne.CanvasObject{p} + display.Refresh() + status.SetText("Simulated. Toggle series in the legend.") + }) + + header := container.NewVBox(intro, container.NewBorder(nil, nil, nil, status, simBtn)) + return container.NewBorder(header, nil, nil, nil, display) +} + +// runSimulation gathers inputs and current maps, runs the replay and returns the +// named series for the plotter. +func (bt *BoostTuner) runSimulation() (map[string][]float64, error) { + if len(bt.values) == 0 { + return nil, errString("load logs first (Logs tab)") + } + if missing := bt.missingChannels(); len(missing) > 0 { + return nil, errString("missing channels for simulation") + } + in := simInputs{ + n: bt.nrecords, + rpm: mustSeries(bt.series("rpm")), + setv: mustSeries(bt.series("setValue")), + loadDiff: mustSeries(bt.series("loadDiff")), + regCon: mustSeries(bt.series("regCon")), + pFac: mustSeries(bt.series("pFac")), + iFac: mustSeries(bt.series("iFac")), + dFac: mustSeries(bt.series("dFac")), + adap: mustSeries(bt.series("adaption")), + pwmCalc: mustSeries(bt.series("pwmCalc")), + pwm2pct: mustSeries(bt.series("pwm2pct")), + } + + m, err := bt.loadSimMaps() + if err != nil { + return nil, err + } + out := simulate(in, m) + + return map[string][]float64{ + "PWMCalc logged": in.pwmCalc, + "PWMCalc predicted": out.predicted, + "P (sim)": out.pFac, + "I (sim)": out.iFac, + "D (sim)": out.dFac, + }, nil +} + +// loadSimMaps reads the maps and constants for the replay: RegMap and its axes +// from the binary, P/I/D from the editors (so edits/suggestions are reflected), +// IFacMax and FilterFactor from the binary. +func (bt *BoostTuner) loadSimMaps() (simMaps, error) { + var m simMaps + var err error + if m.setLoadXSP, err = bt.readSymbol(symSetLoadXSP); err != nil { + return m, err + } + if m.nEngSP, err = bt.readSymbol(symNEngSP); err != nil { + return m, err + } + regPct, err := bt.readSymbol(symRegMap) + if err != nil { + return m, err + } + m.regMapRaw = make([]float64, len(regPct)) + for i, v := range regPct { + m.regMapRaw[i] = v * dutyRawPerPct // % -> raw 0.1% + } + if m.pidXSP, err = bt.readSymbol(symPIDXSP); err != nil { + return m, err + } + if m.pidYSP, err = bt.readSymbol(symPIDYSP); err != nil { + return m, err + } + m.pMap = bt.pidMapData("P", symPMap) + m.iMap = bt.pidMapData("I", symIMap) + m.dMap = bt.pidMapData("D", symDMap) + + if v, err := bt.readSymbol("BoostCal.IFacMax"); err == nil && len(v) > 0 { + m.iFacMax = v[0] + } else { + m.iFacMax = 350 + } + if v, err := bt.readSymbol("BoostCal.FilterFactor"); err == nil && len(v) > 0 { + m.filterFactor = v[0] + } + return m, nil +} + +// pidMapData returns the editor's live (possibly edited) map data, falling back +// to the binary if the editor is absent. +func (bt *BoostTuner) pidMapData(name, symName string) []float64 { + if ed := bt.pidEditors[name]; ed != nil && len(ed.data) > 0 { + return ed.data + } + v, _ := bt.readSymbol(symName) + return v +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/pkg/widgets/boosttuner/sim_test.go b/pkg/widgets/boosttuner/sim_test.go new file mode 100644 index 00000000..91339dfd --- /dev/null +++ b/pkg/widgets/boosttuner/sim_test.go @@ -0,0 +1,74 @@ +package boosttuner + +import ( + "math" + "testing" +) + +func TestBilerp_CornersAndCenter(t *testing.T) { + xtab := []float64{0, 1} + ytab := []float64{0, 1} + z := []float64{0, 10, 20, 30} // [y][x]: (0,0)=0 (1,0)=10 (0,1)=20 (1,1)=30 + + cases := []struct { + x, y, want float64 + }{ + {0, 0, 0}, {1, 0, 10}, {0, 1, 20}, {1, 1, 30}, + {0.5, 0, 5}, {0, 0.5, 10}, {0.5, 0.5, 15}, + {-5, -5, 0}, // clamp low + {99, 99, 30}, // clamp high + } + for _, c := range cases { + if got := bilerp(xtab, ytab, z, c.x, c.y); math.Abs(got-c.want) > 1e-9 { + t.Errorf("bilerp(%v,%v) = %v, want %v", c.x, c.y, got, c.want) + } + } +} + +// TestSimulate_OpenLoopIdentity: with the loop disengaged and unchanged RegMap, +// the predicted PWM equals the logged PWMCalc (the residual carries everything). +func TestSimulate_OpenLoopIdentity(t *testing.T) { + m := simMaps{ + setLoadXSP: []float64{1000}, nEngSP: []float64{3000}, + regMapRaw: []float64{450}, // bilerp -> 450, matching logged regCon + pidXSP: []float64{0}, pidYSP: []float64{3000}, + pMap: []float64{0}, iMap: []float64{0}, dMap: []float64{0}, + iFacMax: 350, + } + in := simInputs{ + n: 1, rpm: []float64{3000}, setv: []float64{1000}, loadDiff: []float64{0}, + regCon: []float64{450}, pFac: []float64{0}, iFac: []float64{0}, dFac: []float64{0}, + adap: []float64{30}, pwmCalc: []float64{520}, pwm2pct: []float64{1}, // open loop + } + out := simulate(in, m) + if math.Abs(out.predicted[0]-520) > 1e-9 { + t.Fatalf("predicted = %v, want 520 (logged)", out.predicted[0]) + } +} + +// TestSimulate_ClosedLoopRecomputesP checks the P term is recomputed from the map +// and the environment residual is preserved. +func TestSimulate_ClosedLoopRecomputesP(t *testing.T) { + m := simMaps{ + setLoadXSP: []float64{1000}, nEngSP: []float64{3000}, + regMapRaw: []float64{450}, + pidXSP: []float64{0}, pidYSP: []float64{3000}, + pMap: []float64{100}, iMap: []float64{0}, dMap: []float64{0}, // P const 100 + iFacMax: 350, + } + // Logged decomposition: 450+5+20+0+30 = 505 == pwmCalc, so env residual = 0. + in := simInputs{ + n: 1, rpm: []float64{3000}, setv: []float64{1000}, loadDiff: []float64{10}, + regCon: []float64{450}, pFac: []float64{5}, iFac: []float64{20}, dFac: []float64{0}, + adap: []float64{30}, pwmCalc: []float64{505}, pwm2pct: []float64{0}, + } + out := simulate(in, m) + // P_sim = loadDiff*Pconst/100 = 10*100/100 = 10; I=0; D=0. + // predicted = 450 + 10 + 0 + 0 + 30 + env(0) = 490. + if math.Abs(out.pFac[0]-10) > 1e-9 { + t.Errorf("P_sim = %v, want 10", out.pFac[0]) + } + if math.Abs(out.predicted[0]-490) > 1e-9 { + t.Errorf("predicted = %v, want 490", out.predicted[0]) + } +} diff --git a/pkg/widgets/canflasher/candump.go b/pkg/widgets/canflasher/candump.go index e0cdc92e..6032a5eb 100644 --- a/pkg/widgets/canflasher/candump.go +++ b/pkg/widgets/canflasher/candump.go @@ -3,7 +3,6 @@ package canflasher import ( "context" "fmt" - "log" "os" "time" @@ -32,33 +31,18 @@ func (t *CanFlasherWidget) ecuDump(filename string) { filename = addSuffix(filename, ".bin") t.progressBar.SetValue(0) - done := make(chan struct{}) - go func() { - for { - select { - case err := <-dev.Err(): - if err == nil { - return - } - log.Println("Error:", err) - case <-done: - return - } - } - }() - - go func() { - defer close(done) ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Second) defer cancel() - //defer dev.Close() + // defer dev.Close() fyne.Do(t.Disable) defer fyne.Do(t.Enable) - c, err := gocan.NewWithOpts(ctx, dev) + c, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + t.log(e.String()) + })) if err != nil { t.logValues.Append(err.Error()) return @@ -86,7 +70,7 @@ func (t *CanFlasherWidget) ecuDump(filename string) { return } - if err := os.WriteFile(filename, bin, 0644); err == nil { + if err := os.WriteFile(filename, bin, 0o644); err == nil { t.log("Saved as " + filename) } else { t.log(err.Error()) @@ -97,8 +81,6 @@ func (t *CanFlasherWidget) ecuDump(filename string) { time.Sleep(200 * time.Millisecond) - if err := tr.ResetECU(ctx); err != nil { - t.log(err.Error()) - } + _ = tr.ResetECU(ctx) }() } diff --git a/pkg/widgets/canflasher/canflash.go b/pkg/widgets/canflasher/canflash.go index 13368f17..f070d7f5 100644 --- a/pkg/widgets/canflasher/canflash.go +++ b/pkg/widgets/canflasher/canflash.go @@ -3,7 +3,6 @@ package canflasher import ( "context" "fmt" - "log" "os" "time" @@ -37,21 +36,7 @@ func (t *CanFlasherWidget) ecuFlash(filename string) { t.progressBar.SetValue(0) - done := make(chan struct{}) - - go func() { - for { - select { - case err := <-dev.Err(): - log.Println("Error:", err) - case <-done: - return - } - } - }() - go func() { - defer close(done) ctx, cancel := context.WithTimeout(context.Background(), 1800*time.Second) defer cancel() @@ -60,7 +45,9 @@ func (t *CanFlasherWidget) ecuFlash(filename string) { fyne.Do(t.Disable) defer fyne.Do(t.Enable) - c, err := gocan.NewWithOpts(ctx, dev) + c, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + t.log(e.String()) + })) if err != nil { t.logValues.Append(err.Error()) return diff --git a/pkg/widgets/canflasher/caninfo.go b/pkg/widgets/canflasher/caninfo.go index b5dcbdb7..05562e3a 100644 --- a/pkg/widgets/canflasher/caninfo.go +++ b/pkg/widgets/canflasher/caninfo.go @@ -21,7 +21,6 @@ func (t *CanFlasherWidget) ecuInfo() { } go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/pkg/widgets/canflasher/canrecovery.go b/pkg/widgets/canflasher/canrecovery.go index 6b0f4a0d..1c9d23ec 100644 --- a/pkg/widgets/canflasher/canrecovery.go +++ b/pkg/widgets/canflasher/canrecovery.go @@ -3,7 +3,6 @@ package canflasher import ( "context" "fmt" - "log" "os" "time" @@ -28,21 +27,7 @@ func (t *CanFlasherWidget) ecuRecover(filename string) { t.progressBar.SetValue(0) - done := make(chan struct{}) - - go func() { - for { - select { - case err := <-dev.Err(): - log.Println("Error:", err) - case <-done: - return - } - } - }() - go func() { - defer close(done) ctx, cancel := context.WithTimeout(context.Background(), 1800*time.Second) defer cancel() @@ -51,7 +36,9 @@ func (t *CanFlasherWidget) ecuRecover(filename string) { fyne.Do(t.Disable) defer fyne.Do(t.Enable) - c, err := gocan.NewWithOpts(ctx, dev) + c, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + t.log(e.String()) + })) if err != nil { t.logValues.Append(err.Error()) return diff --git a/pkg/widgets/cbar/cbar.go b/pkg/widgets/cbar/cbar.go index b51acb9b..31958ba2 100644 --- a/pkg/widgets/cbar/cbar.go +++ b/pkg/widgets/cbar/cbar.go @@ -43,6 +43,9 @@ type CBar struct { // Fast float formatting fmtPrec int buf []byte + + // Cached monospace glyph width for the current display TextSize + charWidth float32 } func New(cfg *widgets.GaugeConfig) *CBar { @@ -122,10 +125,11 @@ func (s *CBar) initializeVisualElements() { } func (s *CBar) SetValue(value float64) { + value = max(s.cfg.Min, min(s.cfg.Max, value)) if value == s.value { return } - s.value = max(s.cfg.Min, min(s.cfg.Max, value)) + s.value = value barPosition := s.center var pxWidth float32 @@ -170,7 +174,6 @@ func (s *CBar) updateDisplayTextPosition() { if len(text) == 0 { return } - minSize := s.displayText.MinSize() dotIdx := -1 for i := 0; i < len(text); i++ { @@ -182,27 +185,23 @@ func (s *CBar) updateDisplayTextPosition() { var x float32 if dotIdx >= 0 { - charWidth := minSize.Width / float32(len(text)) - x = s.lastSize.Width*0.5 - charWidth*(float32(dotIdx)+0.5) + x = s.lastSize.Width*0.5 - s.charWidth*(float32(dotIdx)+0.5) } else { - x = s.lastSize.Width*0.5 - minSize.Width*0.5 + x = s.lastSize.Width*0.5 - s.charWidth*float32(len(text))*0.5 } s.displayText.Move(fyne.Position{X: x, Y: s.displayY}) } -func (s *CBar) SetValue2(value float64) { - s.SetValue(value) -} - func (s *CBar) CreateRenderer() fyne.WidgetRenderer { // Initialize visual elements s.initializeVisualElements() - return &CBarRenderer{s} + return &CBarRenderer{CBar: s} } type CBarRenderer struct { *CBar + objects []fyne.CanvasObject } func (r *CBarRenderer) MinSize() fyne.Size { @@ -265,6 +264,7 @@ func (r *CBarRenderer) Layout(space fyne.Size) { r.bar.Resize(fyne.Size{Width: r.barWidth * r.widthFactor, Height: r.barHeight}) r.displayText.TextSize = r.bar.Size().Height - 8 + r.charWidth = fyne.MeasureText("0", r.displayText.TextSize, r.displayText.TextStyle).Width var y float32 switch r.cfg.TextPosition { @@ -282,11 +282,13 @@ func (r *CBarRenderer) Layout(space fyne.Size) { } func (r *CBarRenderer) Objects() []fyne.CanvasObject { - objs := []fyne.CanvasObject{} - for _, line := range r.bars { - objs = append(objs, line) + if r.objects == nil { + objs := make([]fyne.CanvasObject, 0, len(r.bars)+4) + for _, line := range r.bars { + objs = append(objs, line) + } + objs = append(objs, r.bar, r.face, r.titleText, r.displayText) + r.objects = objs } - - objs = append(objs, r.bar, r.face, r.titleText, r.displayText) - return objs + return r.objects } diff --git a/pkg/widgets/combinedlogplayer/combinedlogplayer.go b/pkg/widgets/combinedlogplayer/combinedlogplayer.go index 127ebd85..da9e95c0 100644 --- a/pkg/widgets/combinedlogplayer/combinedlogplayer.go +++ b/pkg/widgets/combinedlogplayer/combinedlogplayer.go @@ -6,7 +6,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/widget" - "github.com/roffe/txlogger/pkg/eventbus" + "github.com/roffe/txlogger/pkg/bus" "github.com/roffe/txlogger/pkg/logfile" "github.com/roffe/txlogger/pkg/widgets/dashboard" "github.com/roffe/txlogger/pkg/widgets/logplayer" @@ -36,12 +36,12 @@ func New(cfg *CombinedLogplayerConfig) *Widget { return "Undefined" } } - bus := eventbus.New(eventbus.DefaultConfig) + buz := bus.NewBus[string, float64]() db := dashboard.NewDashboard(cfg.DBcfg) for _, name := range db.GetMetricNames() { - cancel := bus.SubscribeFunc(name, func(f float64) { + cancel := buz.SubscribeFunc(name, func(f float64) { fyne.Do(func() { db.SetValue(name, f) }) @@ -51,7 +51,7 @@ func New(cfg *CombinedLogplayerConfig) *Widget { cp.db = db cp.lp = logplayer.New(&logplayer.Config{ - EBus: bus, + EBus: buz, Logfile: cfg.Logfile, TimeSetter: db.SetTime, }) diff --git a/pkg/widgets/dashboard/dashboard.go b/pkg/widgets/dashboard/dashboard.go index 91c7e9c4..e11c4023 100644 --- a/pkg/widgets/dashboard/dashboard.go +++ b/pkg/widgets/dashboard/dashboard.go @@ -6,8 +6,6 @@ import ( "log" "time" - _ "embed" - "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/theme" @@ -80,11 +78,11 @@ type Config struct { AirDemToString func(float64) string UseMPH bool SwapRPMandSpeed bool - Low float64 - High float64 - WidebandSymbol string - MetricRouter map[string]func(float64) - FullscreenFunc func(bool) + // Low/High set the wideband lambda bar display range (defaults 0.5–1.5). + Low float64 + High float64 + WidebandSymbol string + FullscreenFunc func(bool) } func NewDashboard(cfg *Config) *Dashboard { @@ -99,6 +97,11 @@ func NewDashboard(cfg *Config) *Dashboard { speedometerText = "mph" } + wbLow, wbHigh := cfg.Low, cfg.High + if wbLow >= wbHigh { + wbLow, wbHigh = 0.5, 1.5 + } + db := &Dashboard{ cfg: cfg, logplayer: cfg.Logplayer, @@ -167,9 +170,9 @@ func NewDashboard(cfg *Config) *Dashboard { }), wblambda: cbar.New(&widgets.GaugeConfig{ Title: "", - Min: 0.50, - Center: 1, - Max: 1.50, + Min: wbLow, + Center: (wbLow + wbHigh) * 0.5, + Max: wbHigh, Steps: 20, MinSize: fyne.NewSize(50, 35), DisplayString: "λ %.2f", @@ -242,10 +245,6 @@ func NewDashboard(cfg *Config) *Dashboard { } db.ExtendBaseWidget(db) - db.text.cruise.Hide() - db.image.checkEngine.Hide() - db.image.limpMode.Hide() - db.metricRouter = db.createRouter() var isFullscreen bool @@ -414,10 +413,8 @@ type dims struct { sixthWidth float32 thirdHeight float32 tenthHeight float32 - halfHeight float32 centerX float32 centerY float32 - bottomY float32 textSize float32 smallTextSize float32 } @@ -528,7 +525,7 @@ func (db *Dashboard) layoutIcons(dims *dims) { }) // Taz icon - tazMin := fyne.Min(dims.sixthWidth, dims.thirdHeight) + tazMin := min(dims.sixthWidth, dims.thirdHeight) tazSize := fyne.Size{Width: tazMin, Height: tazMin + 16} db.image.taz.Resize(tazSize) @@ -642,12 +639,8 @@ func (dr *DashboardRenderer) Layout(space fyne.Size) { sixthWidth: space.Width * common.OneSixth, thirdHeight: (space.Height - 50) * .33, tenthHeight: (space.Height - 50) * .1, - halfHeight: (space.Height - 50) * .5, centerX: space.Width * 0.5, centerY: space.Height * 0.5, - bottomY: space.Height - 55, - - // textSize: max(min(space.Height, space.Width)*0.07, 20), } // Layout horizontal bars dr.db.layoutBars(dims) @@ -661,10 +654,7 @@ func (dr *DashboardRenderer) Layout(space fyne.Size) { dr.db.fullscreenBtn.Resize(fyne.NewSize(btnWidth, btnHeigh)) dr.db.fullscreenBtn.Move(fyne.NewPos(space.Width-btnWidth, space.Height-btnHeigh)) - dims.textSize = dr.db.gauges.nblambda.Size().Height - 2 - dims.smallTextSize = dims.textSize * 0.5 - - // Layout text elements + // Layout text elements (computes its own textSize/smallTextSize) dr.db.layoutTexts(dims) // Layout icons @@ -682,10 +672,6 @@ func (dr *DashboardRenderer) Destroy() { } func (dr *DashboardRenderer) Objects() []fyne.CanvasObject { - // The object set is fixed for the lifetime of the renderer, so build it - // once and reuse it. Fyne calls Objects() on every render/refresh pass, - // and during live logging this would otherwise allocate a new slice each - // time, creating needless GC pressure. if dr.objects == nil { dr.objects = []fyne.CanvasObject{ dr.db.image.wheelLeft, diff --git a/pkg/widgets/dashboard/setters.go b/pkg/widgets/dashboard/setters.go index 57f1b3fb..0b211280 100644 --- a/pkg/widgets/dashboard/setters.go +++ b/pkg/widgets/dashboard/setters.go @@ -1,8 +1,8 @@ package dashboard import ( - "fmt" "image/color" + "math" "strconv" "time" @@ -139,13 +139,24 @@ func textSetter(obj *canvas.Text, text, unit string, precision int) func(float64 } func idcSetter(obj *canvas.Text, text string) func(float64) { + var buf []byte oldValue := -1.0 return func(value float64) { if value == oldValue { return } oldValue = value - obj.Text = fmt.Sprintf(text+": %02.0f%%", value) + buf = buf[:0] + buf = append(buf, text...) + buf = append(buf, ": "...) + // Matches the old "%02.0f%%" format: rounded, zero-padded to two digits. + iv := int64(math.Round(value)) + if iv >= 0 && iv < 10 { + buf = append(buf, '0') + } + buf = strconv.AppendInt(buf, iv, 10) + buf = append(buf, '%') + obj.Text = string(buf) switch { case value > 60 && value < 85: obj.Color = color.RGBA{R: 0xFF, G: 0xA5, B: 0, A: 0xFF} diff --git a/pkg/widgets/dial/dial.go b/pkg/widgets/dial/dial.go index 45c54f63..d06bdfc4 100644 --- a/pkg/widgets/dial/dial.go +++ b/pkg/widgets/dial/dial.go @@ -4,7 +4,6 @@ import ( "image/color" "math" "strconv" - "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -19,13 +18,9 @@ type Dial struct { cfg *widgets.GaugeConfig - factor float64 - value float64 - highestObserved float64 + value float64 - needle *canvas.Line - highestObservedMarker *canvas.Line - lastHighestObserved time.Time + needle *canvas.Line pips []*canvas.Line pipLabels []*canvas.Text @@ -93,12 +88,9 @@ func New(cfg *widgets.GaugeConfig) *Dial { totalRange = 1 } - c.factor = c.cfg.Max / steps - c.face = canvas.NewArc(-135.73, 135.8, 0.985, color.RGBA{0x80, 0x80, 0x80, 0xFF}) c.center = &canvas.Circle{FillColor: color.RGBA{R: 0x01, G: 0x0B, B: 0x13, A: 0xFF}} c.needle = &canvas.Line{StrokeColor: color.RGBA{R: 0xFF, G: 0x67, B: 0, A: 0xFF}, StrokeWidth: 3} - c.highestObservedMarker = &canvas.Line{StrokeColor: color.RGBA{R: 216, G: 250, B: 8, A: 0xFF}, StrokeWidth: 6} c.titleText = &canvas.Text{Text: c.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} c.titleText.TextStyle.Monospace = true @@ -137,7 +129,6 @@ func New(cfg *widgets.GaugeConfig) *Dial { Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, Alignment: fyne.TextAlignCenter, } - // lbl.TextStyle.Monospace = true if n := len(txt); n > c.maxLabelChars { c.maxLabelChars = n } @@ -200,20 +191,6 @@ func (c *Dial) SetValue(value float64) { // Update needle position (no immediate refresh) c.rotateNeedleNoRefresh(c.needle, value, c.needleOffset, c.needleLength) - // Highest observed marker with lazy reset; only refresh when it actually moves - markerMoved := false - if value > c.highestObserved { - c.highestObserved = value - c.lastHighestObserved = time.Now() - c.rotateNeedleNoRefresh(c.highestObservedMarker, value, c.radius-2, 6) - markerMoved = true - } else if time.Since(c.lastHighestObserved) > 10*time.Second { - c.highestObserved = value - c.lastHighestObserved = time.Now() - c.rotateNeedleNoRefresh(c.highestObservedMarker, value, c.radius-2, 6) - markerMoved = true - } - // Update text with minimal allocs; skip refresh if formatted output is unchanged c.buf = c.buf[:0] if c.fmtPrec >= 0 { @@ -227,30 +204,26 @@ func (c *Dial) SetValue(value float64) { } canvas.Refresh(c.needle) - if markerMoved { - canvas.Refresh(c.highestObservedMarker) - } if textChanged { canvas.Refresh(c.displayText) } } -func (c *Dial) SetValue2(value float64) { c.SetValue(value) } - -func (c *Dial) CreateRenderer() fyne.WidgetRenderer { return &DialRenderer{Dial: c} } +func (c *Dial) CreateRenderer() fyne.WidgetRenderer { return &DialRenderer{d: c} } type DialRenderer struct { - *Dial + d *Dial objects []fyne.CanvasObject } -func (c *DialRenderer) Layout(space fyne.Size) { +func (r *DialRenderer) Layout(space fyne.Size) { + c := r.d if c.size == space { return } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) c.needleOffset = -c.radius * .15 @@ -329,18 +302,16 @@ func (c *DialRenderer) Layout(space fyne.Size) { c.applySinCos(p, c.pipSin[i], c.pipCos[i], radius87, eightRadius-1) } } - - c.highestObservedMarker.StrokeWidth = max(2.0, midStroke) - c.rotateNeedleNoRefresh(c.highestObservedMarker, c.highestObserved, c.radius-2, 6) } -func (c *DialRenderer) MinSize() fyne.Size { return c.minsize } -func (c *DialRenderer) Refresh() {} -func (c *DialRenderer) Destroy() {} +func (r *DialRenderer) MinSize() fyne.Size { return r.d.minsize } +func (r *DialRenderer) Refresh() {} +func (r *DialRenderer) Destroy() {} -func (c *DialRenderer) Objects() []fyne.CanvasObject { - if c.objects == nil { - objs := make([]fyne.CanvasObject, 0, len(c.pips)+len(c.pipLabels)+7) +func (r *DialRenderer) Objects() []fyne.CanvasObject { + if r.objects == nil { + c := r.d + objs := make([]fyne.CanvasObject, 0, len(c.pips)+len(c.pipLabels)+6) for _, v := range c.pips { objs = append(objs, v) } @@ -350,19 +321,8 @@ func (c *DialRenderer) Objects() []fyne.CanvasObject { } } objs = append(objs, c.face, c.titleText, c.center, - c.highestObservedMarker, c.needle, c.displayText) - c.objects = objs + c.needle, c.displayText) + r.objects = objs } - return c.objects + return r.objects } - -// --- helpers --- - -// max helper that matches your float32 usage -func max(a, b float32) float32 { - if a > b { - return a - } - return b -} - diff --git a/pkg/widgets/dial/dial.old b/pkg/widgets/dial/dial.old index 4eaf3858..f3a76afe 100644 --- a/pkg/widgets/dial/dial.old +++ b/pkg/widgets/dial/dial.old @@ -233,7 +233,7 @@ func (c *DialRenderer) Layout(space fyne.Size) { } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = fmin(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) c.needleOffset = -c.radius * .15 diff --git a/pkg/widgets/dial/shader/dial.go b/pkg/widgets/dial/shader/dial.go new file mode 100644 index 00000000..d7a36011 --- /dev/null +++ b/pkg/widgets/dial/shader/dial.go @@ -0,0 +1,275 @@ +package dial + +import ( + "image/color" + "math" + "strconv" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/widgets" +) + +type Dial struct { + widget.BaseWidget + displayString string + + cfg *widgets.GaugeConfig + + factor float64 + value float64 + highestObserved float64 + + lastHighestObserved time.Time + + // All dial geometry (face, pips, needle, marker, center) in one object + shader *canvas.Shader + + pipLabels []*canvas.Text + + displayText *canvas.Text + titleText *canvas.Text + + size fyne.Size + minsize fyne.Size + + diameter float32 + radius float32 + middle fyne.Position + needleRotConst float64 // = common.Pi15/(Max-Min) + lineRotConst float64 // = common.Pi15/steps + + // Precomputed trig for pip labels: angle_i = lineRotConst*float64(i) - common.Pi43 + pipSin []float32 + pipCos []float32 + + // Fast float formatting + fmtPrec int // precision extracted from displayString like "%.0f", "%.1f", defaults to -1 + gaugePrec int // precision extracted from GaugeTextString like "%.0f", "%.1f", defaults to -1 + buf []byte + + // Label sizing cache (avoids MinSize calls every layout) + maxLabelChars int // longest label length at construction + labelBoxW float32 // computed each layout from TextSize and maxLabelChars + labelBoxH float32 // computed each layout from TextSize +} + +func New(cfg *widgets.GaugeConfig) *Dial { + c := &Dial{ + cfg: cfg, + displayString: "%.0f", + minsize: fyne.NewSize(100, 100), + fmtPrec: -1, + } + c.ExtendBaseWidget(c) + + if cfg.DisplayString != "" { + c.displayString = cfg.DisplayString + if n := common.ParseFixedPrec(c.displayString); n >= 0 { + c.fmtPrec = n + } + } + if cfg.GaugeTextString != "" { + if n := common.ParseFixedPrec(cfg.GaugeTextString); n >= 0 { + c.gaugePrec = n + } + } + if cfg.GaugeFactor == 0 { + cfg.GaugeFactor = 1.0 + } + if cfg.MinSize.Width > 0 && cfg.MinSize.Height > 0 { + c.minsize = cfg.MinSize + } + + steps := float64(cfg.Steps) + totalRange := c.cfg.Max - c.cfg.Min + if totalRange <= 0 { + totalRange = 1 + } + + c.factor = c.cfg.Max / steps + + // Constants + c.needleRotConst = common.Pi15 / totalRange + c.lineRotConst = common.Pi15 / steps + + c.shader = canvas.NewShader( + "txlogger-dial", + []byte(dialShaderPreludeGL+dialShaderBody), + []byte(dialShaderPreludeES+dialShaderBody), + ) + c.shader.Uniforms = map[string]float32{ + "size_d": 100, + "steps": float32(cfg.Steps), + "needle_a": c.needleAngle(0), + "marker_a": c.needleAngle(0), + } + + c.titleText = &canvas.Text{Text: c.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} + c.titleText.TextStyle.Monospace = true + c.titleText.Alignment = fyne.TextAlignCenter + + c.displayText = &canvas.Text{Text: "0", Color: color.RGBA{R: 0x2c, G: 0xfc, B: 0x03, A: 0xFF}, TextSize: 52} + c.displayText.Alignment = fyne.TextAlignCenter + + // Labels at every other pip; also track the longest label length + for i := 0; i < c.cfg.Steps+1; i++ { + if i%2 == 0 { + val := c.cfg.Min + (float64(i)/float64(c.cfg.Steps))*(c.cfg.Max-c.cfg.Min)*c.cfg.GaugeFactor + txt := strconv.FormatFloat(val, 'f', c.gaugePrec, 64) + + lbl := &canvas.Text{ + Text: txt, + Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, + Alignment: fyne.TextAlignCenter, + } + if n := len(txt); n > c.maxLabelChars { + c.maxLabelChars = n + } + c.pipLabels = append(c.pipLabels, lbl) + } else { + c.pipLabels = append(c.pipLabels, nil) + } + } + + // Precompute pip sin/cos for label placement (size-independent) + c.pipSin = make([]float32, c.cfg.Steps+1) + c.pipCos = make([]float32, c.cfg.Steps+1) + for i := 0; i <= c.cfg.Steps; i++ { + ang := c.lineRotConst*float64(i) - common.Pi43 + s, co := math.Sincos(ang) + c.pipSin[i] = float32(s) + c.pipCos[i] = float32(co) + } + + return c +} + +func (c *Dial) GetConfig() *widgets.GaugeConfig { return c.cfg } + +// needle angle for a face value; clamped below Min like the CPU renderer, +// free to overshoot above Max +func (c *Dial) needleAngle(value float64) float32 { + normalized := value - c.cfg.Min + if normalized < 0 { + normalized = 0 + } + return float32(c.needleRotConst*normalized - common.Pi43) +} + +func (c *Dial) SetValue(value float64) { + if value == c.value { + return + } + c.value = value + + c.shader.Uniforms["needle_a"] = c.needleAngle(value) + + // Highest observed marker with lazy reset + if value > c.highestObserved || time.Since(c.lastHighestObserved) > 10*time.Second { + c.highestObserved = value + c.lastHighestObserved = time.Now() + c.shader.Uniforms["marker_a"] = c.needleAngle(value) + } + + // Update text with minimal allocs; skip refresh if formatted output is unchanged + c.buf = c.buf[:0] + if c.fmtPrec >= 0 { + c.buf = strconv.AppendFloat(c.buf, value, 'f', c.fmtPrec, 64) + } else { + c.buf = common.AppendFormatFloat(c.buf, c.displayString, value) + } + if !common.SameTextBytes(c.displayText.Text, c.buf) { + c.displayText.Text = string(c.buf) + canvas.Refresh(c.displayText) + } + + canvas.Refresh(c.shader) +} + +func (c *Dial) SetValue2(value float64) { c.SetValue(value) } + +func (c *Dial) CreateRenderer() fyne.WidgetRenderer { return &DialRenderer{Dial: c} } + +type DialRenderer struct { + *Dial + objects []fyne.CanvasObject +} + +func (c *DialRenderer) Layout(space fyne.Size) { + if c.size == space { + return + } + c.size = space + + c.diameter = min(space.Width, space.Height) + c.radius = c.diameter * common.OneHalf + c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) + + size := fyne.Size{Width: c.diameter, Height: c.diameter} + topleft := fyne.NewPos(c.middle.X-c.radius, c.middle.Y-c.radius) + + // Shader quad: the dial square padded so the marker overhang isn't clipped + c.shader.Move(topleft.SubtractXY(dialPad, dialPad)) + c.shader.Resize(fyne.Size{Width: c.diameter + 2*dialPad, Height: c.diameter + 2*dialPad}) + c.shader.Uniforms["size_d"] = c.diameter + + // Title (no rounding needed) + c.titleText.TextSize = float32(int(c.radius * common.OneFourth)) + c.titleText.Move(c.middle.Add(fyne.NewPos(0, c.diameter*common.OneFourth))) + + // Display text + c.displayText.TextSize = float32(int(c.radius * common.OneThird)) + c.displayText.Move(topleft.AddXY(0, c.diameter*common.OneFifth)) + c.displayText.Resize(size) + + // Labels: reuse precomputed sin/cos, scale with current radius + radius43 := c.radius * common.OneFourth * 3 + labelPad := max(float32(6.0), c.radius*0.14) + + // Assume monospace, digits only: width ≈ chars * 0.62 * TextSize; height ≈ 1.15 * TextSize + // This keeps alignment stable and removes per-label measuring. + const charWidthFactor = 0.62 + const heightFactor = 1.15 + + labelTextSize := c.radius * 0.10 + c.labelBoxW = float32(c.maxLabelChars) * float32(charWidthFactor) * labelTextSize + c.labelBoxH = float32(heightFactor) * labelTextSize + + for i, lbl := range c.pipLabels { + if lbl == nil { + continue + } + lbl.TextSize = labelTextSize + + // Place label on the INSIDE of the gauge + labelRadius := radius43 - labelPad + cx := c.middle.X + c.pipSin[i]*labelRadius + cy := c.middle.Y - c.pipCos[i]*labelRadius + + lbl.Resize(fyne.NewSize(c.labelBoxW, c.labelBoxH)) + lbl.Move(fyne.Position{X: cx - c.labelBoxW/2, Y: cy - c.labelBoxH/2}) + } +} + +func (c *DialRenderer) MinSize() fyne.Size { return c.minsize } +func (c *DialRenderer) Refresh() {} +func (c *DialRenderer) Destroy() {} + +func (c *DialRenderer) Objects() []fyne.CanvasObject { + if c.objects == nil { + objs := make([]fyne.CanvasObject, 0, len(c.pipLabels)+3) + objs = append(objs, c.shader) + for _, t := range c.pipLabels { + if t != nil { + objs = append(objs, t) + } + } + objs = append(objs, c.titleText, c.displayText) + c.objects = objs + } + return c.objects +} diff --git a/pkg/widgets/dial/shader/dial_shader.go b/pkg/widgets/dial/shader/dial_shader.go new file mode 100644 index 00000000..604b6c65 --- /dev/null +++ b/pkg/widgets/dial/shader/dial_shader.go @@ -0,0 +1,126 @@ +package dial + +// GPU renderer: the dial geometry (face rim, pips, needle, highest-observed +// marker and center cap) is drawn by a single canvas.Shader, collapsing ~30 +// canvas objects per dial into one draw call; SetValue only writes a float +// uniform. Text (title, value, pip labels) stays as canvas.Text layered on +// top - rasterizing glyphs in a fragment shader buys nothing. +// +// All dials share one Shader.Name: the painter caches the compiled program +// per name and the dial needs no textures, so every instance reuses the same +// program with its own uniforms. +// +// Conventions shared between the Go side and the GLSL below: +// - angles are radians, 0 pointing up, clockwise positive, matching the +// needle direction (sin a, -cos a) of the CPU renderer +// - the needle sweeps Pi15 (270 degrees) from -3/4 pi at Min to +3/4 pi +// at Max; pip i sits at i*Pi15/steps - 3/4 pi +// - the shader quad is the dial square padded by dialPad logical px per +// side, because the marker pokes 4 px past the rim like the CPU line did +const dialPad = 4 + +const dialShaderPreludeGL = "#version 110\n" + +const dialShaderPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const dialShaderBody = ` +uniform vec2 frame_size; +uniform vec4 rect_coords; + +uniform float size_d; // dial diameter, logical px +uniform float steps; // pip intervals; steps+1 pips are drawn +uniform float needle_a; // needle angle, radians +uniform float marker_a; // highest-observed marker angle, radians + +const float PIP_ANG = 2.35619449; // 3/4 pi, the first/last pip angle +const float FACE_ANG = 2.3693; // rim ends just past the end pips, like the canvas.Arc face +const float PAD = 4.0; // logical px, keep in sync with dialPad + +const vec3 FACE_COL = vec3(0.502, 0.502, 0.502); // 0x808080 +const vec3 CENTER_COL = vec3(0.0039, 0.0431, 0.0745); // 0x010B13 +const vec3 NEEDLE_COL = vec3(1.0, 0.4039, 0.0); // 0xFF6700 +const vec3 MARKER_COL = vec3(0.8471, 0.9804, 0.0314); // 0xD8FA08 + +// distance to the radial bar at angle a covering radius [r0, r1], half width hw +float radial_d(vec2 p, float a, float r0, float r1, float hw) { + vec2 dir = vec2(sin(a), -cos(a)); + float u = dot(p, dir); + float v = dot(p, vec2(-dir.y, dir.x)); + return length(vec2(u - clamp(u, r0, r1), v)) - hw; +} + +// 1 px anti-aliased coverage of signed distance d (device px) +float aa(float d) { + return clamp(0.5 - d, 0.0, 1.0); +} + +// src-over: lay coverage a of colour c on top; col stays premultiplied +void over(inout vec3 col, inout float alpha, vec3 c, float a) { + col = col * (1.0 - a) + c * a; + alpha = alpha * (1.0 - a) + a; +} + +void main() { + vec2 ext = vec2(rect_coords.y - rect_coords.x, rect_coords.w - rect_coords.z); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > ext.x || p_dev.y > ext.y) { + discard; + } + + float px = ext.x / (size_d + 2.0 * PAD); // device px per logical px + float r = 0.5 * size_d * px; // dial radius, device px + vec2 p = p_dev - 0.5 * ext; + + float len = length(p); + float theta = atan(p.x, -p.y); // 0 up, clockwise positive + + vec3 col = vec3(0.0); + float alpha = 0.0; + + // pips: strokes are far thinner than the pip spacing, so only the + // nearest pip can cover this pixel - no loop needed + float n = max(steps, 1.0); + float step_a = 4.71238898 / n; // Pi15 between first and last pip + float i = clamp(floor((theta + PIP_ANG) / step_a + 0.5), 0.0, n); + float odd = mod(i, 2.0); + float hw = 0.5 * px * mix(max(2.0, size_d / 80.0), max(2.0, size_d / 200.0), odd); + float rin = mix(0.75, 0.875, odd) * r; + // intersect with a disc one logical px inside the rim edge: the round + // end cap must not poke past the rim, and the AA fringe of the cut has + // to stay under the opaque part of the rim + float d = max(radial_d(p, i * step_a - PIP_ANG, rin, r - px, hw), len - (r - px)); + // green -> yellow -> red, like the CPU pip gradient + float t = i / n; + vec3 pip_col = vec3(clamp(2.0 * t, 0.0, 1.0), clamp(2.0 - 2.0 * t, 0.0, 1.0), 0.0); + over(col, alpha, pip_col, aa(d)); + + // face rim: the ring [0.985r, r] over the pip arc; the angular term is + // the arc length past the rim ends + d = max(abs(len - 0.9925 * r) - 0.0075 * r, (abs(theta) - FACE_ANG) * len); + over(col, alpha, FACE_COL, aa(d)); + + // center cap, diameter r/4 + over(col, alpha, CENTER_COL, aa(len - 0.125 * r)); + + // highest-observed marker: radius-2 .. radius+4 like the CPU line + float mhw = 0.5 * px * max(2.0, size_d / 80.0); + over(col, alpha, MARKER_COL, aa(radial_d(p, marker_a, r - 2.0 * px, r + 4.0 * px, mhw))); + + // needle: offset -0.15r, length 1.14r, tip pulled in 2 logical px + float nhw = 0.5 * px * (size_d / 60.0); + over(col, alpha, NEEDLE_COL, aa(radial_d(p, needle_a, -0.15 * r, 0.99 * r - 2.0 * px, nhw))); + + if (alpha < 0.004) { + discard; + } + gl_FragColor = vec4(col / alpha, alpha); +} +` diff --git a/pkg/widgets/dial/shader/dial_shader_test.go b/pkg/widgets/dial/shader/dial_shader_test.go new file mode 100644 index 00000000..31e6e0e3 --- /dev/null +++ b/pkg/widgets/dial/shader/dial_shader_test.go @@ -0,0 +1,29 @@ +package dial + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Both shader variants must at least compile; glslangValidator checks them +// against the GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestDialShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": dialShaderPreludeGL + dialShaderBody, + "es.frag": dialShaderPreludeES + dialShaderBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} diff --git a/pkg/widgets/dtcreader/dtcreader.go b/pkg/widgets/dtcreader/dtcreader.go index a1a182c3..c04e8f5a 100644 --- a/pkg/widgets/dtcreader/dtcreader.go +++ b/pkg/widgets/dtcreader/dtcreader.go @@ -148,26 +148,19 @@ func (d *DTCReader) ReadDTCS() error { return } - eventHandler := func(e gocan.Event) { - d.log(e.String()) - } - d.log("Connecting to device " + dev.Name()) - cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventHandler(eventHandler)) + // Events (incl. the final fatal) stream to the log; a fatal adapter + // failure also aborts any in-flight call below with that error. + cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + d.log(e.String()) + })) if err != nil { d.err(err) return } defer cl.Close() - go func() { - if err := cl.Wait(ctx); err != nil { - d.err(err) - return - } - }() - readDTCSFunc(ctx, cl) }() return nil @@ -198,26 +191,19 @@ func (d *DTCReader) ClearDTCS() error { d.err(err) return } - eventHandler := func(e gocan.Event) { - d.log(e.String()) - } - d.log("Connecting to device " + dev.Name()) - cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventHandler(eventHandler)) + // Events (incl. the final fatal) stream to the log; a fatal adapter + // failure also aborts any in-flight call below with that error. + cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { + d.log(e.String()) + })) if err != nil { d.err(err) return } defer cl.Close() - go func() { - if err := cl.Wait(ctx); err != nil { - d.err(err) - return - } - }() - clearDTCSFunc(ctx, cl) }() return nil diff --git a/pkg/widgets/dualdial/dual_dial.go b/pkg/widgets/dualdial/dual_dial.go index 8dc44673..6e7db05e 100644 --- a/pkg/widgets/dualdial/dual_dial.go +++ b/pkg/widgets/dualdial/dual_dial.go @@ -251,7 +251,7 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) @@ -350,12 +350,3 @@ func (c *DualDialRenderer) Objects() []fyne.CanvasObject { } return c.objects } - -// --- helpers --- - -func max(a, b float32) float32 { - if a > b { - return a - } - return b -} diff --git a/pkg/widgets/dualdial/dual_dial.old b/pkg/widgets/dualdial/dual_dial.old index 063a6d5c..ea3a3719 100644 --- a/pkg/widgets/dualdial/dual_dial.old +++ b/pkg/widgets/dualdial/dual_dial.old @@ -231,7 +231,7 @@ func (c *DualDialRenderer) Layout(space fyne.Size) { } c.size = space - c.diameter = fyne.Min(space.Width, space.Height) + c.diameter = min(space.Width, space.Height) c.radius = c.diameter * common.OneHalf c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) @@ -361,9 +361,3 @@ func appendFormatFloat(dst []byte, format string, v float64) []byte { return strconv.AppendFloat(dst, v, 'f', 0, 64) } -func max(a, b float32) float32 { - if a > b { - return a - } - return b -} diff --git a/pkg/widgets/dualdial/shader/dual_dial.go b/pkg/widgets/dualdial/shader/dual_dial.go new file mode 100644 index 00000000..9bec975e --- /dev/null +++ b/pkg/widgets/dualdial/shader/dual_dial.go @@ -0,0 +1,291 @@ +package dualdial + +import ( + "image/color" + "math" + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/widgets" +) + +type DualDial struct { + widget.BaseWidget + + cfg *widgets.GaugeConfig + + titleText *canvas.Text + displayString string + + value float64 + value2 float64 + + // All gauge geometry (face, pips, both needles, center) in one object + shader *canvas.Shader + + pipLabels []*canvas.Text + + displayText *canvas.Text + displayText2 *canvas.Text + + steps float64 + factor float64 + + size fyne.Size + minsize fyne.Size + + diameter float32 + radius float32 + middle fyne.Position + needleRotConst float64 + lineRotConst float64 + + // cached sin/cos for pip labels (angle_i = lineRotConst*i - common.Pi43) + pipSin []float32 + pipCos []float32 + + // fast float formatting buffers + fmtPrec int + gaugePrec int + buf1 []byte + buf2 []byte + + // Label sizing cache (avoid per-label MinSize on each layout) + maxLabelChars int + labelBoxW float32 + labelBoxH float32 +} + +func New(cfg *widgets.GaugeConfig) *DualDial { + s := &DualDial{ + cfg: cfg, + steps: 10, + displayString: "%.0f", + minsize: fyne.NewSize(100, 100), + fmtPrec: -1, + } + s.ExtendBaseWidget(s) + + if cfg.Steps > 0 { + s.steps = float64(cfg.Steps) + } + if cfg.DisplayString != "" { + s.displayString = cfg.DisplayString + if n := common.ParseFixedPrec(s.displayString); n >= 0 { + s.fmtPrec = n + } + } + if cfg.GaugeTextString != "" { + if n := common.ParseFixedPrec(cfg.GaugeTextString); n >= 0 { + s.gaugePrec = n + } + } + if cfg.MinSize.Width > 0 && cfg.MinSize.Height > 0 { + s.minsize = cfg.MinSize + } + + s.factor = s.cfg.Max / s.steps + + totalRange := s.cfg.Max - s.cfg.Min + if totalRange <= 0 { + totalRange = 1 + } + s.needleRotConst = common.Pi15 / totalRange + s.lineRotConst = common.Pi15 / s.steps + + s.shader = canvas.NewShader( + "txlogger-dualdial", + []byte(dualDialShaderPreludeGL+dualDialShaderBody), + []byte(dualDialShaderPreludeES+dualDialShaderBody), + ) + s.shader.Uniforms = map[string]float32{ + "size_d": 100, + "steps": float32(s.steps), + "needle_a": s.needleAngle(0), + "needle2_a": s.needleAngle(0), + } + + s.titleText = &canvas.Text{Text: s.cfg.Title, Color: color.RGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF}, TextSize: 25} + s.titleText.TextStyle.Monospace = true + s.titleText.Alignment = fyne.TextAlignCenter + + s.displayText = &canvas.Text{Text: "0", Color: color.RGBA{R: 0x2c, G: 0xfc, B: 0x03, A: 0xFF}, TextSize: 52} + s.displayText.Alignment = fyne.TextAlignCenter + + s.displayText2 = &canvas.Text{Text: "0", Color: color.RGBA{R: 0xff, G: 0x0, B: 0, A: 0xFF}, TextSize: 35} + s.displayText2.Alignment = fyne.TextAlignCenter + + // Labels at every other pip; also track the longest label length + for i := 0; i <= int(s.steps); i++ { + if i%2 == 0 { + val := s.cfg.Min + (float64(i)/float64(s.cfg.Steps))*(s.cfg.Max-s.cfg.Min) + txt := strconv.FormatFloat(val, 'f', s.gaugePrec, 64) + lbl := &canvas.Text{ + Text: txt, + Color: color.RGBA{0xE0, 0xE0, 0xE0, 0xFF}, + Alignment: fyne.TextAlignCenter, + } + if n := len(txt); n > s.maxLabelChars { + s.maxLabelChars = n + } + s.pipLabels = append(s.pipLabels, lbl) + } else { + s.pipLabels = append(s.pipLabels, nil) + } + } + + // precompute pip trig for label placement (size independent) + s.pipSin = make([]float32, int(s.steps)+1) + s.pipCos = make([]float32, int(s.steps)+1) + for i := 0; i <= int(s.steps); i++ { + ang := s.lineRotConst*float64(i) - common.Pi43 + sinA, cosA := math.Sincos(ang) + s.pipSin[i] = float32(sinA) + s.pipCos[i] = float32(cosA) + } + + return s +} + +func (c *DualDial) GetConfig() *widgets.GaugeConfig { return c.cfg } + +// needle angle for a face value; clamped below Min like the CPU renderer, +// free to overshoot above Max +func (c *DualDial) needleAngle(value float64) float32 { + normalized := value - c.cfg.Min + if normalized < 0 { + normalized = 0 + } + return float32(c.needleRotConst*normalized - common.Pi43) +} + +func (c *DualDial) SetValue(value float64) { + if value == c.value { + return + } + c.value = value + + c.shader.Uniforms["needle_a"] = c.needleAngle(value) + + c.buf1 = c.buf1[:0] + if c.fmtPrec >= 0 { + c.buf1 = strconv.AppendFloat(c.buf1, value, 'f', c.fmtPrec, 64) + } else { + c.buf1 = common.AppendFormatFloat(c.buf1, c.displayString, value) + } + if !common.SameTextBytes(c.displayText.Text, c.buf1) { + c.displayText.Text = string(c.buf1) + canvas.Refresh(c.displayText) + } + + canvas.Refresh(c.shader) +} + +func (c *DualDial) SetValue2(value float64) { + if value == c.value2 { + return + } + c.value2 = value + + c.shader.Uniforms["needle2_a"] = c.needleAngle(value) + + c.buf2 = c.buf2[:0] + if c.fmtPrec >= 0 { + c.buf2 = strconv.AppendFloat(c.buf2, value, 'f', c.fmtPrec, 64) + } else { + c.buf2 = common.AppendFormatFloat(c.buf2, c.displayString, value) + } + if !common.SameTextBytes(c.displayText2.Text, c.buf2) { + c.displayText2.Text = string(c.buf2) + canvas.Refresh(c.displayText2) + } + + canvas.Refresh(c.shader) +} + +func (c *DualDial) CreateRenderer() fyne.WidgetRenderer { return &DualDialRenderer{DualDial: c} } + +type DualDialRenderer struct { + *DualDial + objects []fyne.CanvasObject +} + +func (c *DualDialRenderer) Layout(space fyne.Size) { + if c.size == space { + return + } + c.size = space + + c.diameter = min(space.Width, space.Height) + c.radius = c.diameter * common.OneHalf + c.middle = fyne.NewPos(space.Width*common.OneHalf, space.Height*common.OneHalf) + + size := fyne.Size{Width: c.diameter, Height: c.diameter} + topleft := fyne.NewPos(c.middle.X-c.radius, c.middle.Y-c.radius) + + c.shader.Move(topleft) + c.shader.Resize(size) + c.shader.Uniforms["size_d"] = c.diameter + + // Title & display text sizes (no math.Round needed) + c.titleText.TextSize = c.radius * common.OneFourth + c.titleText.Move(c.middle.Add(fyne.NewPos(0, c.diameter*common.OneFourth))) + + sixthDiameter := c.diameter * common.OneSixth + + c.displayText.TextSize = c.radius * common.OneThird + c.displayText.Move(topleft.AddXY(0, c.diameter*common.OneFifth)) + c.displayText.Resize(size) + + c.displayText2.TextSize = c.radius * common.OneThird + c.displayText2.Move(topleft.AddXY(0, -sixthDiameter)) + c.displayText2.Resize(size) + + // Labels: reuse precomputed trig scaled by current radius + radius43 := c.radius * common.OneFourth * 3 + + // Label padding and cached box dims (avoid lbl.MinSize per label) + labelPad := max(float32(6.0), c.radius*0.14) + const charWidthFactor = 0.62 + const heightFactor = 1.15 + labelTextSize := c.radius * 0.10 + c.labelBoxW = float32(c.maxLabelChars) * float32(charWidthFactor) * labelTextSize + c.labelBoxH = float32(heightFactor) * labelTextSize + + for i, lbl := range c.pipLabels { + if lbl == nil { + continue + } + lbl.TextSize = labelTextSize + + // place inside the gauge slightly inward from long pip inner end + labelRadius := radius43 - labelPad + cx := c.middle.X + c.pipSin[i]*labelRadius + cy := c.middle.Y - c.pipCos[i]*labelRadius + + lbl.Resize(fyne.NewSize(c.labelBoxW, c.labelBoxH)) + lbl.Move(fyne.NewPos(cx-c.labelBoxW/2, cy-c.labelBoxH/2)) + } +} + +func (c *DualDialRenderer) MinSize() fyne.Size { return c.minsize } +func (c *DualDialRenderer) Refresh() {} +func (c *DualDialRenderer) Destroy() {} + +func (c *DualDialRenderer) Objects() []fyne.CanvasObject { + if c.objects == nil { + objs := make([]fyne.CanvasObject, 0, len(c.pipLabels)+4) + objs = append(objs, c.shader) + for _, v := range c.pipLabels { + if v != nil { + objs = append(objs, v) + } + } + objs = append(objs, c.titleText, c.displayText, c.displayText2) + c.objects = objs + } + return c.objects +} diff --git a/pkg/widgets/dualdial/shader/dual_dial_shader.go b/pkg/widgets/dualdial/shader/dual_dial_shader.go new file mode 100644 index 00000000..54b784b7 --- /dev/null +++ b/pkg/widgets/dualdial/shader/dual_dial_shader.go @@ -0,0 +1,119 @@ +package dualdial + +// GPU renderer: like the dial widget, the whole gauge geometry (face rim, +// pips, both needles and center cap) is drawn by a single canvas.Shader; +// a SetValue/SetValue2 only writes that needle's angle uniform. Text +// (title, both values, pip labels) stays as canvas.Text layered on top. +// +// All dual dials share one Shader.Name: the painter caches the compiled +// program per name and the widget needs no textures, so every instance +// reuses the same program with its own uniforms. +// +// Conventions match the dial shader: angles are radians, 0 pointing up, +// clockwise positive; the needles sweep Pi15 (270 degrees) from -3/4 pi at +// Min to +3/4 pi at Max. There is no marker overhanging the rim, so the +// shader quad is exactly the dial square. + +const dualDialShaderPreludeGL = "#version 110\n" + +const dualDialShaderPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const dualDialShaderBody = ` +uniform vec2 frame_size; +uniform vec4 rect_coords; + +uniform float size_d; // dial diameter, logical px +uniform float steps; // pip intervals; steps+1 pips are drawn +uniform float needle_a; // primary needle angle, radians +uniform float needle2_a; // secondary needle angle, radians + +const float PIP_ANG = 2.35619449; // 3/4 pi, the first/last pip angle +const float FACE_ANG = 2.3693; // rim ends just past the end pips, like the canvas.Arc face + +const vec3 FACE_COL = vec3(0.502, 0.502, 0.502); // 0x808080 +const vec3 CENTER_COL = vec3(0.0039, 0.0431, 0.0745); // 0x010B13 +const vec3 NEEDLE_COL = vec3(1.0, 0.4039, 0.0); // 0xFF6700 +const vec3 NEEDLE2_COL = vec3(0.9765, 0.1059, 0.0078); // 0xF91B02 + +// distance to the radial bar at angle a covering radius [r0, r1], half width hw +float radial_d(vec2 p, float a, float r0, float r1, float hw) { + vec2 dir = vec2(sin(a), -cos(a)); + float u = dot(p, dir); + float v = dot(p, vec2(-dir.y, dir.x)); + return length(vec2(u - clamp(u, r0, r1), v)) - hw; +} + +// 1 px anti-aliased coverage of signed distance d (device px) +float aa(float d) { + return clamp(0.5 - d, 0.0, 1.0); +} + +// src-over: lay coverage a of colour c on top; col stays premultiplied +void over(inout vec3 col, inout float alpha, vec3 c, float a) { + col = col * (1.0 - a) + c * a; + alpha = alpha * (1.0 - a) + a; +} + +void main() { + vec2 ext = vec2(rect_coords.y - rect_coords.x, rect_coords.w - rect_coords.z); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > ext.x || p_dev.y > ext.y) { + discard; + } + + float px = ext.x / max(size_d, 1.0); // device px per logical px + float r = 0.5 * ext.x; // dial radius, device px + vec2 p = p_dev - 0.5 * ext; + + float len = length(p); + float theta = atan(p.x, -p.y); // 0 up, clockwise positive + + vec3 col = vec3(0.0); + float alpha = 0.0; + + // pips: strokes are far thinner than the pip spacing, so only the + // nearest pip can cover this pixel - no loop needed + float n = max(steps, 1.0); + float step_a = 4.71238898 / n; // Pi15 between first and last pip + float i = clamp(floor((theta + PIP_ANG) / step_a + 0.5), 0.0, n); + float odd = mod(i, 2.0); + float hw = 0.5 * px * mix(max(2.0, size_d / 80.0), max(2.0, size_d / 200.0), odd); + float rin = mix(0.75, 0.875, odd) * r; + // intersect with a disc one logical px inside the rim edge: the round + // end cap must not poke past the rim, and the AA fringe of the cut has + // to stay under the opaque part of the rim + float d = max(radial_d(p, i * step_a - PIP_ANG, rin, r - px, hw), len - (r - px)); + // green -> yellow -> red, like the CPU pip gradient + float t = i / n; + vec3 pip_col = vec3(clamp(2.0 * t, 0.0, 1.0), clamp(2.0 - 2.0 * t, 0.0, 1.0), 0.0); + over(col, alpha, pip_col, aa(d)); + + // face rim: the ring [0.985r, r] over the pip arc; the angular term is + // the arc length past the rim ends + d = max(abs(len - 0.9925 * r) - 0.0075 * r, (abs(theta) - FACE_ANG) * len); + over(col, alpha, FACE_COL, aa(d)); + + // center cap, diameter r/4 + over(col, alpha, CENTER_COL, aa(len - 0.125 * r)); + + // needles: offset -0.15r, length 1.14r, tips pulled in 2 logical px; + // the primary draws on top + float nhw = 0.5 * px * (size_d / 60.0); + float ntip = 0.99 * r - 2.0 * px; + over(col, alpha, NEEDLE2_COL, aa(radial_d(p, needle2_a, -0.15 * r, ntip, nhw))); + over(col, alpha, NEEDLE_COL, aa(radial_d(p, needle_a, -0.15 * r, ntip, nhw))); + + if (alpha < 0.004) { + discard; + } + gl_FragColor = vec4(col / alpha, alpha); +} +` diff --git a/pkg/widgets/dualdial/shader/dual_dial_shader_test.go b/pkg/widgets/dualdial/shader/dual_dial_shader_test.go new file mode 100644 index 00000000..eb6d6f63 --- /dev/null +++ b/pkg/widgets/dualdial/shader/dual_dial_shader_test.go @@ -0,0 +1,29 @@ +package dualdial + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Both shader variants must at least compile; glslangValidator checks them +// against the GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestDualDialShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": dualDialShaderPreludeGL + dualDialShaderBody, + "es.frag": dualDialShaderPreludeES + dualDialShaderBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} diff --git a/pkg/widgets/editparameters/editparameters.go b/pkg/widgets/editparameters/editparameters.go index 47c3e1d5..bfaad065 100644 --- a/pkg/widgets/editparameters/editparameters.go +++ b/pkg/widgets/editparameters/editparameters.go @@ -193,81 +193,74 @@ func (t *EditParameters) readParameters() { t.err(err) return } - eventHandler := func(e gocan.Event) { + cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { log.Printf("EVENT: %v", e) - } - - cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventHandler(eventHandler)) + })) if err != nil { t.err(err) return } + defer cl.Close() + gm := gmlan.New(cl, 0x7e0, 0x5e8, 0x7e8) t8c := &T8{gm: gm} - go func() { - defer cl.Close() - - defer func() { - _ = gm.ReturnToNormalMode(ctx) - time.Sleep(75 * time.Millisecond) - }() - - data, err := gm.ReadDataByIdentifier(ctx, 0x01) - if err != nil { - t.err(err) - return - } + defer func() { + _ = gm.ReturnToNormalMode(ctx) + time.Sleep(75 * time.Millisecond) + }() - status, err := t8.DecodePI01(data) - if err != nil { - t.err(err) - return - } + // A fatal adapter failure aborts any of these calls with that error. + data, err := gm.ReadDataByIdentifier(ctx, 0x01) + if err != nil { + t.err(err) + return + } - t.SetDiagnosticType(status.DiagnosticType.String()) - t.SetTankType(status.TankType.String()) - t.SetConvertible(status.Convertible) - t.SetSAI(status.SAI) - t.SetHighOutput(status.HighOutput) - t.SetBioPower(status.BioPower) - t.SetClutchStart(status.ClutchStart) + status, err := t8.DecodePI01(data) + if err != nil { + t.err(err) + return + } - oilQuality, err := t8c.GetOilQuality(ctx) - if err != nil { - t.err(err) - return - } - t.SetOilQuality(strconv.FormatFloat(oilQuality, 'f', 2, 64)) + t.SetDiagnosticType(status.DiagnosticType.String()) + t.SetTankType(status.TankType.String()) + t.SetConvertible(status.Convertible) + t.SetSAI(status.SAI) + t.SetHighOutput(status.HighOutput) + t.SetBioPower(status.BioPower) + t.SetClutchStart(status.ClutchStart) - vin, err := t8c.GetVehicleVIN(ctx) - if err != nil { - t.err(err) - return - } - t.SetVIN(vin) + oilQuality, err := t8c.GetOilQuality(ctx) + if err != nil { + t.err(err) + return + } + t.SetOilQuality(strconv.FormatFloat(oilQuality, 'f', 2, 64)) - topSpeed, err := t8c.GetTopSpeed(ctx) - if err != nil { - t.err(err) - return - } - t.SetTopSpeed(strconv.Itoa(int(topSpeed))) + vin, err := t8c.GetVehicleVIN(ctx) + if err != nil { + t.err(err) + return + } + t.SetVIN(vin) - time.Sleep(5 * time.Millisecond) + topSpeed, err := t8c.GetTopSpeed(ctx) + if err != nil { + t.err(err) + return + } + t.SetTopSpeed(strconv.Itoa(int(topSpeed))) - e85percentage, err := t8c.GetE85Percent(ctx) - if err != nil { - t.err(err) - return - } - t.SetE85Percent(strconv.FormatFloat(e85percentage, 'f', 0, 64)) - }() + time.Sleep(5 * time.Millisecond) - if err := cl.Wait(ctx); err != nil { + e85percentage, err := t8c.GetE85Percent(ctx) + if err != nil { t.err(err) return } + t.SetE85Percent(strconv.FormatFloat(e85percentage, 'f', 0, 64)) + t.hasBeenRead = true } @@ -287,168 +280,162 @@ func (t *EditParameters) writeParameters() { t.err(err) return } - eventHandler := func(e gocan.Event) { + cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventFunc(func(e gocan.Event) { log.Printf("EVENT: %v", e) - } - - cl, err := gocan.NewWithOpts(ctx, dev, gocan.WithEventHandler(eventHandler)) + })) if err != nil { t.err(err) return } + defer cl.Close() + gm := gmlan.New(cl, 0x7e0, 0x5e8, 0x7e8) t8c := &T8{gm: gm} - go func() { - defer cl.Close() - - //if err := gm.InitiateDiagnosticOperation(ctx, gmlan.LEV_EDDDC); err != nil { - // log.Println(err) - // return - //} - - defer func() { - _ = gm.ReturnToNormalMode(ctx) - time.Sleep(75 * time.Millisecond) - }() - - if err := gm.RequestSecurityAccess(ctx, 0xFD, 1, t8sec.CalculateAccessKey); err != nil { - t.err(err) - return - } - - vin, err := t.GetVIN() - if err != nil { - t.err(fmt.Errorf("Error getting VIN: %w", err)) - return - } - if err := t8c.SetVehicleVIN(ctx, vin); err != nil { - t.err(fmt.Errorf("Error setting VIN: %w", err)) - } - - e85content, err := t.GetE85Percent() - if err != nil { - t.err(fmt.Errorf("Error getting E85 content: %w", err)) - return - } - e85percent, err := strconv.ParseFloat(e85content, 64) - if err != nil { - t.err(fmt.Errorf("Error parsing E85 content: %w", err)) - return - } - if err := t8c.SetE85Percent(ctx, e85percent); err != nil { - t.err(fmt.Errorf("Error setting E85 percent: %w", err)) - return - } - - topSpeed, err := t.GetTopSpeed() - if err != nil { - t.err(fmt.Errorf("Error getting Top Speed: %w", err)) - return - } - topSpeedVal, err := strconv.Atoi(topSpeed) - if err != nil { - t.err(fmt.Errorf("Error parsing Top Speed: %w", err)) - return - } - if err := t8c.SetTopSpeed(ctx, uint16(topSpeedVal)); err != nil { - t.err(fmt.Errorf("Error setting Top Speed: %w", err)) - return - } + //if err := gm.InitiateDiagnosticOperation(ctx, gmlan.LEV_EDDDC); err != nil { + // log.Println(err) + // return + //} - oilQuality, err := t.GetOilQuality() - if err != nil { - t.err(fmt.Errorf("Error getting oil quality: %w", err)) - return - } - oilQualityVal, err := strconv.ParseFloat(oilQuality, 64) - if err != nil { - t.err(fmt.Errorf("Error parsing oil quality: %w", err)) - return - } - if err := t8c.SetOilQuality(ctx, oilQualityVal); err != nil { - t.err(fmt.Errorf("Error setting oil quality: %w", err)) - return - } + defer func() { + _ = gm.ReturnToNormalMode(ctx) + time.Sleep(75 * time.Millisecond) + }() - data, err := gm.ReadDataByIdentifier(ctx, 0x01) - if err != nil { - t.err(fmt.Errorf("Error reading PI01: %w", err)) - return - } + // A fatal adapter failure aborts any of these calls with that error. + if err := gm.RequestSecurityAccess(ctx, 0xFD, 1, t8sec.CalculateAccessKey); err != nil { + t.err(err) + return + } - pi01, err := t.GetPI01Data() - if err != nil { - t.err(fmt.Errorf("Error getting PI 0x01 data: %w", err)) - return - } + vin, err := t.GetVIN() + if err != nil { + t.err(fmt.Errorf("Error getting VIN: %w", err)) + return + } + if err := t8c.SetVehicleVIN(ctx, vin); err != nil { + t.err(fmt.Errorf("Error setting VIN: %w", err)) + } - // -------C - data[0] = setBit(data[0], 0, pi01.BioPower) - - // -----C-- - data[0] = setBit(data[0], 2, pi01.Convertible) - - // ---01--- US - // ---10--- EU - // ---11--- AWD - switch pi01.TankType { - case t8.TankTypeUS: - data[0] = setBit(data[0], 3, true) - data[0] = setBit(data[0], 4, false) - case t8.TankTypeEU: - data[0] = setBit(data[0], 3, false) - data[0] = setBit(data[0], 4, true) - case t8.TankTypeAWD: - data[0] = setBit(data[0], 3, true) - data[0] = setBit(data[0], 4, true) - } + e85content, err := t.GetE85Percent() + if err != nil { + t.err(fmt.Errorf("Error getting E85 content: %w", err)) + return + } + e85percent, err := strconv.ParseFloat(e85content, 64) + if err != nil { + t.err(fmt.Errorf("Error parsing E85 content: %w", err)) + return + } + if err := t8c.SetE85Percent(ctx, e85percent); err != nil { + t.err(fmt.Errorf("Error setting E85 percent: %w", err)) + return + } - // -01----- OBD2 - // -10----- EOBD - // -11----- LOBD - switch pi01.DiagnosticType { - case t8.DiagnosticTypeOBD2: - data[0] = setBit(data[0], 5, true) - data[0] = setBit(data[0], 6, false) - case t8.DiagnosticTypeEOBD: - data[0] = setBit(data[0], 5, false) - data[0] = setBit(data[0], 6, true) - case t8.DiagnosticTypeLOBD: - data[0] = setBit(data[0], 5, true) - data[0] = setBit(data[0], 6, true) - case t8.DiagnosticTypeNone: - data[0] = setBit(data[0], 5, false) - data[0] = setBit(data[0], 6, false) - } + topSpeed, err := t.GetTopSpeed() + if err != nil { + t.err(fmt.Errorf("Error getting Top Speed: %w", err)) + return + } + topSpeedVal, err := strconv.Atoi(topSpeed) + if err != nil { + t.err(fmt.Errorf("Error parsing Top Speed: %w", err)) + return + } + if err := t8c.SetTopSpeed(ctx, uint16(topSpeedVal)); err != nil { + t.err(fmt.Errorf("Error setting Top Speed: %w", err)) + return + } - // on = -----10- - // off= -----01- - data[1] = setBit(data[1], 1, !pi01.ClutchStart) - data[1] = setBit(data[1], 2, pi01.ClutchStart) + oilQuality, err := t.GetOilQuality() + if err != nil { + t.err(fmt.Errorf("Error getting oil quality: %w", err)) + return + } + oilQualityVal, err := strconv.ParseFloat(oilQuality, 64) + if err != nil { + t.err(fmt.Errorf("Error parsing oil quality: %w", err)) + return + } + if err := t8c.SetOilQuality(ctx, oilQualityVal); err != nil { + t.err(fmt.Errorf("Error setting oil quality: %w", err)) + return + } - // on = ---10--- - // off= ---01--- - data[1] = setBit(data[1], 3, !pi01.SAI) - data[1] = setBit(data[1], 4, pi01.SAI) + data, err := gm.ReadDataByIdentifier(ctx, 0x01) + if err != nil { + t.err(fmt.Errorf("Error reading PI01: %w", err)) + return + } - // high= -01----- - // low = -10----- - data[1] = setBit(data[1], 5, pi01.HighOutput) - data[1] = setBit(data[1], 6, !pi01.HighOutput) + pi01, err := t.GetPI01Data() + if err != nil { + t.err(fmt.Errorf("Error getting PI 0x01 data: %w", err)) + return + } - if err := gm.WriteDataByIdentifier(ctx, 0x01, data); err != nil { - t.err(fmt.Errorf("Error writing PI 0x01: %w", err)) - return - } + // -------C + data[0] = setBit(data[0], 0, pi01.BioPower) + + // -----C-- + data[0] = setBit(data[0], 2, pi01.Convertible) + + // ---01--- US + // ---10--- EU + // ---11--- AWD + switch pi01.TankType { + case t8.TankTypeUS: + data[0] = setBit(data[0], 3, true) + data[0] = setBit(data[0], 4, false) + case t8.TankTypeEU: + data[0] = setBit(data[0], 3, false) + data[0] = setBit(data[0], 4, true) + case t8.TankTypeAWD: + data[0] = setBit(data[0], 3, true) + data[0] = setBit(data[0], 4, true) + } + + // -01----- OBD2 + // -10----- EOBD + // -11----- LOBD + switch pi01.DiagnosticType { + case t8.DiagnosticTypeOBD2: + data[0] = setBit(data[0], 5, true) + data[0] = setBit(data[0], 6, false) + case t8.DiagnosticTypeEOBD: + data[0] = setBit(data[0], 5, false) + data[0] = setBit(data[0], 6, true) + case t8.DiagnosticTypeLOBD: + data[0] = setBit(data[0], 5, true) + data[0] = setBit(data[0], 6, true) + case t8.DiagnosticTypeNone: + data[0] = setBit(data[0], 5, false) + data[0] = setBit(data[0], 6, false) + } + + // on = -----10- + // off= -----01- + data[1] = setBit(data[1], 1, !pi01.ClutchStart) + data[1] = setBit(data[1], 2, pi01.ClutchStart) + + // on = ---10--- + // off= ---01--- + data[1] = setBit(data[1], 3, !pi01.SAI) + data[1] = setBit(data[1], 4, pi01.SAI) + + // high= -01----- + // low = -10----- + data[1] = setBit(data[1], 5, pi01.HighOutput) + data[1] = setBit(data[1], 6, !pi01.HighOutput) + + if err := gm.WriteDataByIdentifier(ctx, 0x01, data); err != nil { + t.err(fmt.Errorf("Error writing PI 0x01: %w", err)) + return + } - if err := gm.DeviceControl(ctx, 0x16); err != nil { - t.err(fmt.Errorf("Error performing device control 0x16: %w", err)) - return - } - }() - if err := cl.Wait(ctx); err != nil { - t.err(err) + if err := gm.DeviceControl(ctx, 0x16); err != nil { + t.err(fmt.Errorf("Error performing device control 0x16: %w", err)) + return } } diff --git a/pkg/widgets/gauge/gauge.go b/pkg/widgets/gauge/gauge.go index d93ab59d..03f8dc23 100644 --- a/pkg/widgets/gauge/gauge.go +++ b/pkg/widgets/gauge/gauge.go @@ -3,6 +3,7 @@ package gauge import ( "errors" + "fyne.io/fyne/v2" "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/pkg/widgets" "github.com/roffe/txlogger/pkg/widgets/cbar" @@ -12,29 +13,33 @@ import ( "github.com/roffe/txlogger/pkg/widgets/vbar" ) -func New(cfg *widgets.GaugeConfig) (widgets.IGauge, []func(), error) { +func New(cfg *widgets.GaugeConfig) (fyne.CanvasObject, func(), error) { switch cfg.Type { case "Dial": dial := dial.New(cfg) cancel := ebus.SubscribeFunc(cfg.SymbolName, dial.SetValue) - return dial, []func(){cancel}, nil + return dial, cancel, nil case "DualDial": ddial := dualdial.New(cfg) cancel1 := ebus.SubscribeFunc(cfg.SymbolName, ddial.SetValue) cancel2 := ebus.SubscribeFunc(cfg.SymbolNameSecondary, ddial.SetValue2) - return ddial, []func(){cancel1, cancel2}, nil + cancelFn := func() { + cancel1() + cancel2() + } + return ddial, cancelFn, nil case "VBar": vb := vbar.New(cfg) cancel := ebus.SubscribeFunc(cfg.SymbolName, vb.SetValue) - return vb, []func(){cancel}, nil + return vb, cancel, nil case "HBar": hb := hbar.New(cfg) cancel := ebus.SubscribeFunc(cfg.SymbolName, hb.SetValue) - return hb, []func(){cancel}, nil + return hb, cancel, nil case "CBar": cb := cbar.New(cfg) cancel := ebus.SubscribeFunc(cfg.SymbolName, cb.SetValue) - return cb, []func(){cancel}, nil + return cb, cancel, nil } return nil, nil, errors.New("unknown gauge type") } diff --git a/pkg/widgets/graph2d/graph2d.go b/pkg/widgets/graph2d/graph2d.go new file mode 100644 index 00000000..0459393e --- /dev/null +++ b/pkg/widgets/graph2d/graph2d.go @@ -0,0 +1,563 @@ +// Package graph2d provides a T7Suite style 2D graph for one dimensional +// maps. The map values are plotted as a line with one marker per cell, +// value callouts above the markers and the axis values along the bottom. +package graph2d + +import ( + "image/color" + "math" + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" +) + +const ( + tickTextSize = 12 + calloutTextSize = 11 + + markerRadius = 4 + + padRight = 12 + axisGapX = 6 // gap between the y tick labels and the plot area + + calloutPadX = 4 + calloutPadY = 2 + + // pool size for y gridlines/labels/bands, must fit maxYTicks plus the + // extra ticks added when the range is extended to nice boundaries + gridPool = 16 + + maxYTicks = 8 +) + +var ( + plotBgColor = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF} + bandColor = color.RGBA{0xEF, 0xED, 0xD8, 0xFF} + gridLineColor = color.RGBA{0xC9, 0xC9, 0xC9, 0xFF} + plotLineColor = color.RGBA{0xD8, 0x40, 0x28, 0xFF} + markerStrokeCol = color.RGBA{0x8A, 0x22, 0x12, 0xFF} + calloutBorderCol = color.RGBA{0x99, 0x99, 0x99, 0xFF} + calloutTextColor = color.RGBA{0x00, 0x00, 0x00, 0xFF} + // same color as the mapviewer crosshair so the live cursor is + // recognizable; NRGBA since the value is not alpha-premultiplied + cursorColor = color.NRGBA{165, 55, 253, 180} +) + +var ( + _ fyne.Widget = (*Graph)(nil) + _ desktop.Mouseable = (*Graph)(nil) +) + +type Config struct { + AxisData []float64 // axis value per cell, shown along the x axis + Values []float64 + AxisPrecision int + ValuePrecision int + AxisLabel string // optional axis description shown below the x axis + ColorblindMode colors.ColorBlindMode +} + +type Graph struct { + widget.BaseWidget + + cfg *Config + + axis []float64 + values []float64 + + zMin, zMax float64 + + colorMode colors.ColorBlindMode + + cursorIdx float64 + showCursor bool + + OnMouseDown func() + + renderer *graphRenderer +} + +func New(cfg *Config) *Graph { + g := &Graph{ + cfg: cfg, + axis: cfg.AxisData, + values: cfg.Values, + colorMode: cfg.ColorblindMode, + } + if len(g.axis) != len(g.values) { + g.axis = make([]float64, len(g.values)) + for i := range g.axis { + g.axis[i] = float64(i) + } + } + g.zMin, g.zMax = common.FindMinMaxFloat64(g.values) + g.ExtendBaseWidget(g) + return g +} + +// SetValues updates the plotted values. The number of values must match the +// map dimensions the graph was created with. +func (g *Graph) SetValues(min, max float64, values []float64) { + if len(values) != len(g.values) { + return + } + g.values = values + g.zMin = min + g.zMax = max + g.Refresh() +} + +func (g *Graph) SetColorBlindMode(mode colors.ColorBlindMode) { + if g.colorMode != mode { + g.colorMode = mode + g.Refresh() + } +} + +// SetCursor positions the live cursor at the (fractional) cell index, +// mirroring the crosshair in the map above. +func (g *Graph) SetCursor(idx float64) { + if idx < 0 { + idx = 0 + } else if max := float64(len(g.values) - 1); idx > max { + idx = max + } + g.cursorIdx = idx + g.showCursor = true + if g.renderer != nil { + g.renderer.positionCursor() + g.renderer.cursor.Refresh() + } +} + +func (g *Graph) MouseDown(_ *desktop.MouseEvent) { + if g.OnMouseDown != nil { + g.OnMouseDown() + } +} + +func (g *Graph) MouseUp(_ *desktop.MouseEvent) {} + +func (g *Graph) CreateRenderer() fyne.WidgetRenderer { + n := len(g.values) + r := &graphRenderer{g: g} + + r.plotBg = &canvas.Rectangle{FillColor: plotBgColor} + + for i := 0; i < gridPool; i++ { + band := &canvas.Rectangle{FillColor: bandColor} + band.Hide() + r.bands = append(r.bands, band) + + line := &canvas.Line{StrokeColor: gridLineColor, StrokeWidth: 1} + line.Hide() + r.gridLines = append(r.gridLines, line) + + label := &canvas.Text{TextSize: tickTextSize} + label.Hide() + r.yLabels = append(r.yLabels, label) + } + + for i := 0; i < n-1; i++ { + r.segments = append(r.segments, &canvas.Line{StrokeColor: plotLineColor, StrokeWidth: 2}) + } + + r.cursor = &canvas.Line{StrokeColor: cursorColor, StrokeWidth: 3} + r.cursor.Hide() + + for i := 0; i < n; i++ { + r.markers = append(r.markers, &canvas.Circle{StrokeColor: markerStrokeCol, StrokeWidth: 1.5}) + r.xLabels = append(r.xLabels, &canvas.Text{TextSize: tickTextSize}) + r.calloutBoxes = append(r.calloutBoxes, &canvas.Rectangle{ + FillColor: plotBgColor, + StrokeColor: calloutBorderCol, + StrokeWidth: 1, + CornerRadius: 2, + }) + r.calloutTexts = append(r.calloutTexts, &canvas.Text{ + TextSize: calloutTextSize, + Color: calloutTextColor, + Alignment: fyne.TextAlignCenter, + }) + } + + if g.cfg.AxisLabel != "" { + r.axisLabel = &canvas.Text{Text: g.cfg.AxisLabel, TextSize: tickTextSize} + } + + // z-order: background, bands, gridlines, cursor, line, markers, + // callouts, labels + r.objects = append(r.objects, r.plotBg) + for _, o := range r.bands { + r.objects = append(r.objects, o) + } + for _, o := range r.gridLines { + r.objects = append(r.objects, o) + } + r.objects = append(r.objects, r.cursor) + for _, o := range r.segments { + r.objects = append(r.objects, o) + } + for _, o := range r.markers { + r.objects = append(r.objects, o) + } + for i := 0; i < n; i++ { + r.objects = append(r.objects, r.calloutBoxes[i], r.calloutTexts[i]) + } + for _, o := range r.yLabels { + r.objects = append(r.objects, o) + } + for _, o := range r.xLabels { + r.objects = append(r.objects, o) + } + if r.axisLabel != nil { + r.objects = append(r.objects, r.axisLabel) + } + + g.renderer = r + return r +} + +var _ fyne.WidgetRenderer = (*graphRenderer)(nil) + +type graphRenderer struct { + g *Graph + + plotBg *canvas.Rectangle + + bands []*canvas.Rectangle + gridLines []*canvas.Line + yLabels []*canvas.Text + + segments []*canvas.Line + markers []*canvas.Circle + + calloutBoxes []*canvas.Rectangle + calloutTexts []*canvas.Text + + xLabels []*canvas.Text + axisLabel *canvas.Text + + cursor *canvas.Line + + objects []fyne.CanvasObject + + size fyne.Size + + // plot geometry, kept so the live cursor can be moved without a relayout + plotTop, plotBottom float32 + plotLeft float32 + xStep float32 +} + +func (r *graphRenderer) Layout(size fyne.Size) { + if size == r.size { + return + } + r.size = size + r.relayout() + // the tick labels can change content with the available size so a plain + // reposition is not enough + r.refreshObjects() +} + +func (r *graphRenderer) MinSize() fyne.Size { + return fyne.NewSize(200, 250) +} + +func (r *graphRenderer) Refresh() { + r.relayout() + r.refreshObjects() +} + +func (r *graphRenderer) Destroy() {} + +func (r *graphRenderer) Objects() []fyne.CanvasObject { + return r.objects +} + +func (r *graphRenderer) refreshObjects() { + for _, o := range r.objects { + if !o.Visible() { + continue + } + o.Refresh() + } +} + +func (r *graphRenderer) relayout() { + g := r.g + n := len(g.values) + size := r.size + if n == 0 || size.Width <= 0 || size.Height <= 0 { + return + } + + style := fyne.TextStyle{} + + // measure the value callouts so headroom can be reserved above the plot + calloutWidths := make([]float32, n) + var maxCalloutW, calloutH float32 + for i, v := range g.values { + text := strconv.FormatFloat(v, 'f', g.cfg.ValuePrecision, 64) + r.calloutTexts[i].Text = text + s := fyne.MeasureText(text, calloutTextSize, style) + calloutWidths[i] = s.Width + 2*calloutPadX + if calloutWidths[i] > maxCalloutW { + maxCalloutW = calloutWidths[i] + } + if h := s.Height + 2*calloutPadY; h > calloutH { + calloutH = h + } + } + + padTop := 2*(calloutH+2) + markerRadius + 4 + + xLabelH := fyne.MeasureText("0", tickTextSize, style).Height + padBottom := xLabelH + 8 + if r.axisLabel != nil { + padBottom += xLabelH + 2 + } + + plotH := size.Height - padTop - padBottom + if plotH < 24 { + plotH = 24 + } + plotTop := padTop + plotBottom := plotTop + plotH + + // y scale with "nice" tick steps, extended to tick boundaries + maxTicks := int(plotH / 36) + if maxTicks < 2 { + maxTicks = 2 + } else if maxTicks > maxYTicks { + maxTicks = maxYTicks + } + rng := g.zMax - g.zMin + if rng <= 0 { + rng = math.Abs(g.zMax) + if rng == 0 { + rng = 1 + } + } + step := niceStep(rng, maxTicks) + yStart := math.Floor(g.zMin/step) * step + yEnd := math.Ceil(g.zMax/step) * step + if yEnd-yStart < step { + yEnd = yStart + step + } + ticks := int(math.Round((yEnd-yStart)/step)) + 1 + if ticks > len(r.gridLines) { + ticks = len(r.gridLines) + } + + decimals := 0 + if e := math.Floor(math.Log10(step)); e < 0 { + decimals = int(-e) + } + + tickTexts := make([]string, ticks) + var maxYLabelW float32 + for i := range tickTexts { + tickTexts[i] = strconv.FormatFloat(yStart+float64(i)*step, 'f', decimals, 64) + if s := fyne.MeasureText(tickTexts[i], tickTextSize, style); s.Width > maxYLabelW { + maxYLabelW = s.Width + } + } + padLeft := maxYLabelW + axisGapX + 4 + plotW := size.Width - padLeft - padRight + if plotW < 10 { + plotW = 10 + } + + scale := plotH / float32(yEnd-yStart) + yFor := func(v float64) float32 { + return plotBottom - float32(v-yStart)*scale + } + + labelColor := theme.Color(theme.ColorNameForeground) + + r.plotBg.Move(fyne.NewPos(padLeft, plotTop)) + r.plotBg.Resize(fyne.NewSize(plotW, plotH)) + + for i := 0; i < len(r.gridLines); i++ { + if i >= ticks { + r.gridLines[i].Hide() + r.yLabels[i].Hide() + r.bands[i].Hide() + continue + } + y := yFor(yStart + float64(i)*step) + + line := r.gridLines[i] + line.Position1 = fyne.NewPos(padLeft, y) + line.Position2 = fyne.NewPos(padLeft+plotW, y) + line.Show() + + label := r.yLabels[i] + label.Text = tickTexts[i] + label.Color = labelColor + s := fyne.MeasureText(label.Text, tickTextSize, style) + label.Resize(s) + label.Move(fyne.NewPos(padLeft-axisGapX-s.Width, y-s.Height/2)) + label.Show() + + // alternating band between this gridline and the one above it + band := r.bands[i] + if i+1 < ticks && i%2 == 0 { + yAbove := yFor(yStart + float64(i+1)*step) + band.Move(fyne.NewPos(padLeft, yAbove)) + band.Resize(fyne.NewSize(plotW, y-yAbove)) + band.Show() + } else { + band.Hide() + } + } + + xStep := plotW / float32(n) + cx := func(i int) float32 { + return padLeft + (float32(i)+0.5)*xStep + } + + cys := make([]float32, n) + for i, v := range g.values { + cys[i] = yFor(v) + } + + for i := 0; i < n; i++ { + marker := r.markers[i] + marker.FillColor = colors.GetColorInterpolation(g.zMin, g.zMax, g.values[i], g.colorMode) + marker.Position1 = fyne.NewPos(cx(i)-markerRadius, cys[i]-markerRadius) + marker.Position2 = fyne.NewPos(cx(i)+markerRadius, cys[i]+markerRadius) + } + + for i := 0; i < n-1; i++ { + segment := r.segments[i] + segment.Position1 = fyne.NewPos(cx(i), cys[i]) + segment.Position2 = fyne.NewPos(cx(i+1), cys[i+1]) + } + + // stagger the callouts on two levels when they would overlap and skip + // some of them when even that is not enough + levels := 1 + if maxCalloutW+4 > xStep { + levels = 2 + } + skip := 1 + if needed := maxCalloutW + 4; needed > xStep*float32(levels) { + skip = int(math.Ceil(float64(needed / (xStep * float32(levels))))) + } + shown := 0 + for i := 0; i < n; i++ { + box, text := r.calloutBoxes[i], r.calloutTexts[i] + if i%skip != 0 { + box.Hide() + text.Hide() + continue + } + level := 0 + if levels == 2 { + level = shown % 2 + } + shown++ + + w := calloutWidths[i] + bottom := cys[i] - markerRadius - 3 - float32(level)*(calloutH+2) + x := cx(i) - w/2 + if x < padLeft+1 { + x = padLeft + 1 + } + if x+w > padLeft+plotW-1 { + x = padLeft + plotW - 1 - w + } + box.Move(fyne.NewPos(x, bottom-calloutH)) + box.Resize(fyne.NewSize(w, calloutH)) + box.Show() + text.Resize(fyne.NewSize(w, calloutH-2*calloutPadY)) + text.Move(fyne.NewPos(x, bottom-calloutH+calloutPadY)) + text.Show() + } + + xLabelTexts := make([]string, n) + var maxXLabelW float32 + for i, v := range g.axis { + xLabelTexts[i] = strconv.FormatFloat(v, 'f', g.cfg.AxisPrecision, 64) + if s := fyne.MeasureText(xLabelTexts[i], tickTextSize, style); s.Width > maxXLabelW { + maxXLabelW = s.Width + } + } + labelSkip := 1 + if maxXLabelW+6 > xStep { + labelSkip = int(math.Ceil(float64((maxXLabelW + 6) / xStep))) + } + for i := 0; i < n; i++ { + label := r.xLabels[i] + if i%labelSkip != 0 { + label.Hide() + continue + } + label.Text = xLabelTexts[i] + label.Color = labelColor + s := fyne.MeasureText(label.Text, tickTextSize, style) + label.Resize(s) + x := cx(i) - s.Width/2 + if x < 0 { + x = 0 + } else if x+s.Width > size.Width { + x = size.Width - s.Width + } + label.Move(fyne.NewPos(x, plotBottom+4)) + label.Show() + } + + if r.axisLabel != nil { + r.axisLabel.Color = labelColor + s := fyne.MeasureText(r.axisLabel.Text, tickTextSize, style) + r.axisLabel.Resize(s) + r.axisLabel.Move(fyne.NewPos(padLeft+(plotW-s.Width)/2, plotBottom+4+xLabelH+2)) + } + + r.plotTop = plotTop + r.plotBottom = plotBottom + r.plotLeft = padLeft + r.xStep = xStep + r.positionCursor() +} + +func (r *graphRenderer) positionCursor() { + if !r.g.showCursor { + return + } + x := r.plotLeft + (float32(r.g.cursorIdx)+0.5)*r.xStep + r.cursor.Position1 = fyne.NewPos(x, r.plotTop) + r.cursor.Position2 = fyne.NewPos(x, r.plotBottom) + if r.cursor.Hidden { + r.cursor.Show() + } +} + +// niceStep returns a 1/2/5*10^n step so that the range is covered by at most +// maxTicks intervals. +func niceStep(rng float64, maxTicks int) float64 { + if maxTicks < 1 { + maxTicks = 1 + } + raw := rng / float64(maxTicks) + mag := math.Pow(10, math.Floor(math.Log10(raw))) + switch norm := raw / mag; { + case norm <= 1: + return mag + case norm <= 2: + return 2 * mag + case norm <= 5: + return 5 * mag + default: + return 10 * mag + } +} diff --git a/pkg/widgets/graph2d/graph2d_render_test.go b/pkg/widgets/graph2d/graph2d_render_test.go new file mode 100644 index 00000000..d207ae66 --- /dev/null +++ b/pkg/widgets/graph2d/graph2d_render_test.go @@ -0,0 +1,101 @@ +package graph2d + +import ( + "image/png" + "os" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + "github.com/roffe/txlogger/pkg/colors" +) + +// battery correction table from the T7Suite screenshot the widget mimics +func testGraph(t testing.TB) *Graph { + t.Helper() + return New(&Config{ + AxisData: []float64{5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, + Values: []float64{4590, 3605, 2785, 2145, 1755, 1495, 1295, 1145, 1025, 925, 845, 765}, + AxisPrecision: 0, + ValuePrecision: 0, + AxisLabel: "Battery voltage", + ColorblindMode: colors.ModeNormal, + }) +} + +func TestRender(t *testing.T) { + test.NewApp() + g := testGraph(t) + w := test.NewWindow(g) + defer w.Close() + w.Resize(fyne.NewSize(640, 420)) + + g.SetCursor(3.5) + g.SetValues(700, 4700, []float64{4700, 3605, 2785, 2145, 1755, 1495, 1295, 1145, 1025, 925, 845, 700}) + + img := w.Canvas().Capture() + if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 { + t.Fatal("captured empty image") + } + + if os.Getenv("GRAPH2D_DUMP") != "" { + f, err := os.Create("/tmp/graph2d.png") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + } +} + +// many cells in a small window exercise the callout stagger/skip and the +// x label thinning +func TestRenderCrowded(t *testing.T) { + test.NewApp() + values := make([]float64, 32) + for i := range values { + values[i] = 1000 + 500*float64(i%5) + } + g := New(&Config{ + Values: values, + ValuePrecision: 0, + ColorblindMode: colors.ModeNormal, + }) + w := test.NewWindow(g) + defer w.Close() + w.Resize(fyne.NewSize(400, 300)) + + img := w.Canvas().Capture() + if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 { + t.Fatal("captured empty image") + } + + if os.Getenv("GRAPH2D_DUMP") != "" { + f, err := os.Create("/tmp/graph2d_crowded.png") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + } +} + +// a mismatched axis must fall back to index labels and a flat line of equal +// values must not divide by zero +func TestRenderDegenerate(t *testing.T) { + test.NewApp() + g := New(&Config{ + Values: []float64{42, 42, 42, 42}, + ColorblindMode: colors.ModeNormal, + }) + w := test.NewWindow(g) + defer w.Close() + w.Resize(fyne.NewSize(300, 200)) + if img := w.Canvas().Capture(); img.Bounds().Dx() == 0 { + t.Fatal("captured empty image") + } +} diff --git a/pkg/widgets/grid/grid.go b/pkg/widgets/grid/grid.go index 674ddf4c..55126139 100644 --- a/pkg/widgets/grid/grid.go +++ b/pkg/widgets/grid/grid.go @@ -35,11 +35,12 @@ func New(cols, rows int) *Grid { } func (g *Grid) CreateRenderer() fyne.WidgetRenderer { - return &gridRenderer{g} + return &gridRenderer{G: g} } type gridRenderer struct { - *Grid + G *Grid + objects []fyne.CanvasObject } func (g *gridRenderer) MinSize() fyne.Size { @@ -47,17 +48,17 @@ func (g *gridRenderer) MinSize() fyne.Size { } func (g *gridRenderer) Layout(size fyne.Size) { - if size == g.lastSize { + if size == g.G.lastSize { return } - g.lastSize = size + g.G.lastSize = size - cellWidth := size.Width / float32(g.cols) - cellHeight := size.Height / float32(g.rows) + cellWidth := size.Width / float32(g.G.cols) + cellHeight := size.Height / float32(g.G.rows) // update vertical lines - for i := 0; i < g.cols; i++ { - l := g.lines[i] + for i := 0; i < g.G.cols; i++ { + l := g.G.lines[i] x := float32(i) * cellWidth l.Position1 = fyne.NewPos(x, 0) l.Position2 = fyne.NewPos(x, size.Height) @@ -65,9 +66,9 @@ func (g *gridRenderer) Layout(size fyne.Size) { } // update horizontal lines - offset := g.cols - for i := 0; i < g.rows; i++ { - l := g.lines[offset+i] + offset := g.G.cols + for i := 0; i < g.G.rows; i++ { + l := g.G.lines[offset+i] y := float32(i) * cellHeight l.Position1 = fyne.NewPos(0, y) l.Position2 = fyne.NewPos(size.Width, y) @@ -82,9 +83,11 @@ func (g *gridRenderer) Destroy() { } func (g *gridRenderer) Objects() []fyne.CanvasObject { - objs := make([]fyne.CanvasObject, len(g.lines)) - for i, l := range g.lines { - objs[i] = l + if g.objects == nil { + g.objects = make([]fyne.CanvasObject, len(g.G.lines)) + for i, l := range g.G.lines { + g.objects[i] = l + } } - return objs + return g.objects } diff --git a/pkg/widgets/hbar/hbar.go b/pkg/widgets/hbar/hbar.go index 64add695..de0fa3ab 100644 --- a/pkg/widgets/hbar/hbar.go +++ b/pkg/widgets/hbar/hbar.go @@ -130,10 +130,6 @@ func (s *HBar) SetValue(value float64) { } } -func (s *HBar) SetValue2(value float64) { - s.SetValue(value) -} - func (s *HBar) Value() float64 { return s.value } @@ -188,7 +184,7 @@ func (s *HBar) CreateRenderer() fyne.WidgetRenderer { s.lines[i] = line } - return &HBarRenderer{s} + return &HBarRenderer{HBar: s} } // getColorForValue returns fill & stroke color for an arbitrary gauge value. @@ -229,11 +225,12 @@ func (s *HBar) getColorForValue(value float64) (fillColor, strokeColor color.RGB } type HBarRenderer struct { - *HBar + HBar *HBar + objects []fyne.CanvasObject } func (r *HBarRenderer) MinSize() fyne.Size { - return r.cfg.MinSize + return r.HBar.cfg.MinSize } func (r *HBarRenderer) Refresh() { @@ -245,41 +242,41 @@ func (r *HBarRenderer) Destroy() { } func (r *HBarRenderer) Layout(space fyne.Size) { - if r.size == space { + if r.HBar.size == space { return } - r.size = space + r.HBar.size = space - r.layoutValues.middle = space.Height * 0.5 + r.HBar.layoutValues.middle = space.Height * 0.5 - stepFactor := float32(space.Width) / float32(r.cfg.Steps) + stepFactor := float32(space.Width) / float32(r.HBar.cfg.Steps) // Face layout - r.face.Move(fyne.Position{X: -2, Y: 0}) - r.face.Resize(space.AddWidthHeight(3, 1)) + r.HBar.face.Move(fyne.Position{X: -2, Y: 0}) + r.HBar.face.Resize(space.AddWidthHeight(3, 1)) // Title centered horizontally, just below bar - titleMinSize := r.titleText.MinSize() - r.titleText.Resize(fyne.Size{Width: space.Width, Height: titleMinSize.Height}) - r.titleText.Move(fyne.Position{ + titleMinSize := r.HBar.titleText.MinSize() + r.HBar.titleText.Resize(fyne.Size{Width: space.Width, Height: titleMinSize.Height}) + r.HBar.titleText.Move(fyne.Position{ X: 0, Y: space.Height - titleMinSize.Height, }) // Display text in the middle, centered vertically - displayMinSize := r.displayText.MinSize() - r.displayText.Resize(fyne.Size{Width: space.Width, Height: displayMinSize.Height}) - r.displayText.Move(fyne.Position{ + displayMinSize := r.HBar.displayText.MinSize() + r.HBar.displayText.Resize(fyne.Size{Width: space.Width, Height: displayMinSize.Height}) + r.HBar.displayText.Move(fyne.Position{ X: 0, - Y: r.layoutValues.middle - displayMinSize.Height*0.5, + Y: r.HBar.layoutValues.middle - displayMinSize.Height*0.5, }) // Tick lines layout (vertical lines across width) oneThird := space.Height * common.OneThird oneSeventh := space.Height * common.OneSeventh - middle := r.layoutValues.middle + middle := r.HBar.layoutValues.middle - for i, line := range r.lines { + for i, line := range r.HBar.lines { x := float32(i) * stepFactor if i%2 == 0 { line.Position1 = fyne.Position{X: x, Y: middle - oneThird} @@ -291,19 +288,23 @@ func (r *HBarRenderer) Layout(space fyne.Size) { } // Bar position is fixed at origin; set once here so SetValue can skip it. - r.bar.Move(fyne.Position{X: 0, Y: 0}) + r.HBar.bar.Move(fyne.Position{X: 0, Y: 0}) // Recompute bar geometry for current value using new size - norm := r.clampNorm(r.value) - barWidth := norm * float32(r.size.Width) - r.bar.Resize(fyne.Size{Width: barWidth, Height: r.size.Height}) + norm := r.HBar.clampNorm(r.HBar.value) + barWidth := norm * float32(r.HBar.size.Width) + r.HBar.bar.Resize(fyne.Size{Width: barWidth, Height: r.HBar.size.Height}) } func (r *HBarRenderer) Objects() []fyne.CanvasObject { - objs := make([]fyne.CanvasObject, 0, len(r.lines)+4) - for _, line := range r.lines { - objs = append(objs, line) + if r.objects == nil { + + objs := make([]fyne.CanvasObject, 0, len(r.HBar.lines)+4) + for _, line := range r.HBar.lines { + objs = append(objs, line) + } + objs = append(objs, r.HBar.bar, r.HBar.face, r.HBar.titleText, r.HBar.displayText) + r.objects = objs } - objs = append(objs, r.bar, r.face, r.titleText, r.displayText) - return objs + return r.objects } diff --git a/pkg/widgets/icon/icon.go b/pkg/widgets/icon/icon.go index d55fc347..8e6ee8de 100644 --- a/pkg/widgets/icon/icon.go +++ b/pkg/widgets/icon/icon.go @@ -46,22 +46,26 @@ func (ic *Icon) SetText(text string) { } func (ic *Icon) CreateRenderer() fyne.WidgetRenderer { - return &IconRenderer{ic} + return &IconRenderer{ + IC: ic, + objects: []fyne.CanvasObject{ic.cfg.Image, ic.text}, + } } type IconRenderer struct { - *Icon + IC *Icon + objects []fyne.CanvasObject } func (ic *IconRenderer) Layout(size fyne.Size) { - ic.cfg.Image.Move(fyne.NewPos(0, 0)) - ic.cfg.Image.Resize(ic.cfg.Minsize) - ic.text.Resize(fyne.NewSize(size.Width, 30)) - ic.text.Move(fyne.NewPos(14, 87)) + ic.IC.cfg.Image.Move(fyne.NewPos(0, 0)) + ic.IC.cfg.Image.Resize(ic.IC.cfg.Minsize) + ic.IC.text.Resize(fyne.NewSize(size.Width, 30)) + ic.IC.text.Move(fyne.NewPos(14, 87)) } func (ic *IconRenderer) MinSize() fyne.Size { - return ic.cfg.Minsize + return ic.IC.cfg.Minsize } func (ic *IconRenderer) Refresh() { @@ -71,5 +75,5 @@ func (ic *IconRenderer) Destroy() { } func (ic *IconRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{ic.cfg.Image, ic.text} + return ic.objects } diff --git a/pkg/widgets/igauge.go b/pkg/widgets/igauge.go deleted file mode 100644 index b7368da4..00000000 --- a/pkg/widgets/igauge.go +++ /dev/null @@ -1,12 +0,0 @@ -package widgets - -import ( - "fyne.io/fyne/v2" -) - -type IGauge interface { - fyne.Widget - SetValue(float64) - SetValue2(float64) - GetConfig() *GaugeConfig -} diff --git a/pkg/widgets/ledicon/ledicon.go b/pkg/widgets/ledicon/ledicon.go index f6e5cea6..6d74049f 100644 --- a/pkg/widgets/ledicon/ledicon.go +++ b/pkg/widgets/ledicon/ledicon.go @@ -25,7 +25,7 @@ func New(text string) *Widget { ColorOff: color.RGBA{0x80, 0x80, 0x80, 0xFF}, } w.ExtendBaseWidget(w) - w.ledicon = &canvas.Circle{FillColor: color.RGBA{0x80, 0x80, 0x80, 0xFF}} + w.ledicon = &canvas.Circle{FillColor: w.ColorOff} w.label = widget.NewLabel(text) return w } @@ -52,14 +52,17 @@ func (w *Widget) SetState(state bool) { } func (w *Widget) CreateRenderer() fyne.WidgetRenderer { - return &iconRenderer{w: w} - + return &iconRenderer{ + w: w, + objects: []fyne.CanvasObject{w.ledicon, w.label}, + } } var _ fyne.WidgetRenderer = (*iconRenderer)(nil) type iconRenderer struct { - w *Widget + w *Widget + objects []fyne.CanvasObject } func (r *iconRenderer) MinSize() fyne.Size { @@ -82,5 +85,5 @@ func (r *iconRenderer) Destroy() { } func (r *iconRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{r.w.ledicon, r.w.label} + return r.objects } diff --git a/pkg/widgets/liveplotter/liveplotter.go b/pkg/widgets/liveplotter/liveplotter.go new file mode 100644 index 00000000..9bce410b --- /dev/null +++ b/pkg/widgets/liveplotter/liveplotter.go @@ -0,0 +1,582 @@ +// Package liveplotter provides a scope-style widget that plots live EBUS values +// over a sliding time window (default 120s). It auto-follows the newest data and +// lets you scrub back into the retained history of the current pull. +// +// Unlike the logplayer's plotter, which renders an immutable log, the live +// plotter appends one sample per ECU frame: it subscribes to each selected +// symbol (storing the latest value) and to ebus.TOPIC_FRAME, which fires once per +// completed frame carrying that frame's real timestamp. On each frame it snapshots +// every symbol's latest value with the shared timestamp, so all series stay time +// aligned. It reuses the plotter package's primitives (TimeSeries + PlotImage +// rasterizer and the TappableText legend) but owns its own data model and layout. +package liveplotter + +import ( + "fmt" + "image" + "image/color" + "sort" + "sync" + "sync/atomic" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/ebus" + "github.com/roffe/txlogger/pkg/widgets/plotter" +) + +var ( + _ fyne.Widget = (*Widget)(nil) + _ fyne.Draggable = (*Widget)(nil) + _ fyne.Scrollable = (*Widget)(nil) +) + +const defaultWindow = 120 * time.Second + +// Config configures a live plotter. +type Config struct { + // Order is the set of symbol names to plot, in legend order. + Order []string + // Window is the sliding time window length. Defaults to 120s when zero. + Window time.Duration +} + +type Widget struct { + widget.BaseWidget + + order []string + ts []*plotter.TimeSeries + + // mu guards the raw sample store and latest values, which are written from + // the logging goroutine (subscriptions) and read on the UI goroutine (refresh). + mu sync.Mutex + latest map[string]float64 + times []int64 // frame timestamps, Unix millis, ascending + values map[string][]float64 // per-series samples, parallel to times + view map[string][]float64 // reusable per-render windowed copy (UI goroutine) + + windowMillis int64 + + // UI-goroutine-only view state. + follow bool + anchorMillis int64 // right edge timestamp when paused + lastNewest int64 + viewCount int + hilightLine int + + // mouse cursor state (UI goroutine only). When mouseInPlot the vertical + // cursor and the legend track cursorX (plot-relative pixels); otherwise the + // cursor sits at the right edge and the legend shows the newest/frozen value. + mouseInPlot bool + cursorX float32 + + // canvas objects + canvasImage *canvas.Image + cursor *canvas.Line + overlayText *canvas.Text + plotObj fyne.CanvasObject + zoom *widget.Slider + legend *fyne.Container + legendTexts []*plotter.TappableText + legendVals []float64 + windowSel *widget.Select + followBtn *widget.Button + split *container.Split + root *fyne.Container + + plotResolution fyne.Size + size fyne.Size + + imgBuffers [2]*image.RGBA + imgIndex int + + refreshPending atomic.Bool + // paused mirrors !follow for the logging goroutine (which can't touch the + // UI-only follow field): while set, onFrame keeps appending but skips + // compaction so the frozen history is never trimmed until playback resumes. + paused atomic.Bool + + cancels []func() + closeOnce sync.Once + closed atomic.Bool +} + +func New(cfg *Config) *Widget { + window := cfg.Window + if window <= 0 { + window = defaultWindow + } + + p := &Widget{ + order: append([]string(nil), cfg.Order...), + latest: make(map[string]float64, len(cfg.Order)), + values: make(map[string][]float64, len(cfg.Order)), + view: make(map[string][]float64, len(cfg.Order)), + windowMillis: window.Milliseconds(), + follow: true, + hilightLine: -1, + legend: container.NewVBox(), + zoom: widget.NewSlider(1, 100), + canvasImage: canvas.NewImageFromImage(image.NewRGBA(image.Rect(0, 0, 400, 200))), + cursor: canvas.NewLine(color.White), + } + p.ExtendBaseWidget(p) + + p.canvasImage.FillMode = canvas.ImageFillContain + p.canvasImage.ScaleMode = canvas.ImageScaleFastest + + p.zoom.Orientation = widget.Vertical + p.zoom.Value = 100 // show the whole window by default + p.zoom.OnChanged = func(float64) { p.scheduleRefresh() } + + p.overlayText = canvas.NewText("", color.White) + p.overlayText.TextSize = 25 + + p.ts = make([]*plotter.TimeSeries, len(p.order)) + p.legendVals = make([]float64, len(p.order)) + for n, name := range p.order { + p.values[name] = make([]float64, 0, 4096) + p.ts[n] = plotter.NewSeries(name) + p.legendTexts = append(p.legendTexts, p.newLegendText(n, name)) + p.legend.Add(p.legendTexts[n]) + } + + p.plotObj = p.canvasImage + p.subscribe() + + return p +} + +func (p *Widget) newLegendText(n int, name string) *plotter.TappableText { + onTapped := func(enabled bool) { + p.ts[n].Enabled = enabled + p.scheduleRefresh() + } + onColorUpdate := func(col color.Color) { + r, g, b, a := col.RGBA() + p.ts[n].Color = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} + p.scheduleRefresh() + } + onHover := func(hover bool) { + if hover { + p.overlayText.Text = name + p.overlayText.Color = p.ts[n].Color + p.hilightLine = n + } else { + p.overlayText.Text = "" + p.hilightLine = -1 + } + p.scheduleRefresh() + } + return plotter.NewTappableText(name, p.ts[n].Color, onTapped, onColorUpdate, onHover) +} + +// subscribe wires the per-symbol latest-value updates and the frame tick. The +// callbacks run on the logging goroutine and must stay fast, so they only touch +// the guarded store; the redraw is coalesced onto the UI goroutine. +func (p *Widget) subscribe() { + for _, name := range p.order { + name := name + p.cancels = append(p.cancels, ebus.CONTROLLER.SubscribeFunc(name, func(v float64) { + p.mu.Lock() + p.latest[name] = v + p.mu.Unlock() + })) + } + p.cancels = append(p.cancels, ebus.CONTROLLER.SubscribeFunc(ebus.TOPIC_FRAME, p.onFrame)) +} + +func (p *Widget) onFrame(tMillis float64) { + if p.closed.Load() { + return + } + p.ingest(int64(tMillis), p.paused.Load()) + p.scheduleRefresh() +} + +// ingest appends one frame (the latest value of every series at timestamp t) and +// compacts unless paused. Split out from onFrame so the windowing/retention can +// be tested without a running UI. +func (p *Widget) ingest(t int64, paused bool) { + p.mu.Lock() + defer p.mu.Unlock() + // Guard against out-of-order/duplicate timestamps so times stays ascending. + if n := len(p.times); n > 0 && t <= p.times[n-1] { + t = p.times[n-1] + 1 + } + p.times = append(p.times, t) + for _, name := range p.order { + p.values[name] = append(p.values[name], p.latest[name]) + } + if !paused { + p.compactLocked() + } +} + +// viewRange returns the [start,end) sample indices to display for the visible +// span. In follow mode the right edge tracks the newest sample; when paused it +// is frozen at anchor, clamped only to the data we still hold (never pushed +// forward). The returned rightT is the clamped right-edge timestamp. +func viewRange(times []int64, follow bool, anchor, span int64) (start, end int, rightT int64) { + n := len(times) + newest, oldest := times[n-1], times[0] + if follow { + rightT = newest + } else { + rightT = anchor + if rightT > newest { + rightT = newest + } + if rightT < oldest { + rightT = oldest + } + } + leftT := rightT - span + if leftT < oldest { + leftT = oldest + } + start = sort.Search(n, func(i int) bool { return times[i] >= leftT }) + end = sort.Search(n, func(i int) bool { return times[i] > rightT }) + if end <= start { + end = start + 1 + } + return start, end, rightT +} + +// compactLocked drops samples older than the window once the retained span grows +// past 2x the window, so memory stays bounded and the cost is amortized O(1) per +// frame. Caller holds p.mu. +func (p *Widget) compactLocked() { + n := len(p.times) + if n < 2 || p.times[n-1]-p.times[0] <= 2*p.windowMillis { + return + } + cutoff := p.times[n-1] - p.windowMillis + cut := sort.Search(n, func(i int) bool { return p.times[i] >= cutoff }) + if cut <= 0 { + return + } + p.times = append(p.times[:0], p.times[cut:]...) + for _, name := range p.order { + s := p.values[name] + p.values[name] = append(s[:0], s[cut:]...) + } +} + +// scheduleRefresh coalesces redraws onto the UI goroutine: while one is pending, +// further calls only return, so the draw rate is bounded by what the UI can keep +// up with rather than the frame rate (mirrors plotter.Plotter.Seek). +func (p *Widget) scheduleRefresh() { + if p.closed.Load() { + return + } + if !p.refreshPending.CompareAndSwap(false, true) { + return + } + fyne.Do(func() { + p.refreshPending.Store(false) + p.refresh() + }) +} + +// spanMillis is the visible time span derived from the zoom slider, clamped to +// the window. Zoom at max shows the whole window; scrolling in shrinks it. +func (p *Widget) spanMillis() int64 { + span := int64(float64(p.windowMillis) * p.zoom.Value / p.zoom.Max) + if span > p.windowMillis { + span = p.windowMillis + } + if span < 1000 { + span = 1000 + } + return span +} + +// refresh recomputes the visible window, copies it out under the lock, and +// redraws. Runs on the UI goroutine. +func (p *Widget) refresh() { + span := p.spanMillis() + + p.mu.Lock() + n := len(p.times) + if n < 2 { + p.mu.Unlock() + p.viewCount = 0 + p.drawImage() + p.canvasImage.Refresh() + return + } + p.lastNewest = p.times[n-1] + + startIdx, endIdx, rightT := viewRange(p.times, p.follow, p.anchorMillis, span) + if !p.follow { + p.anchorMillis = rightT + } + for _, name := range p.order { + src := p.values[name][startIdx:endIdx] + p.view[name] = append(p.view[name][:0], src...) + } + p.mu.Unlock() + + p.viewCount = endIdx - startIdx + + // Auto-range unknown signals from the visible window so they stay on screen. + for _, ts := range p.ts { + if !ts.Auto || !ts.Enabled { + continue + } + data := p.view[ts.Name] + if len(data) == 0 { + continue + } + mn, mx := data[0], data[0] + for _, v := range data { + if v < mn { + mn = v + } + if v > mx { + mx = v + } + } + if mn == mx { + mn -= 1 + mx += 1 + } else { + pad := (mx - mn) * 0.05 + mn -= pad + mx += pad + } + ts.SetRange(mn, mx) + } + + p.computeLegendVals() + p.drawImage() + p.updateLegendValues() + p.layoutCursor() + p.cursor.Refresh() + p.canvasImage.Refresh() +} + +// computeLegendVals fills legendVals from the visible window: the sample under +// the mouse cursor when hovering, otherwise the right edge (newest when live, +// frozen value when paused). +func (p *Widget) computeLegendVals() { + if p.viewCount <= 0 { + return + } + idx := p.viewCount - 1 + if p.mouseInPlot { + if plotW := p.plotObj.Size().Width; plotW > 0 { + frac := float64(p.cursorX) / float64(plotW) + idx = int(frac*float64(p.viewCount-1) + 0.5) + if idx < 0 { + idx = 0 + } + if idx > p.viewCount-1 { + idx = p.viewCount - 1 + } + } + } + for i, name := range p.order { + if v := p.view[name]; idx < len(v) { + p.legendVals[i] = v[idx] + } + } +} + +func (p *Widget) updateLegendValues() { + for i := range p.order { + newValue := fmt.Sprintf("%.4g", p.legendVals[i]) + obj := p.legendTexts[i] + if obj.Value() == newValue { + continue + } + obj.SetValue(newValue) + } +} + +// drawImage rasterizes the visible window into a reused buffer via the plotter's +// TimeSeries.PlotImage, exactly like the plotter image backend. +func (p *Widget) drawImage() { + w, h := int(p.plotResolution.Width), int(p.plotResolution.Height) + if w <= 0 || h <= 0 { + return + } + p.imgIndex ^= 1 + img := p.imgBuffers[p.imgIndex] + if img == nil || img.Rect.Dx() != w || img.Rect.Dy() != h { + img = image.NewRGBA(image.Rect(0, 0, w, h)) + p.imgBuffers[p.imgIndex] = img + } else { + clear(img.Pix) + } + + if p.viewCount >= 2 { + for n := range p.ts { + if !p.ts[n].Enabled || p.hilightLine == n { + continue + } + p.ts[n].PlotImage(img, p.view, 0, p.viewCount, 1) + } + if p.hilightLine >= 0 && p.ts[p.hilightLine].Enabled { + p.ts[p.hilightLine].PlotImage(img, p.view, 0, p.viewCount, 4) + } + } + + p.canvasImage.Image = img +} + +// layoutCursor positions the vertical cursor: under the mouse while hovering the +// plot, otherwise at the right edge as a "now" line. +func (p *Widget) layoutCursor() { + plotSize := p.plotObj.Size() + zw := p.zoom.Size().Width + var x float32 + if p.mouseInPlot { + x = zw + p.cursorX + } else { + x = zw + plotSize.Width - 1 + } + p.cursor.Position1 = fyne.NewPos(x, 0) + p.cursor.Position2 = fyne.NewPos(x+1, plotSize.Height) +} + +func (p *Widget) setFollow(follow bool) { + p.follow = follow + p.paused.Store(!follow) + // The button is labelled with the action it performs, not the current state: + // while live it offers Pause; while paused it offers Go Live. + if follow { + p.followBtn.SetIcon(theme.MediaPauseIcon()) + p.followBtn.SetText("Pause") + } else { + p.anchorMillis = p.lastNewest + p.followBtn.SetIcon(theme.MediaPlayIcon()) + p.followBtn.SetText("Go Live") + } + p.scheduleRefresh() +} + +func (p *Widget) Close() { + p.closeOnce.Do(func() { + p.closed.Store(true) + for _, cancel := range p.cancels { + cancel() + } + p.cancels = nil + }) +} + +func (p *Widget) CreateRenderer() fyne.WidgetRenderer { + // Initial state is live (following), so the button offers the Pause action. + p.followBtn = widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() { + p.setFollow(!p.follow) + }) + + p.windowSel = widget.NewSelect([]string{"15s", "30s", "60s", "120s", "300s"}, func(s string) { + var d time.Duration + switch s { + case "15s": + d = 15 * time.Second + case "30s": + d = 30 * time.Second + case "60s": + d = 60 * time.Second + case "120s": + d = 120 * time.Second + case "300s": + d = 300 * time.Second + } + if d > 0 { + p.mu.Lock() + p.windowMillis = d.Milliseconds() + p.mu.Unlock() + p.scheduleRefresh() + } + }) + p.windowSel.Selected = fmt.Sprintf("%ds", p.windowMillis/1000) + + toggleVisibleBtn := widget.NewButton("Toggle visible", func() { + for _, ts := range p.legendTexts { + ts.Tapped(&fyne.PointEvent{}) + } + }) + + leading := container.NewBorder( + nil, nil, p.zoom, nil, + container.New(&livePlotLayout{p: p}, p.plotObj), + ) + p.split = container.NewHSplit( + leading, + container.NewBorder( + nil, toggleVisibleBtn, nil, nil, + container.NewVScroll(p.legend), + ), + ) + p.split.Offset = 0.90 + + controls := container.NewHBox( + p.followBtn, + widget.NewLabel("Window"), + p.windowSel, + ) + + p.root = container.NewBorder(nil, controls, nil, nil, p.split) + + return &liveRenderer{p: p} +} + +type liveRenderer struct { + p *Widget + objects []fyne.CanvasObject +} + +func (r *liveRenderer) MinSize() fyne.Size { return r.p.root.MinSize() } + +func (r *liveRenderer) Layout(size fyne.Size) { + if r.p.size == size { + return + } + r.p.size = size + r.p.root.Resize(size) +} + +func (r *liveRenderer) Refresh() {} + +func (r *liveRenderer) Destroy() { r.p.Close() } + +func (r *liveRenderer) Objects() []fyne.CanvasObject { + if r.objects == nil { + r.objects = []fyne.CanvasObject{r.p.root, r.p.overlayText, r.p.cursor} + } + return r.objects +} + +// livePlotLayout sizes the plot canvas and recomputes the render resolution and +// cursor whenever the plot area changes (mirrors plotter.plotLayout). +type livePlotLayout struct { + p *Widget + oldSize fyne.Size +} + +func (t *livePlotLayout) Layout(_ []fyne.CanvasObject, plotSize fyne.Size) { + if t.oldSize == plotSize { + return + } + t.oldSize = plotSize + + t.p.overlayText.Move(fyne.NewPos(t.p.zoom.Size().Width, 20)) + t.p.plotObj.Resize(plotSize) + t.p.plotResolution = fyne.NewSize(plotSize.Width, plotSize.Height) + t.p.refresh() + t.p.layoutCursor() + t.p.cursor.Refresh() +} + +func (t *livePlotLayout) MinSize([]fyne.CanvasObject) fyne.Size { + return fyne.NewSize(400, 100) +} diff --git a/pkg/widgets/liveplotter/liveplotter_mouse.go b/pkg/widgets/liveplotter/liveplotter_mouse.go new file mode 100644 index 00000000..4b49824f --- /dev/null +++ b/pkg/widgets/liveplotter/liveplotter_mouse.go @@ -0,0 +1,73 @@ +package liveplotter + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver/desktop" +) + +var _ desktop.Hoverable = (*Widget)(nil) + +// Scrolled zooms the visible time span: scrolling up zooms in (shorter span), +// down zooms out toward the full window. +func (p *Widget) Scrolled(event *fyne.ScrollEvent) { + if event.Scrolled.DY > 0 { + p.zoom.SetValue(p.zoom.Value - 5) + } else { + p.zoom.SetValue(p.zoom.Value + 5) + } +} + +// Dragged pans the view back in time, pausing the live follow. Dragging right +// reveals older data; the anchored right edge is clamped to the retained window +// in refresh. +func (p *Widget) Dragged(event *fyne.DragEvent) { + plotW := p.plotObj.Size().Width + if plotW <= 0 { + return + } + if p.follow { + p.setFollow(false) + } + deltaMillis := int64(float64(event.Dragged.DX) / float64(plotW) * float64(p.spanMillis())) + p.anchorMillis -= deltaMillis + p.scheduleRefresh() +} + +func (p *Widget) DragEnd() {} + +// MouseIn / MouseMoved track the pointer over the plot so the cursor line and +// legend follow it. MouseOut returns the cursor to the right edge and the legend +// to the newest/frozen value. +func (p *Widget) MouseIn(event *desktop.MouseEvent) { p.onMouse(event.Position) } + +func (p *Widget) MouseMoved(event *desktop.MouseEvent) { p.onMouse(event.Position) } + +func (p *Widget) MouseOut() { + p.mouseInPlot = false + p.refreshCursorAndLegend() +} + +// onMouse maps a pointer position (widget-local) onto the plot and updates the +// cursor + legend. Runs on the UI goroutine. +func (p *Widget) onMouse(pos fyne.Position) { + plotW := p.plotObj.Size().Width + relX := pos.X - p.zoom.Size().Width + if relX < 0 { + relX = 0 + } + if relX > plotW { + relX = plotW + } + p.cursorX = relX + p.mouseInPlot = true + p.refreshCursorAndLegend() +} + +// refreshCursorAndLegend repositions the cursor and updates the legend readout +// without redrawing the trace (the samples are unchanged on a mouse move). +func (p *Widget) refreshCursorAndLegend() { + p.computeLegendVals() + p.updateLegendValues() + p.layoutCursor() + p.cursor.Refresh() +} diff --git a/pkg/widgets/liveplotter/liveplotter_test.go b/pkg/widgets/liveplotter/liveplotter_test.go new file mode 100644 index 00000000..422d61af --- /dev/null +++ b/pkg/widgets/liveplotter/liveplotter_test.go @@ -0,0 +1,103 @@ +package liveplotter + +import "testing" + +// newBare builds a Widget with only the fields the data path needs, so the +// windowing/retention logic can be exercised without a Fyne app. +func newBare(order []string, windowMillis int64) *Widget { + p := &Widget{ + order: order, + windowMillis: windowMillis, + follow: true, + latest: map[string]float64{}, + values: map[string][]float64{}, + } + for _, n := range order { + p.values[n] = nil + } + return p +} + +// span at full zoom equals the window. +func fullSpan(p *Widget) int64 { return p.windowMillis } + +func TestFollowWindowSlides(t *testing.T) { + const window = 120_000 // 120s + p := newBare([]string{"a"}, window) + + // 300s of frames at 10Hz. + const hz = 10 + const dur = 300_000 + for ms := int64(0); ms <= dur; ms += 1000 / hz { + p.latest["a"] = float64(ms) + p.ingest(ms, false /*not paused*/) + } + + newest := p.times[len(p.times)-1] + oldest := p.times[0] + + // Retention: compaction keeps at most ~2x the window, and never less than + // the window once full. + span := newest - oldest + if span > 2*window+1000 { + t.Fatalf("retained span %dms exceeds 2x window", span) + } + if span < window-1000 { + t.Fatalf("retained span %dms dropped below window", span) + } + + // Visible window in follow mode: right edge at newest, ~window wide, and the + // oldest visible sample is well after the very first frame (old data phased + // out). + start, end, rightT := viewRange(p.times, true, 0, fullSpan(p)) + if rightT != newest { + t.Fatalf("follow right edge = %d, want newest %d", rightT, newest) + } + if start == 0 { + t.Fatalf("visible window still starts at index 0; old entries never phased out") + } + visibleSpan := p.times[end-1] - p.times[start] + if visibleSpan > window+1000 || visibleSpan < window-2000 { + t.Fatalf("visible span %dms, want ~%dms", visibleSpan, window) + } +} + +func TestPausedFreezesAndRetains(t *testing.T) { + const window = 120_000 + p := newBare([]string{"a"}, window) + + for ms := int64(0); ms <= 200_000; ms += 100 { + p.latest["a"] = float64(ms) + p.ingest(ms, false) + } + + // Pause: anchor at the current newest, then keep ingesting without compaction. + p.follow = false + anchor := p.times[len(p.times)-1] + preLen := len(p.times) + for ms := int64(200_100); ms <= 600_000; ms += 100 { + p.latest["a"] = float64(ms) + p.ingest(ms, true /*paused*/) + } + + // Frozen view: right edge stays at the anchor regardless of new data. + start, end, rightT := viewRange(p.times, false, anchor, fullSpan(p)) + if rightT != anchor { + t.Fatalf("paused right edge = %d, want anchor %d", rightT, anchor) + } + if got := p.times[end-1]; got > anchor { + t.Fatalf("frozen view includes sample at %d past anchor %d", got, anchor) + } + visibleSpan := p.times[end-1] - p.times[start] + if visibleSpan < window-2000 { + t.Fatalf("frozen visible span %dms collapsed below window", visibleSpan) + } + + // Retention halted: nothing was trimmed while paused. + if len(p.times) <= preLen { + t.Fatalf("paused buffer did not grow: pre=%d post=%d", preLen, len(p.times)) + } + if p.times[0] != 0 { + t.Fatalf("paused buffer trimmed old data: oldest=%d, want 0", p.times[0]) + } +} diff --git a/pkg/widgets/logplayer/logplayer.go b/pkg/widgets/logplayer/logplayer.go index 0a6d55c0..4e83b618 100644 --- a/pkg/widgets/logplayer/logplayer.go +++ b/pkg/widgets/logplayer/logplayer.go @@ -1,6 +1,7 @@ package logplayer import ( + "fmt" "sync" "time" @@ -9,8 +10,8 @@ import ( "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/bus" "github.com/roffe/txlogger/pkg/capture" - "github.com/roffe/txlogger/pkg/eventbus" "github.com/roffe/txlogger/pkg/layout" "github.com/roffe/txlogger/pkg/logfile" "github.com/roffe/txlogger/pkg/widgets/plotter" @@ -57,6 +58,11 @@ type Logplayer struct { OnMouseDown func() + // selStart and selEnd mark the in/out points of the export selection. + // A value of -1 means the point has not been set yet. + selStart int + selEnd int + focused bool closed bool } @@ -70,12 +76,20 @@ type logplayerObjects struct { positionSlider *slider timeLabel *widget.Label speedSelect *widget.Select + setInBtn *widget.Button + setOutBtn *widget.Button + exportBtn *widget.Button + selectionLabel *widget.Label } type Config struct { - EBus *eventbus.Controller - Logfile logfile.Logfile - TimeSetter func(time.Time) + EBus *bus.Controller[string, float64] + Logfile logfile.Logfile + TimeSetter func(time.Time) + PlotterRenderer plotter.PlotBackend + // OnExport, when set, is called with the records of the selected range when + // the user exports a selection. When nil the selection controls are hidden. + OnExport func(records []logfile.Record) } func New(cfg *Config) *Logplayer { @@ -88,7 +102,9 @@ func New(cfg *Config) *Logplayer { objs: &logplayerObjects{ positionSlider: NewSlider(), }, - logFile: cfg.Logfile, + logFile: cfg.Logfile, + selStart: -1, + selEnd: -1, } lp.ExtendBaseWidget(lp) @@ -168,7 +184,16 @@ func (l *Logplayer) TypedKey(ev *fyne.KeyEvent) { } } -func (l *Logplayer) TypedRune(_ rune) { +func (l *Logplayer) TypedRune(r rune) { + if l.closed { + return + } + switch r { + case 'i', 'I': + l.setSelectionStart() + case 'o', 'O': + l.setSelectionEnd() + } } func (l *Logplayer) control(op *controlMsg) { @@ -229,6 +254,14 @@ func (l *Logplayer) render() { l.control(&controlMsg{Op: OpNext}) }) + l.objs.setInBtn = widget.NewButton("In", l.setSelectionStart) + l.objs.setOutBtn = widget.NewButton("Out", l.setSelectionEnd) + l.objs.exportBtn = widget.NewButtonWithIcon("Save selection", theme.DocumentSaveIcon(), l.exportSelection) + l.objs.exportBtn.Disable() + l.objs.selectionLabel = widget.NewLabel("") + l.updateSelectionLabel() + + n := l.logFile.Len() values := make(map[string][]float64) for { if rec := l.logFile.Next(); !rec.EOF { @@ -236,7 +269,11 @@ func (l *Logplayer) render() { if k == "Pgm_status" { continue } - values[k] = append(values[k], v) + s, ok := values[k] + if !ok { + s = make([]float64, 0, n) // ponytail: cap once, kills append regrowth churn + } + values[k] = append(s, v) } } else { break @@ -258,33 +295,52 @@ func (l *Logplayer) render() { l.objs.positionSlider.Refresh() l.control(&controlMsg{Op: OpSeek, Pos: int(pos)}) }), + plotter.WithRenderer(l.cfg.PlotterRenderer), ) } func (l *Logplayer) CreateRenderer() fyne.WidgetRenderer { - l.container = container.NewBorder( + controls := container.NewBorder( + nil, + nil, + container.NewGridWithColumns(4, + l.objs.rewindBtn, + l.objs.playbackToggleBtn, + l.objs.forwardBtn, + l.objs.restartBtn, + ), nil, container.NewBorder( nil, nil, - container.NewGridWithColumns(4, - l.objs.rewindBtn, - l.objs.playbackToggleBtn, - l.objs.forwardBtn, - l.objs.restartBtn, - ), nil, - container.NewBorder( - nil, - nil, - nil, - container.NewHBox( - layout.NewFixedWidth(85, l.objs.timeLabel), - layout.NewFixedWidth(75, l.objs.speedSelect), - ), - l.objs.positionSlider, + container.NewHBox( + layout.NewFixedWidth(85, l.objs.timeLabel), + layout.NewFixedWidth(75, l.objs.speedSelect), ), + l.objs.positionSlider, ), + ) + + var bottom fyne.CanvasObject = controls + if l.cfg.OnExport != nil { + selection := container.NewBorder( + nil, + nil, + container.NewHBox( + l.objs.setInBtn, + l.objs.setOutBtn, + l.objs.exportBtn, + ), + nil, + l.objs.selectionLabel, + ) + bottom = container.NewVBox(selection) + } + + l.container = container.NewBorder( + bottom, + controls, nil, nil, l.objs.plotter, @@ -300,7 +356,8 @@ func (l *Logplayer) CreateRenderer() fyne.WidgetRenderer { } type LogplayerRenderer struct { - l *Logplayer + l *Logplayer + objects []fyne.CanvasObject } func (lr *LogplayerRenderer) Layout(space fyne.Size) { @@ -315,7 +372,10 @@ func (lr *LogplayerRenderer) Refresh() { } func (lr *LogplayerRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{lr.l.container} + if lr.objects == nil { + lr.objects = []fyne.CanvasObject{lr.l.container} + } + return lr.objects } func (lr *LogplayerRenderer) Destroy() { @@ -361,12 +421,116 @@ func (l *Logplayer) togglePlayback() { } } +// setSelectionStart marks the in point of the export selection at the current +// playback position. +func (l *Logplayer) setSelectionStart() { + l.selStart = int(l.objs.positionSlider.Value) + l.updateSelectionLabel() +} + +// setSelectionEnd marks the out point of the export selection at the current +// playback position. +func (l *Logplayer) setSelectionEnd() { + l.selEnd = int(l.objs.positionSlider.Value) + l.updateSelectionLabel() +} + +// selectionRange returns the normalized [lo, hi] record indices of the current +// selection. An unset in point defaults to the start of the log and an unset +// out point to the end. The bounds are clamped to the log and swapped if the +// out point was set before the in point. +func (l *Logplayer) selectionRange() (int, int) { + last := l.logFile.Len() - 1 + lo, hi := l.selStart, l.selEnd + if lo == -1 { + lo = 0 + } + if hi == -1 { + hi = last + } + if lo > hi { + lo, hi = hi, lo + } + if lo < 0 { + lo = 0 + } + if hi > last { + hi = last + } + return lo, hi +} + +func (l *Logplayer) updateSelectionLabel() { + if l.objs.selectionLabel == nil { + return + } + inTxt, outTxt := "—", "—" + if l.selStart != -1 { + inTxt = l.logFile.RecordAt(l.selStart).Time.Format("15:04:05.00") + } + if l.selEnd != -1 { + outTxt = l.logFile.RecordAt(l.selEnd).Time.Format("15:04:05.00") + } + + if l.selStart == -1 && l.selEnd == -1 { + l.objs.selectionLabel.SetText("In — Out —") + l.objs.exportBtn.Disable() + return + } + + lo, hi := l.selectionRange() + l.objs.selectionLabel.SetText(fmt.Sprintf("In %s Out %s (%d samples)", inTxt, outTxt, hi-lo+1)) + l.objs.exportBtn.Enable() +} + +// exportSelection collects the records of the current selection and hands them +// to the OnExport callback so they can be written to a new logfile. +func (l *Logplayer) exportSelection() { + if l.cfg.OnExport == nil || l.logFile.Len() == 0 { + return + } + lo, hi := l.selectionRange() + records := make([]logfile.Record, 0, hi-lo+1) + for i := lo; i <= hi; i++ { + records = append(records, l.logFile.RecordAt(i)) + } + l.cfg.OnExport(records) +} + +// frameTarget returns the wall-clock instant a record should play at, given an +// anchor (anchorWall pinned to anchorRec) and a speed multiplier where larger +// means slower (1 = real time). Because the target is computed from the record's +// own timestamp relative to the fixed anchor, scheduling jitter on earlier +// frames does not shift it — error stays bounded instead of accumulating. +func frameTarget(anchorWall, anchorRec, recTime time.Time, speed float64) time.Time { + return anchorWall.Add(time.Duration(float64(recTime.Sub(anchorRec)) * speed)) +} + func (l *Logplayer) playLog() { speedMultiplier := 1.0 timer := time.NewTimer(0) defer timer.Stop() - var nextDelay time.Duration + // Absolute scheduling: anchorWall maps to anchorRec (the wall-clock instant + // the current record's timeline position was pinned). Every frame is + // scheduled against its record's true timestamp relative to this anchor, so + // per-frame scheduling jitter cannot accumulate into drift — a late frame + // fires immediately and re-syncs instead of pushing the whole timeline back. + var anchorWall, anchorRec time.Time + + // reanchor pins the timeline to the current record at the current instant. + // Called whenever playback (re)starts or jumps: play, seek, step, speed change. + reanchor := func() { + anchorWall = time.Now() + anchorRec = l.logFile.Get().Time + } + + // schedule arms the timer for the next record using its real timestamp. + // time.Until goes negative when we're behind, firing immediately to catch up. + schedule := func() { + next := l.logFile.RecordAt(l.logFile.Pos() + 1) + timer.Reset(time.Until(frameTarget(anchorWall, anchorRec, next.Time, speedMultiplier))) + } timeSetter := func(t time.Time) { timeText := t.Format("15:04:05.00") @@ -384,6 +548,10 @@ func (l *Logplayer) playLog() { switch op.Op { case OpPlaybackSpeed: speedMultiplier = op.Rate + if l.state == statePlaying { + reanchor() + schedule() + } case OpSeek: l.logFile.Seek(op.Pos) if rec := l.logFile.Get(); !rec.EOF { @@ -393,12 +561,14 @@ func (l *Logplayer) playLog() { f(rec.Time) } if l.state == statePlaying { - timer.Reset(0) + reanchor() + schedule() } else { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) } timeSetter(rec.Time) + l.cfg.EBus.Publish("__frame__", float64(rec.Time.UnixMilli())) timer.Stop() } l.objs.plotter.Seek(op.Pos) @@ -413,11 +583,13 @@ func (l *Logplayer) playLog() { }) if l.state == statePlaying { - timer.Reset(0) + reanchor() + schedule() } else { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) } + l.cfg.EBus.Publish("__frame__", float64(rec.Time.UnixMilli())) } l.objs.plotter.Seek(pos) @@ -432,11 +604,13 @@ func (l *Logplayer) playLog() { l.objs.positionSlider.Value = float64(pos) timeSetter(rec.Time) if l.state == statePlaying { - timer.Reset(0) + reanchor() + schedule() } else { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) } + l.cfg.EBus.Publish("__frame__", float64(rec.Time.UnixMilli())) } if f := l.cfg.TimeSetter; f != nil { f(rec.Time) @@ -445,7 +619,8 @@ func (l *Logplayer) playLog() { } case OpPlay: l.state = statePlaying - timer.Reset(0) // Start playback immediately + reanchor() + schedule() case OpPause: l.state = statePaused timer.Stop() @@ -457,8 +632,7 @@ func (l *Logplayer) playLog() { } if rec := l.logFile.Next(); !rec.EOF { currentPos := l.logFile.Pos() - nextDelay = time.Duration(float64(rec.DelayTillNext)*speedMultiplier) * time.Millisecond - timer.Reset(nextDelay) + schedule() l.objs.positionSlider.Value = float64(currentPos) timeText := rec.Time.Format("15:04:05.00") @@ -469,6 +643,7 @@ func (l *Logplayer) playLog() { for k, v := range rec.Values { l.cfg.EBus.Publish(k, v) } + l.cfg.EBus.Publish("__frame__", float64(rec.Time.UnixMilli())) if f := l.cfg.TimeSetter; f != nil { f(rec.Time) } diff --git a/pkg/widgets/logplayer/playback_timing_test.go b/pkg/widgets/logplayer/playback_timing_test.go new file mode 100644 index 00000000..d69f2c0d --- /dev/null +++ b/pkg/widgets/logplayer/playback_timing_test.go @@ -0,0 +1,61 @@ +package logplayer + +import ( + "testing" + "time" +) + +// TestFrameTargetNoDrift simulates a jittery scheduler (every wake-up is a few ms +// late, as on a loaded Windows box) and checks that absolute scheduling keeps the +// per-frame timing error bounded, while the old relative-reset scheme accumulates +// it into unbounded drift. Same jitter, two schemes. +func TestFrameTargetNoDrift(t *testing.T) { + const ( + frames = 5000 + gap = 10 * time.Millisecond // recording cadence + jitter = 2 * time.Millisecond // how late each wake-up fires + speed = 1.0 // real time + ) + + start := time.Unix(0, 0) + recTime := func(i int) time.Time { return start.Add(time.Duration(i) * gap) } + + // Absolute: target is recomputed from the fixed anchor every frame. + anchorWall, anchorRec := start, recTime(0) + now := start + var absMax time.Duration + + // Relative: the old scheme reset the next delay from the late firing instant. + relNow := start + var relMax time.Duration + + for i := 1; i <= frames; i++ { + // absolute + now = frameTarget(anchorWall, anchorRec, recTime(i), speed).Add(jitter) + if e := absErr(now.Sub(start), recTime(i).Sub(start)); e > absMax { + absMax = e + } + // relative: fire at previous-fire + gap, then add the same jitter + relNow = relNow.Add(gap).Add(jitter) + if e := absErr(relNow.Sub(start), recTime(i).Sub(start)); e > relMax { + relMax = e + } + } + + // Absolute error never exceeds a single wake-up's jitter, no matter how long. + if absMax > jitter { + t.Fatalf("absolute scheme drifted: max error %v > jitter %v", absMax, jitter) + } + // Relative error grows ~jitter per frame: proves the bug the rewrite fixes. + if relMax < time.Duration(frames/2)*jitter { + t.Fatalf("relative scheme should accumulate drift, got only %v", relMax) + } + t.Logf("after %d frames: absolute max error %v, relative drift %v", frames, absMax, relMax) +} + +func absErr(a, b time.Duration) time.Duration { + if a < b { + return b - a + } + return a - b +} diff --git a/pkg/widgets/mapviewer/mapviewer.go b/pkg/widgets/mapviewer/mapviewer.go index 0641660c..0720f521 100644 --- a/pkg/widgets/mapviewer/mapviewer.go +++ b/pkg/widgets/mapviewer/mapviewer.go @@ -14,11 +14,14 @@ import ( "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/driver/desktop" + fynelayout "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" "github.com/roffe/txlogger/pkg/interpolate" "github.com/roffe/txlogger/pkg/layout" - "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/graph2d" "github.com/roffe/txlogger/pkg/widgets/meshgrid" ) @@ -27,6 +30,10 @@ const ( maxTextSize = 28 ) +// singleCellColor is used for 1x1 maps where value-based color interpolation +// is meaningless (min == max yields a flat gray). ponytail: fixed accent. +var singleCellColor = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF} // white + var ( // _ fyne.Tappable = (*MapViewer)(nil) _ fyne.Focusable = (*MapViewer)(nil) @@ -52,29 +59,38 @@ type MapViewer struct { content fyne.CanvasObject - innerView *fyne.Container - valueRects *fyne.Container - valueTexts *fyne.Container + innerView *fyne.Container + valueRects *fyne.Container + valueTexts *fyne.Container + selectionOverlay *fyne.Container + regionOverlay *fyne.Container - selectionRect *canvas.Rectangle - crosshair *canvas.Rectangle + crosshair *canvas.Rectangle - zDataRects []*canvas.Rectangle + zDataRects []*canvas.Rectangle + selectionRects []*canvas.Rectangle selectedX, SelectedY int - mesh *meshgrid.Meshgrid + mesh *meshgrid.Meshgrid + meshSplit *container.Split + meshModeBtn *widget.Button + graph *graph2d.Graph // Mouse - mousePos fyne.Position - selecting bool - lastModifier fyne.KeyModifier - selectedCells []int + mousePos fyne.Position + selecting bool + dragCornerX, dragCornerY int + lastModifier fyne.KeyModifier + selectedCells []int // Keyboard inputBuffer strings.Builder restoreValues bool + // scratch buffer for formatting cell values without allocating + scratch []byte + popup *widget.PopUpMenu widthFactor float32 @@ -85,13 +101,12 @@ type MapViewer struct { func New(config *Config) (*MapViewer, error) { mv := &MapViewer{ - cfg: config, - crosshair: NewCrosshair(color.RGBA{165, 55, 253, 180}, 3), - selectionRect: NewRectangle(color.RGBA{0x30, 0x70, 0xFF, 0xFF}, 3), - numColumns: len(config.XData), - numRows: len(config.YData), - numData: len(config.ZData), - colorMode: config.ColorblindMode, + cfg: config, + crosshair: NewCrosshair(color.RGBA{165, 55, 253, 180}, 3), + numColumns: len(config.XData), + numRows: len(config.YData), + numData: len(config.ZData), + colorMode: config.ColorblindMode, } mv.ExtendBaseWidget(mv) @@ -100,18 +115,38 @@ func New(config *Config) (*MapViewer, error) { if len(mv.cfg.ZData) == 0 { return nil, fmt.Errorf("mapViewer zData is empty") } - mv.zMin, mv.zMax = widgets.FindMinMax(mv.cfg.ZData) + mv.zMin, mv.zMax = common.FindMinMaxFloat64(mv.cfg.ZData) if mv.numColumns*mv.numRows != mv.numData && mv.numColumns > 1 && mv.numRows > 1 { return nil, fmt.Errorf("mapViewer columns * rows != data length") } return mv, nil } +func (mv *MapViewer) toggleMesh() { + if mv.mesh == nil || mv.meshSplit == nil { + return + } + if mv.mesh.Visible() { + mv.mesh.Hide() + mv.meshModeBtn.Hide() + mv.meshSplit.SetOffset(1) + } else { + mv.mesh.Show() + mv.meshModeBtn.Show() + mv.meshSplit.SetOffset(0.2) + } +} + func (mv *MapViewer) SetColorBlindMode(mode colors.ColorBlindMode) { if mv.colorMode != mode { mv.colorMode = mode mv.Refresh() - mv.mesh.SetColorBlindMode(mode) + if mv.mesh != nil { + mv.mesh.SetColorBlindMode(mode) + } + if mv.graph != nil { + mv.graph.SetColorBlindMode(mode) + } } } @@ -119,7 +154,11 @@ func (mv *MapViewer) CreateRenderer() fyne.WidgetRenderer { mv.createYAxis() mv.createXAxis() mv.createZdata() + mv.createSelectionOverlay() + mv.createRegionOverlay() mv.createTextValues() + // Start with nothing selected; a cell is selected on first click/keypress. + mv.selectedCells = nil mv.content = mv.render() return widget.NewSimpleRenderer(mv.content) // return &mapViewerRenderer{mv: mv} @@ -161,8 +200,9 @@ func (mr *movingRectsLayout) Layout(_ []fyne.CanvasObject, size fyne.Size) { float32(float64(mr.mv.numRows)-1-mr.mv.yIndex)*mr.mv.heightFactor, ), ) - mr.mv.resizeSelectionRect() - mr.mv.updateCursor(false) + // The selection lives in selectionOverlay, which shares the cell grid and is + // repositioned automatically by the layout, so there is nothing to recompute + // for it here. } func (mv *MapViewer) render() fyne.CanvasObject { @@ -170,45 +210,76 @@ func (mv *MapViewer) render() fyne.CanvasObject { mv.crosshair.Resize(fyne.NewSize(34, 14)) mv.crosshair.Hide() - // mv.selectionRect.CornerRadius = 4 - mv.selectionRect.Resize(fyne.NewSize(34, 14)) - // mv.selectionRect.Hide() - - mv.innerView = container.NewStack( - mv.valueRects, + layers := []fyne.CanvasObject{mv.valueRects, mv.selectionOverlay} + if mv.regionOverlay != nil { + layers = append(layers, mv.regionOverlay) + } + layers = append(layers, container.New(&movingRectsLayout{mv: mv}, mv.crosshair, - mv.selectionRect, ), mv.valueTexts, ) + mv.innerView = container.NewStack(layers...) buttons := mv.createButtons() - if mv.numColumns == 1 || mv.numRows == 1 { - return container.NewBorder( - nil, - buttons, - nil, - nil, - container.NewBorder( - mv.xAxisLabelContainer, - nil, - mv.yAxisLabelContainer, - nil, - mv.innerView, - ), - ) + var stepButtons fyne.CanvasObject + if mv.cfg.Editable { + stepButtons = mv.createStepButtons() } mapview := container.NewBorder( mv.xAxisLabelContainer, - nil, + stepButtons, mv.yAxisLabelContainer, nil, mv.innerView, ) + if mv.numColumns == 1 || mv.numRows == 1 { + if mv.cfg.MeshView && mv.numData > 1 { + axisData := mv.cfg.XData + axisPrecision := mv.cfg.XPrecision + axisLabel := mv.cfg.XLabel + if mv.numColumns == 1 { + axisData = mv.cfg.YData + axisPrecision = mv.cfg.YPrecision + axisLabel = mv.cfg.YLabel + } + mv.graph = graph2d.New(&graph2d.Config{ + AxisData: axisData, + Values: mv.cfg.ZData, + AxisPrecision: axisPrecision, + ValuePrecision: mv.cfg.ZPrecision, + AxisLabel: axisLabel, + ColorblindMode: mv.colorMode, + }) + if mv.cfg.OnMouseDown != nil { + mv.graph.OnMouseDown = mv.cfg.OnMouseDown + } + split := container.NewVSplit( + mapview, + container.NewBorder( + nil, + buttons, + nil, + nil, + mv.graph, + ), + ) + split.Offset = 0.2 + return split + } + return container.NewBorder( + nil, + buttons, + nil, + nil, + mapview, + ) + } + if mv.cfg.MeshView { var err error mv.mesh, err = meshgrid.NewMeshgrid( @@ -218,7 +289,13 @@ func (mv *MapViewer) render() fyne.CanvasObject { mv.cfg.ZData, mv.numColumns, mv.numRows, + mv.cfg.XData, + mv.cfg.YData, + mv.cfg.XPrecision, + mv.cfg.YPrecision, + mv.cfg.ZPrecision, mv.colorMode, + mv.cfg.MeshRenderer, ) if mv.cfg.OnMouseDown != nil { @@ -226,18 +303,22 @@ func (mv *MapViewer) render() fyne.CanvasObject { } if err == nil { + meshModeBtn := widget.NewButtonWithIcon("", theme.GridIcon(), mv.mesh.CycleRenderMode) + meshModeBtn.Importance = widget.LowImportance + mv.meshModeBtn = meshModeBtn split := container.NewVSplit( mapview, - container.NewBorder( - nil, - buttons, - nil, - nil, + container.NewStack( mv.mesh, + container.NewVBox( + container.NewHBox(fynelayout.NewSpacer(), meshModeBtn), + ), ), ) split.Offset = 0.2 - return split + mv.meshSplit = split + // buttons live outside the split so they stay visible when the mesh is toggled off. + return container.NewBorder(nil, buttons, nil, nil, split) } else { log.Println("MapViewer meshview failed:", err) } @@ -272,10 +353,11 @@ func (mv *MapViewer) SetY(yValue float64) { } func (mv *MapViewer) setCellText(idx int, value float64) { - textValue := strconv.FormatFloat(value, 'f', mv.cfg.ZPrecision, 64) - if mv.textValues[idx].Text != textValue { - mv.textValues[idx].Text = textValue - mv.textValues[idx].Refresh() + mv.scratch = strconv.AppendFloat(mv.scratch[:0], value, 'f', mv.cfg.ZPrecision, 64) + text := mv.textValues[idx] + if string(mv.scratch) != text.Text { + text.Text = string(mv.scratch) + text.Refresh() } } @@ -290,9 +372,17 @@ func (mv *MapViewer) SetZData(zData []float64) error { } func (mv *MapViewer) Refresh() { - mv.zMin, mv.zMax = widgets.FindMinMax(mv.cfg.ZData) + mv.zMin, mv.zMax = common.FindMinMaxFloat64(mv.cfg.ZData) + if len(mv.textValues) == 0 { + // renderer not created yet; createTextValues/createZdata pick up + // the current ZData and color mode when it is + return + } for idx, value := range mv.cfg.ZData { mv.setCellText(idx, value) + if mv.numData == 1 { + continue // single cell keeps singleCellColor + } col := colors.GetColorInterpolation( mv.zMin, mv.zMax, @@ -309,42 +399,46 @@ func (mv *MapViewer) Refresh() { if mv.mesh != nil { mv.mesh.LoadFloat64s(mv.zMin, mv.zMax, mv.cfg.ZData) } + if mv.graph != nil { + mv.graph.SetValues(mv.zMin, mv.zMax, mv.cfg.ZData) + } } func (mv *MapViewer) createYAxis() { - mv.yAxisLabelContainer = container.New(&layout.Vertical{}) - if mv.numRows >= 1 { - for i := mv.numRows - 1; i >= 0; i-- { - text := &canvas.Text{ - Alignment: fyne.TextAlignCenter, - Text: strconv.FormatFloat(mv.cfg.YData[i], 'f', mv.cfg.YPrecision, 64), - TextSize: minTextSize + 2, - } - mv.yAxisTexts = append(mv.yAxisTexts, text) - mv.yAxisLabelContainer.Add(text) + mv.yAxisTexts = make([]*canvas.Text, 0, mv.numRows) + objs := make([]fyne.CanvasObject, 0, mv.numRows) + // ponytail: single value view has only a "0" axis label, skip it + for i := mv.numRows - 1; i >= 0 && mv.numData > 1; i-- { + text := &canvas.Text{ + Alignment: fyne.TextAlignCenter, + Text: strconv.FormatFloat(mv.cfg.YData[i], 'f', mv.cfg.YPrecision, 64), + TextSize: minTextSize + 2, } - return + mv.yAxisTexts = append(mv.yAxisTexts, text) + objs = append(objs, text) } + mv.yAxisLabelContainer = container.New(&layout.Vertical{}, objs...) } func (mv *MapViewer) createXAxis() { - mv.xAxisLabelContainer = container.New(&layout.Horizontal{Offset: mv.yAxisLabelContainer}) - if mv.numColumns >= 1 { - for i := 0; i < mv.numColumns; i++ { - text := &canvas.Text{ - Alignment: fyne.TextAlignCenter, - Text: strconv.FormatFloat(mv.cfg.XData[i], 'f', mv.cfg.XPrecision, 64), - TextSize: minTextSize + 2, - } - mv.xAxisTexts = append(mv.xAxisTexts, text) - mv.xAxisLabelContainer.Add(text) + mv.xAxisTexts = make([]*canvas.Text, 0, mv.numColumns) + objs := make([]fyne.CanvasObject, 0, mv.numColumns) + // ponytail: single value view has only a "0" axis label, skip it + for i := 0; i < mv.numColumns && mv.numData > 1; i++ { + text := &canvas.Text{ + Alignment: fyne.TextAlignCenter, + Text: strconv.FormatFloat(mv.cfg.XData[i], 'f', mv.cfg.XPrecision, 64), + TextSize: minTextSize + 2, } - return + mv.xAxisTexts = append(mv.xAxisTexts, text) + objs = append(objs, text) } + mv.xAxisLabelContainer = container.New(&layout.Horizontal{Offset: mv.yAxisLabelContainer}, objs...) } func (mv *MapViewer) createTextValues() { - mv.valueTexts = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32)) + mv.textValues = make([]*canvas.Text, 0, mv.numData) + objs := make([]fyne.CanvasObject, 0, mv.numData) for _, v := range mv.cfg.ZData { text := &canvas.Text{ Text: strconv.FormatFloat(v, 'f', mv.cfg.ZPrecision, 64), @@ -353,19 +447,159 @@ func (mv *MapViewer) createTextValues() { Alignment: fyne.TextAlignCenter, } mv.textValues = append(mv.textValues, text) - mv.valueTexts.Add(text) + objs = append(objs, text) } + mv.valueTexts = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32), objs...) } func (mv *MapViewer) createZdata() { - mv.valueRects = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32)) + mv.zDataRects = make([]*canvas.Rectangle, 0, mv.numData) + objs := make([]fyne.CanvasObject, 0, mv.numData) + singleCell := mv.numData == 1 for _, value := range mv.cfg.ZData { - color := colors.GetColorInterpolation(mv.zMin, mv.zMax, value, mv.colorMode) - rect := &canvas.Rectangle{FillColor: color, StrokeColor: color, StrokeWidth: 0} - rect.SetMinSize(fyne.NewSize(34, 14)) + col := colors.GetColorInterpolation(mv.zMin, mv.zMax, value, mv.colorMode) + minHeight := float32(14) + if singleCell { + col = singleCellColor + minHeight = 28 + } + rect := &canvas.Rectangle{FillColor: col, StrokeColor: col, StrokeWidth: 0} + rect.SetMinSize(fyne.NewSize(34, minHeight)) mv.zDataRects = append(mv.zDataRects, rect) - mv.valueRects.Add(rect) + objs = append(objs, rect) } + mv.valueRects = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32), objs...) +} + +// createSelectionOverlay builds a dedicated highlight layer with one +// translucent rectangle per cell. The rectangles are hidden by default and +// only shown for cells present in mv.selectedCells. Keeping selection in its +// own layer decouples it from the value rects, so editing a cell's value never +// clears the selection visual and vice versa. +func (mv *MapViewer) createSelectionOverlay() { + mv.selectionRects = make([]*canvas.Rectangle, mv.numData) + objs := make([]fyne.CanvasObject, mv.numData) + for i := range mv.selectionRects { + rect := canvas.NewRectangle(color.RGBA{0xDE, 0xDF, 0xE4, 0xFF}) + rect.Hide() + mv.selectionRects[i] = rect + objs[i] = rect + } + mv.selectionOverlay = container.New(layout.NewGrid(mv.numColumns, mv.numRows, 1.32), objs...) +} + +// createRegionOverlay builds a thin line layer that traces the boundary between +// the cells flagged in cfg.RegionBorder and the rest — e.g. the closed-loop / +// open-loop fuel transition — as a staircase that cuts through the map instead +// of boxing each cell. Leaves regionOverlay nil when there is no region or the +// region has no internal boundary (all cells in or all out). +func (mv *MapViewer) createRegionOverlay() { + if len(mv.cfg.RegionBorder) != mv.numData || mv.numColumns <= 1 || mv.numRows <= 1 { + return + } + edges := mv.regionEdges() + if len(edges) == 0 { + return + } + borderCol := mv.cfg.RegionBorderColor + if borderCol.A == 0 { + borderCol = color.RGBA{0x70, 0x80, 0x90, 0xFF} // ponytail: default slate boundary + } + lines := make([]*canvas.Line, len(edges)) + objs := make([]fyne.CanvasObject, len(edges)) + for i := range edges { + ln := canvas.NewLine(borderCol) + ln.StrokeWidth = 4 + lines[i] = ln + objs[i] = ln + } + mv.regionOverlay = container.New(®ionBorderLayout{mv: mv, edges: edges, lines: lines}, objs...) +} + +// regionEdge marks one shared cell edge on the region boundary. vertical = the +// edge to the right of cell (r,c); otherwise the edge on top of cell (r,c), +// shared with row r+1. Indices use the same row-major order as ZData. +type regionEdge struct { + r, c int + vertical bool +} + +// regionEdges collects every edge where a flagged cell touches an unflagged one. +// Only internal transitions are returned, so the map's outer border is never +// drawn — just the closed/open interface. +func (mv *MapViewer) regionEdges() []regionEdge { + rb := mv.cfg.RegionBorder + var edges []regionEdge + for r := 0; r < mv.numRows; r++ { + for c := 0; c < mv.numColumns; c++ { + in := rb[r*mv.numColumns+c] + if c+1 < mv.numColumns && in != rb[r*mv.numColumns+c+1] { + edges = append(edges, regionEdge{r: r, c: c, vertical: true}) + } + if r+1 < mv.numRows && in != rb[(r+1)*mv.numColumns+c] { + edges = append(edges, regionEdge{r: r, c: c, vertical: false}) + } + } + } + return edges +} + +// regionBorderLayout positions the boundary lines onto cell edges, recomputing +// on resize. Cell slots are size/count (matching the value grid's slot pitch and +// the crosshair layout), and row 0 sits at the bottom, so Y is flipped. +type regionBorderLayout struct { + mv *MapViewer + edges []regionEdge + lines []*canvas.Line + oldSize fyne.Size +} + +func (l *regionBorderLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { return fyne.Size{} } + +func (l *regionBorderLayout) Layout(_ []fyne.CanvasObject, size fyne.Size) { + if size == l.oldSize { + return + } + l.oldSize = size + cw := size.Width / float32(l.mv.numColumns) + ch := size.Height / float32(l.mv.numRows) + for i, e := range l.edges { + ln := l.lines[i] + if e.vertical { + x := float32(e.c+1) * cw + ln.Position1 = fyne.NewPos(x, size.Height-float32(e.r+1)*ch) + ln.Position2 = fyne.NewPos(x, size.Height-float32(e.r)*ch) + } else { + y := size.Height - float32(e.r+1)*ch + ln.Position1 = fyne.NewPos(float32(e.c)*cw, y) + ln.Position2 = fyne.NewPos(float32(e.c+1)*cw, y) + } + ln.Refresh() + } +} + +// clearSelectionVisual hides the highlight for every currently selected cell. +// Call it before mutating mv.selectedCells, then call drawSelectionVisual after. +func (mv *MapViewer) clearSelectionVisual() { + for _, cell := range mv.selectedCells { + if cell >= 0 && cell < len(mv.selectionRects) { + mv.selectionRects[cell].Hide() + } + } +} + +// drawSelectionVisual shows the highlight for every currently selected cell. +func (mv *MapViewer) drawSelectionVisual() { + for _, cell := range mv.selectedCells { + if cell >= 0 && cell < len(mv.selectionRects) { + mv.selectionRects[cell].Show() + } + } + // Show() only flips the Hidden flag. On a freshly opened window nothing has + // dirtied the canvas yet, so the newly shown rects aren't painted until some + // unrelated event (resize, button hover) forces a repaint. Refresh the + // overlay container to repaint immediately. See handlePrimaryCtrlClick. + canvas.Refresh(mv.selectionOverlay) } func (mv *MapViewer) setXY() error { @@ -392,6 +626,16 @@ func (mv *MapViewer) setXY() error { } mv.crosshair.Move(crosshairPos) + if mv.graph != nil { + if mv.numRows == 1 { + mv.graph.SetCursor(xIdx) + } else { + mv.graph.SetCursor(yIdx) + } + } + if mv.mesh != nil { + mv.mesh.SetCursor(xIdx, yIdx) + } if mv.cfg.CursorFollowCrosshair { mv.selectedX = int(math.Round(xIdx)) mv.SelectedY = int(math.Round(yIdx)) @@ -400,6 +644,25 @@ func (mv *MapViewer) setXY() error { return nil } +// stepSelected adjusts every selected cell by one ZPrecision step. sign is +1 +// (incr) or -1 (decr). Same behaviour as the +/- keyboard shortcut. +func (mv *MapViewer) stepSelected(sign float64) { + increment := sign * math.Pow(10, -float64(mv.cfg.ZPrecision)) + for _, cell := range mv.selectedCells { + mv.cfg.ZData[cell] += increment + } + mv.updateCells() + mv.Refresh() +} + +func (mv *MapViewer) createStepButtons() *fyne.Container { + decr := widget.NewButtonWithIcon("Decr", theme.ContentRemoveIcon(), func() { mv.stepSelected(-1) }) + incr := widget.NewButtonWithIcon("Incr", theme.ContentAddIcon(), func() { mv.stepSelected(1) }) + decr.Importance = widget.LowImportance + incr.Importance = widget.LowImportance + return container.NewGridWithColumns(2, decr, incr) +} + func (mv *MapViewer) createButtons() *fyne.Container { noButtons := len(mv.cfg.Buttons) if noButtons > 0 { @@ -415,69 +678,6 @@ func (mv *MapViewer) createButtons() *fyne.Container { } } -func (mv *MapViewer) resizeSelectionRect() { - // Early return if no selection - if mv.selectedX < 0 { - return - } - - var pos fyne.Position - var size fyne.Size - - // Handle multiple cell selection - if len(mv.selectedCells) > 1 { - // Pre-calculate divisor to avoid repeated division operations - colDivisor := float32(mv.numColumns) - - // Initialize bounds using first cell to avoid unnecessary comparisons - firstCell := mv.selectedCells[0] - minX := firstCell % mv.numColumns - maxX := minX - minY := int(float32(firstCell) / colDivisor) - maxY := minY - - // Find bounds in a single pass - for i := 1; i < len(mv.selectedCells); i++ { - cell := mv.selectedCells[i] - x := cell % mv.numColumns - y := int(float32(cell) / colDivisor) - - if x < minX { - minX = x - } else if x > maxX { - maxX = x - } - - if y < minY { - minY = y - } else if y > maxY { - maxY = y - } - } - - // Calculate position and size once - topLeftX := float32(minX) * mv.widthFactor - topLeftY := float32(mv.numRows-1-maxY) * mv.heightFactor - width := float32(maxX-minX+1) * mv.widthFactor - height := float32(maxY-minY+1) * mv.heightFactor - - pos = fyne.NewPos(topLeftX-1, topLeftY) - size = fyne.NewSize(width+1, height+1) - } else { - // Single cell selection - pos = fyne.NewPos( - (float32(mv.selectedX)*mv.widthFactor)-1, - (float32(mv.numRows-1-mv.SelectedY) * mv.heightFactor), - ) - size = fyne.NewSize(mv.widthFactor+1, mv.heightFactor+1) - } - - // Batch UI updates - mv.selectionRect.Move(pos) - mv.selectionRect.Resize(size) - // mv.selectionRect.MoveAndResize(pos, size) -} - /* var _ fyne.WidgetRenderer = (*mapViewerRenderer)(nil) @@ -510,7 +710,7 @@ func (r *mapViewerRenderer) Destroy() { */ func calculateTextSize(widthFactor, heightFactor float32) float32 { - cellSize := fyne.Min(widthFactor, heightFactor) + cellSize := min(widthFactor, heightFactor) // Scale text size relative to cell size, but with a more conservative ratio // Reduced from 0.6 to 0.4 to prevent overflow @@ -533,12 +733,3 @@ func NewCrosshair(strokeColor color.RGBA, strokeWidth float32) *canvas.Rectangle CornerRadius: 4, } } - -func NewRectangle(strokeColor color.RGBA, strokeWidth float32) *canvas.Rectangle { - return &canvas.Rectangle{ - FillColor: color.RGBA{0, 0, 0, 0}, - StrokeColor: strokeColor, - StrokeWidth: strokeWidth, - CornerRadius: 4, - } -} diff --git a/pkg/widgets/mapviewer/mapviewer_focus.go b/pkg/widgets/mapviewer/mapviewer_focus.go index 690ba9c2..e9c7591d 100644 --- a/pkg/widgets/mapviewer/mapviewer_focus.go +++ b/pkg/widgets/mapviewer/mapviewer_focus.go @@ -123,19 +123,9 @@ func (mv *MapViewer) TypedKey(key *fyne.KeyEvent) { mv.updateCells() refresh = true case "+", "A": - increment := math.Pow(10, -float64(mv.cfg.ZPrecision)) - for _, cell := range mv.selectedCells { - mv.cfg.ZData[cell] += increment - } - mv.updateCells() - refresh = true + mv.stepSelected(1) case "-", "Z": - increment := math.Pow(10, -float64(mv.cfg.ZPrecision)) - for _, cell := range mv.selectedCells { - mv.cfg.ZData[cell] -= increment - } - mv.updateCells() - refresh = true + mv.stepSelected(-1) case "Up": mv.SelectedY++ if mv.SelectedY >= mv.numRows { diff --git a/pkg/widgets/mapviewer/mapviewer_keyhandler.go b/pkg/widgets/mapviewer/mapviewer_keyhandler.go index f52a1a3e..772995ee 100644 --- a/pkg/widgets/mapviewer/mapviewer_keyhandler.go +++ b/pkg/widgets/mapviewer/mapviewer_keyhandler.go @@ -25,7 +25,9 @@ func (mv *MapViewer) TypedShortcut(shortcut fyne.Shortcut) { case "Copy": mv.copy() case "Paste": - mv.paste() + // Ctrl+V pastes at the current cursor/selection position rather than + // the coordinates the data was originally copied from. + mv.pasteHere() } } @@ -52,18 +54,22 @@ func (mv *MapViewer) copy() { fyne.CurrentApp().Clipboard().SetContent(copyString.String()) } -func (mv *MapViewer) paste() { - if !mv.cfg.Editable { - return - } - cb := fyne.CurrentApp().Clipboard().Content() +type clipboardCell struct { + x, y int // storage coordinates (y already converted to storage row) + value float64 +} + +// parseClipboardCells parses the internal copy format into cells with storage +// coordinates. The first cell is the anchor; its x carries the +20/+200 marker, +// which is stripped here. +func (mv *MapViewer) parseClipboardCells(cb string) []clipboardCell { split := strings.Split(cb, copyPasteSeparator) + cells := make([]clipboardCell, 0, len(split)) for i, part := range split { if len(part) < 3 { continue } pp := strings.Split(part, ":") - if len(pp) < 3 { continue } @@ -73,7 +79,6 @@ func (mv *MapViewer) paste() { log.Println(err) continue } - if i == 0 && x >= 200 { x -= 200 } else if i == 0 && x >= 20 { @@ -85,27 +90,87 @@ func (mv *MapViewer) paste() { log.Println(err) continue } - value, err := strconv.Atoi(pp[2]) + value, err := strconv.ParseFloat(pp[2], 64) if err != nil { log.Println(err) continue } - y = mv.numRows - 1 - y + cells = append(cells, clipboardCell{x: x, y: mv.numRows - 1 - y, value: value}) + } + return cells +} +// applyPaste writes the parsed cells into ZData, offset by shiftX/shiftY. Cells +// that fall outside the map bounds are skipped. When bounds is non-nil, only +// destination cells present in the set are written, so the paste stays inside +// the current selection. +func (mv *MapViewer) applyPaste(cells []clipboardCell, shiftX, shiftY int, bounds map[int]struct{}) { + changed := false + for _, c := range cells { + x := c.x + shiftX + y := c.y + shiftY + if x < 0 || x >= mv.numColumns || y < 0 || y >= mv.numRows { + continue + } index := y*mv.numColumns + x if index < 0 || index >= len(mv.cfg.ZData) { - log.Printf("Index out of range: %d", index) continue } - mv.cfg.ZData[index] = float64(value) - //if len(split) < 30 { - // mv.cfg.UpdateECUFunc(index, []float64{mv.cfg.ZData[index]}) - //} - } - //if len(split) >= 30 { - // mv.cfg.SaveECUFunc(mv.cfg.ZData) - //} - mv.Refresh() + if bounds != nil { + if _, ok := bounds[index]; !ok { + continue + } + } + mv.cfg.ZData[index] = c.value + changed = true + } + if changed { + mv.Refresh() + } +} + +// paste writes the clipboard back to the same coordinates it was copied from. +func (mv *MapViewer) paste() { + if !mv.cfg.Editable { + return + } + cells := mv.parseClipboardCells(fyne.CurrentApp().Clipboard().Content()) + mv.applyPaste(cells, 0, 0, nil) +} + +// pasteHere writes the clipboard with its anchor cell landing on the currently +// selected cell, so the block is pasted starting where the user right-clicked. +func (mv *MapViewer) pasteHere() { + if !mv.cfg.Editable { + return + } + cells := mv.parseClipboardCells(fyne.CurrentApp().Clipboard().Content()) + if len(cells) == 0 { + return + } + // Anchor on the visual top-left of the copied block (smallest column, + // topmost row). Data row 0 is drawn at the bottom, so the topmost row is the + // largest storage y. This makes the block fill down and to the right from + // the clicked cell, matching the on-screen orientation. + minX, maxY := cells[0].x, cells[0].y + for _, c := range cells[1:] { + if c.x < minX { + minX = c.x + } + if c.y > maxY { + maxY = c.y + } + } + // When more than a single cell is selected, confine the paste to that + // selection so values can't spill outside the highlighted block. + var bounds map[int]struct{} + if len(mv.selectedCells) > 1 { + bounds = make(map[int]struct{}, len(mv.selectedCells)) + for _, cell := range mv.selectedCells { + bounds[cell] = struct{}{} + } + } + mv.applyPaste(cells, mv.selectedX-minX, mv.SelectedY-maxY, bounds) } func (mv *MapViewer) smooth() { @@ -136,20 +201,23 @@ func (mv *MapViewer) smooth() { mv.Refresh() } +// updateCursor collapses the selection to the single cell at selectedX/SelectedY +// and redraws the overlay highlight. Used by keyboard navigation and the +// crosshair-follow cursor. Pass goroutine=true when called off the main thread. func (mv *MapViewer) updateCursor(goroutine bool) { - mv.selectedCells = []int{mv.SelectedY*mv.numColumns + mv.selectedX} - xPosFactor := float32(mv.selectedX) - yPosFactor := float32(float64(mv.numRows-1) - float64(mv.SelectedY)) - xPos := xPosFactor * mv.widthFactor - yPos := yPosFactor * mv.heightFactor + cell := mv.SelectedY*mv.numColumns + mv.selectedX + if len(mv.selectedCells) == 1 && mv.selectedCells[0] == cell { + return + } + apply := func() { + mv.clearSelectionVisual() + mv.selectedCells = append(mv.selectedCells[:0], cell) + mv.drawSelectionVisual() + } if goroutine { - fyne.Do(func() { - mv.selectionRect.Resize(fyne.Size{Width: mv.widthFactor + 1, Height: mv.heightFactor + 1}) - mv.selectionRect.Move(fyne.Position{X: xPos - 1, Y: yPos - 1}) - }) + fyne.Do(apply) } else { - mv.selectionRect.Resize(fyne.Size{Width: mv.widthFactor + 1, Height: mv.heightFactor + 1}) - mv.selectionRect.Move(fyne.Position{X: xPos - 1, Y: yPos - 1}) + apply() } } diff --git a/pkg/widgets/mapviewer/mapviewer_mouse.go b/pkg/widgets/mapviewer/mapviewer_mouse.go index 929c57b2..ba346601 100644 --- a/pkg/widgets/mapviewer/mapviewer_mouse.go +++ b/pkg/widgets/mapviewer/mapviewer_mouse.go @@ -1,12 +1,11 @@ package mapviewer import ( - "math" "slices" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/driver/desktop" - "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -28,7 +27,7 @@ func (mv *MapViewer) MouseOut() {} // MouseMoved is called when the mouse is moved over the map viewer. func (mv *MapViewer) MouseMoved(event *desktop.MouseEvent) { - //log.Println("MouseMoved", event) + // log.Println("MouseMoved", event) if !mv.selecting { return } @@ -38,13 +37,18 @@ func (mv *MapViewer) MouseMoved(event *desktop.MouseEvent) { } mv.mousePos = event.Position - nselectedX, nSelectedY := mv.calculateSelectionBounds(event.Position) - mv.updateSelection(nselectedX, nSelectedY) + nx, ny := mv.calculateSelectionBounds(event.Position) + // Only rebuild the selection when the drag crosses into a different cell. + if nx == mv.dragCornerX && ny == mv.dragCornerY { + return + } + mv.dragCornerX, mv.dragCornerY = nx, ny + mv.selectRegion(nx, ny) } // MouseDown is called when a mouse button is pressed. func (mv *MapViewer) MouseDown(event *desktop.MouseEvent) { - //log.Println("MouseDown") + // log.Println("MouseDown") mv.lastModifier = event.Modifier if mv.cfg.OnMouseDown != nil { mv.cfg.OnMouseDown() @@ -54,37 +58,19 @@ func (mv *MapViewer) MouseDown(event *desktop.MouseEvent) { return } - if event.Modifier != fyne.KeyModifierControl { - for _, rect := range mv.zDataRects { - if rect.FillColor != rect.StrokeColor { - rect.FillColor = rect.StrokeColor - rect.Refresh() - } - } - } - mv.handleFocusAndInputBuffer() switch { case event.Button == desktop.MouseButtonPrimary && event.Modifier == 0: - if mv.selectionRect.Hidden { - mv.selectionRect.Resize(fyne.NewSize(mv.widthFactor, mv.heightFactor)) - mv.selectionRect.Show() - } mv.handlePrimaryClick(event) case event.Button == desktop.MouseButtonPrimary && event.Modifier == fyne.KeyModifierShift: - if mv.selectionRect.Hidden { - mv.selectionRect.Resize(fyne.NewSize(mv.widthFactor, mv.heightFactor)) - mv.selectionRect.Show() - } mv.handlePrimaryClickWithShift(event) + case event.Button == desktop.MouseButtonPrimary && event.Modifier == fyne.KeyModifierControl|fyne.KeyModifierShift: + mv.handlePrimaryCtrlShiftClick(event) + case event.Button == desktop.MouseButtonPrimary && event.Modifier == fyne.KeyModifierControl: - if mv.selectionRect.Hidden { - mv.selectionRect.Resize(fyne.NewSize(mv.widthFactor, mv.heightFactor)) - mv.selectionRect.Show() - } mv.handlePrimaryCtrlClick(event) case event.Button == desktop.MouseButtonSecondary && event.Modifier == 0: @@ -95,57 +81,102 @@ func (mv *MapViewer) MouseDown(event *desktop.MouseEvent) { // MouseUp is called when a mouse button is released. func (mv *MapViewer) MouseUp(event *desktop.MouseEvent) { // log.Println("MouseUp", event) - mv.selectionRect.Hide() if event.Button == desktop.MouseButtonPrimary && mv.selecting { mv.finalizeSelection(event.Position) } } -// handlePrimaryClick handles the primary click action. +// handlePrimaryClick starts a fresh single-cell selection and begins a drag. func (mv *MapViewer) handlePrimaryClick(event *desktop.MouseEvent) { + mv.clearSelectionVisual() mv.selectedX, mv.SelectedY = mv.calculateSelectionBounds(event.Position) - - cellWidth, cellHeight := mv.calculateCellDimensions() - x := (float32(mv.selectedX) * cellWidth) - y := (float32(mv.numRows-mv.SelectedY-1) * cellHeight) - - mv.updateCursorPositionAndSize(x, y, cellWidth, cellHeight) - mv.selectedCells = []int{mv.SelectedY*mv.numColumns + mv.selectedX} + mv.dragCornerX, mv.dragCornerY = mv.selectedX, mv.SelectedY + mv.selectedCells = append(mv.selectedCells[:0], mv.SelectedY*mv.numColumns+mv.selectedX) + mv.drawSelectionVisual() mv.selecting = true } func (mv *MapViewer) handlePrimaryCtrlClick(event *desktop.MouseEvent) { mv.selectedX, mv.SelectedY = mv.calculateSelectionBounds(event.Position) - - cellWidth, cellHeight := mv.calculateCellDimensions() - x := (float32(mv.selectedX) * cellWidth) - y := (float32(mv.numRows-mv.SelectedY-1) * cellHeight) - - mv.updateCursorPositionAndSize(x, y, cellWidth, cellHeight) - newCell := mv.SelectedY*mv.numColumns + mv.selectedX - // Check if cell is already selected + // Toggle the clicked cell while keeping the rest of the selection. if index := slices.Index(mv.selectedCells, newCell); index != -1 { - // Remove cell if already selected mv.selectedCells = append(mv.selectedCells[:index], mv.selectedCells[index+1:]...) - mv.zDataRects[newCell].FillColor = mv.zDataRects[newCell].StrokeColor + mv.selectionRects[newCell].Hide() } else { - // Add new cell mv.selectedCells = append(mv.selectedCells, newCell) - mv.zDataRects[newCell].FillColor = theme.Color(theme.ColorNameForegroundOnPrimary) + mv.selectionRects[newCell].Show() } - mv.zDataRects[newCell].Refresh() + // Show()/Hide() only flip the Hidden flag, and canvas.Refresh on a rect + // that has never been painted (a hidden overlay cell) is a no-op because the + // object isn't in the canvas cache. Refresh the always-visible value rect for + // this cell instead to dirty the canvas and force an immediate repaint that + // draws the toggled highlight. + canvas.Refresh(mv.zDataRects[newCell]) } -// handlePrimaryClickWithShift handles the primary click with shift action. +// handlePrimaryCtrlShiftClick adds the rectangular block between the anchor cell +// (selectedX/SelectedY, the last clicked cell) and the clicked corner to the +// current selection without clearing it. +func (mv *MapViewer) handlePrimaryCtrlShiftClick(event *desktop.MouseEvent) { + nx, ny := mv.calculateSelectionBounds(event.Position) + minX, maxX := min(mv.selectedX, nx), max(mv.selectedX, nx) + minY, maxY := min(mv.SelectedY, ny), max(mv.SelectedY, ny) + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + cell := y*mv.numColumns + x + if !slices.Contains(mv.selectedCells, cell) { + mv.selectedCells = append(mv.selectedCells, cell) + } + } + } + mv.dragCornerX, mv.dragCornerY = nx, ny + mv.drawSelectionVisual() +} + +// handlePrimaryClickWithShift extends the selection from the current anchor to +// the clicked cell and begins a drag. func (mv *MapViewer) handlePrimaryClickWithShift(event *desktop.MouseEvent) { - nselectedX, nSelectedY := mv.calculateSelectionBounds(event.Position) - mv.updateSelection(nselectedX, nSelectedY) + nx, ny := mv.calculateSelectionBounds(event.Position) + mv.dragCornerX, mv.dragCornerY = nx, ny + mv.selectRegion(nx, ny) mv.selecting = true } +// selectRegion replaces the current selection with the rectangular block +// between the anchor cell (selectedX/SelectedY) and the given corner, updating +// the overlay highlight live. +func (mv *MapViewer) selectRegion(cornerX, cornerY int) { + minX, maxX := min(mv.selectedX, cornerX), max(mv.selectedX, cornerX) + minY, maxY := min(mv.SelectedY, cornerY), max(mv.SelectedY, cornerY) + + mv.clearSelectionVisual() + mv.selectedCells = mv.selectedCells[:0] + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + mv.selectedCells = append(mv.selectedCells, y*mv.numColumns+x) + } + } + mv.drawSelectionVisual() +} + func (mv *MapViewer) handleSecondaryClick(event *desktop.MouseEvent) { + x, y := mv.calculateSelectionBounds(event.Position) + clickedCell := y*mv.numColumns + x + + // The clicked cell becomes the anchor for "Paste here" regardless. + mv.selectedX, mv.SelectedY = x, y + + // Right-clicking inside the current selection keeps it (so Copy/Smooth act + // on the whole block). Right-clicking outside it, or with no selection, + // selects just that cell. + if !slices.Contains(mv.selectedCells, clickedCell) { + mv.clearSelectionVisual() + mv.selectedCells = []int{clickedCell} + mv.drawSelectionVisual() + } + mv.showPopupMenu(event.AbsolutePosition) } @@ -159,8 +190,8 @@ func (mv *MapViewer) calculateCellDimensions() (float32, float32) { // calculateSelectionBounds computes the bounding box of the selection area. func (mv *MapViewer) calculateSelectionBounds(eventPos fyne.Position) (int, int) { cellWidth, cellHeight := mv.calculateCellDimensions() - //xAxisOffset := mv.yAxisLabelContainer.Size().Width - //yAxisOffset := mv.xAxisLabelContainer.Size().Height + // xAxisOffset := mv.yAxisLabelContainer.Size().Width + // yAxisOffset := mv.xAxisLabelContainer.Size().Height // Adjust for inner view position relative to the parent container // This accounts for any extra padding or layout adjustments @@ -172,69 +203,29 @@ func (mv *MapViewer) calculateSelectionBounds(eventPos fyne.Position) (int, int) return nselectedX, nSelectedY } -// updateSelection updates the selection based on the new cursor position. -func (mv *MapViewer) updateSelection(nselectedX, nSelectedY int) { - cellWidth, cellHeight := mv.calculateCellDimensions() - difX := int(math.Abs(float64(nselectedX - mv.selectedX))) - difY := int(math.Abs(float64(nSelectedY - mv.SelectedY))) - - topLeftX := float32(min(mv.selectedX, nselectedX)) * cellWidth - topLeftY := float32(mv.numRows-1-max(mv.SelectedY, nSelectedY)) * cellHeight - - mv.updateCursorPositionAndSize(topLeftX, topLeftY, float32(difX+1)*cellWidth, float32(difY+1)*cellHeight) -} - -// updateCursorPositionAndSize updates the cursor's position and size on the screen. -func (mv *MapViewer) updateCursorPositionAndSize(topLeftX, topLeftY, width, height float32) { - mv.selectionRect.Resize(fyne.NewSize(width+2, height+1)) - mv.selectionRect.Move(fyne.NewPos(topLeftX-1, topLeftY)) -} - // handleFocusAndInputBuffer focuses the MapViewer and clears the input buffer if necessary. func (mv *MapViewer) handleFocusAndInputBuffer() { + // Take keyboard focus so the key handler (cell editing, increment/decrement, + // arrow-key navigation) receives events. The multiwindow manager focuses a + // map when its inner window is raised, but a MapViewer placed directly in a + // container (e.g. the matrix builder) is never focused otherwise, so clicking + // a cell would leave key presses with nowhere to go. + if c := fyne.CurrentApp().Driver().CanvasForObject(mv); c != nil { + c.Focus(mv) + } if mv.inputBuffer.Len() > 0 { mv.inputBuffer.Reset() mv.restoreSelectedValues() } } -// finalizeSelection finalizes the selection process. +// finalizeSelection ends a drag. The selection was already built live during +// MouseMoved, so this just snaps to the release position and stops selecting. func (mv *MapViewer) finalizeSelection(eventPos fyne.Position) { // log.Println("finalizeSelection") mv.selecting = false - - nselectedX, nSelectedY := mv.calculateSelectionBounds(eventPos) - mv.updateSelection(nselectedX, nSelectedY) - - // For Ctrl selections, we don't want to clear existing selections - if mv.lastModifier != fyne.KeyModifierControl { - mv.selectedCells = make([]int, 0) - } - - topLeftX := min(mv.selectedX, nselectedX) - bottomRightX := max(mv.selectedX, nselectedX) - topLeftY := min(mv.SelectedY, nSelectedY) - bottomRightY := max(mv.SelectedY, nSelectedY) - - for y := topLeftY; y <= bottomRightY; y++ { - for x := topLeftX; x <= bottomRightX; x++ { - zIndex := y*mv.numColumns + x - if mv.lastModifier == fyne.KeyModifierControl { - // For Ctrl, toggle selection - if index := slices.Index(mv.selectedCells, zIndex); index != -1 { - mv.selectedCells = append(mv.selectedCells[:index], mv.selectedCells[index+1:]...) - mv.zDataRects[zIndex].FillColor = mv.zDataRects[zIndex].StrokeColor - } else { - mv.selectedCells = append(mv.selectedCells, zIndex) - mv.zDataRects[zIndex].FillColor = theme.Color(theme.ColorNameForegroundOnPrimary) - } - } else { - mv.selectedCells = append(mv.selectedCells, zIndex) - mv.zDataRects[zIndex].FillColor = theme.Color(theme.ColorNameForegroundOnPrimary) - } - mv.zDataRects[zIndex].Refresh() - } - } + nx, ny := mv.calculateSelectionBounds(eventPos) + mv.selectRegion(nx, ny) } func (mv *MapViewer) showPopupMenu(pos fyne.Position) { @@ -245,15 +236,28 @@ func (mv *MapViewer) showPopupMenu(pos fyne.Position) { }), ) if mv.cfg.Editable { - menu.Items = append(menu.Items, - fyne.NewMenuItem("Paste", func() { + pasteMenu := fyne.NewMenuItem("Paste", nil) + pasteMenu.ChildMenu = fyne.NewMenu("Paste Options", + fyne.NewMenuItem("At original position", func() { mv.paste() }), + fyne.NewMenuItem("At currently selected location", func() { + mv.pasteHere() + }), + ) + + menu.Items = append(menu.Items, + pasteMenu, fyne.NewMenuItem("Smooth", func() { mv.smooth() }), ) } + + if mv.mesh != nil { + menu.Items = append(menu.Items, fyne.NewMenuItem("Toggle 3D Mesh", mv.toggleMesh)) + } + popupMenu := widget.NewPopUpMenu(menu, fyne.CurrentApp().Driver().CanvasForObject(mv), ) diff --git a/pkg/widgets/mapviewer/mapviewer_opts.go b/pkg/widgets/mapviewer/mapviewer_opts.go index f7065139..7b72db6b 100644 --- a/pkg/widgets/mapviewer/mapviewer_opts.go +++ b/pkg/widgets/mapviewer/mapviewer_opts.go @@ -1,8 +1,11 @@ package mapviewer import ( + "image/color" + "fyne.io/fyne/v2" "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/widgets/meshgrid" ) type Config struct { @@ -27,12 +30,21 @@ type Config struct { OnUpdateCell func(idx int, value []float64) OnMouseDown func() - MeshView bool + MeshView bool + MeshRenderer meshgrid.RenderBackend + Editable bool CursorFollowCrosshair bool ColorblindMode colors.ColorBlindMode + // RegionBorder marks cells (same flat row-major order as ZData) that should + // be drawn with a contrasting border, e.g. to outline the closed-loop fuel + // area. nil or wrong length = no border drawn. + RegionBorder []bool + // RegionBorderColor is the border colour; zero value falls back to a default. + RegionBorderColor color.RGBA + Buttons []*MapViewerButton } diff --git a/pkg/widgets/math.go b/pkg/widgets/math.go deleted file mode 100644 index 21ccf48b..00000000 --- a/pkg/widgets/math.go +++ /dev/null @@ -1,23 +0,0 @@ -package widgets - -type Number interface { - ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 -} - -// Generic function that works with any numeric type -func FindMinMax[T Number](data []T) (T, T) { - if len(data) == 0 { - panic("empty slice") - } - - min, max := data[0], data[0] - for _, v := range data { - if v < min { - min = v - } - if v > max { - max = v - } - } - return min, max -} diff --git a/pkg/widgets/matrixbuilder/filter_test.go b/pkg/widgets/matrixbuilder/filter_test.go new file mode 100644 index 00000000..5e074495 --- /dev/null +++ b/pkg/widgets/matrixbuilder/filter_test.go @@ -0,0 +1,135 @@ +package matrixbuilder + +import "testing" + +// leaf builds a comparison condition node. +func leaf(series, op string, value float64) *FilterNode { + return &FilterNode{Series: series, Operator: op, Value: value} +} + +func TestFilterNodeToQuery(t *testing.T) { + tests := []struct { + name string + node *FilterNode + want string // empty means ok=false (nothing to contribute) + }{ + { + "single leaf, no outer parens at root", + &FilterNode{Combinator: "and", Children: []*FilterNode{leaf("rpm", ">", 3000)}}, + "rpm > 3000", + }, + { + "two anded at root", + &FilterNode{Combinator: "and", Children: []*FilterNode{ + leaf("rpm", ">", 3000), leaf("load", ">", 50), + }}, + "rpm > 3000 and load > 50", + }, + { + "nested group keeps its parens", + &FilterNode{Combinator: "or", Children: []*FilterNode{ + {Combinator: "and", Children: []*FilterNode{leaf("rpm", ">", 3000), leaf("load", ">", 50)}}, + {Series: "ECMStat.ST_ActiveAirDem", Operator: "in", Values: []float64{10, 20}}, + }}, + "(rpm > 3000 and load > 50) or ECMStat.ST_ActiveAirDem in [10, 20]", + }, + { + "negated group at root", + &FilterNode{Combinator: "and", Negate: true, Children: []*FilterNode{ + leaf("rpm", ">", 3000), leaf("load", ">", 50), + }}, + "not (rpm > 3000 and load > 50)", + }, + { + "negated leaf", + &FilterNode{Combinator: "and", Children: []*FilterNode{ + {Series: "rpm", Operator: ">", Value: 3000, Negate: true}, + }}, + "not (rpm > 3000)", + }, + { + "incomplete leaves are dropped", + &FilterNode{Combinator: "and", Children: []*FilterNode{ + leaf("rpm", ">", 3000), + {Series: "", Operator: ">", Value: 1}, // no series + {Series: "x", Operator: "in", Values: nil}, // empty list + }}, + "rpm > 3000", + }, + {"empty group contributes nothing", &FilterNode{Combinator: "and"}, ""}, + { + "group with only incomplete children contributes nothing", + &FilterNode{Combinator: "and", Children: []*FilterNode{{Series: ""}}}, + "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := tt.node.toQuery(true) + if tt.want == "" { + if ok { + t.Fatalf("toQuery() = %q, want ok=false", got) + } + return + } + if !ok { + t.Fatalf("toQuery() ok=false, want %q", tt.want) + } + if got != tt.want { + t.Errorf("toQuery() = %q, want %q", got, tt.want) + } + // Whatever the builder emits must be a valid query. + if _, err := compileQuery(got, resolverFrom(nil)); err != nil { + t.Errorf("compileQuery(%q) error: %v", got, err) + } + }) + } +} + +// The builder is just a structured query author, so a tree and its hand-written +// query equivalent must filter identically. +func TestFilterNodeMatchesQuery(t *testing.T) { + series := map[string][]float64{ + "rpm": {1000, 4000, 4000, 4000}, + "load": {10, 60, 40, 60}, + "state": {10, 20, 30, 10}, + } + resolve := resolverFrom(series) + + // (rpm > 3000 and load > 50) or state in [10, 20] + tree := &FilterNode{Combinator: "or", Children: []*FilterNode{ + {Combinator: "and", Children: []*FilterNode{leaf("rpm", ">", 3000), leaf("load", ">", 50)}}, + {Series: "state", Operator: "in", Values: []float64{10, 20}}, + }} + + src, ok := tree.toQuery(true) + if !ok { + t.Fatal("toQuery() ok=false") + } + q, err := compileQuery(src, resolve) + if err != nil { + t.Fatalf("compileQuery(%q) error: %v", src, err) + } + want := [4]bool{true, true, false, true} // row0 state=10; row1 rpm&load; row3 state=10 + for i := 0; i < 4; i++ { + if got := q.Eval(i); got != want[i] { + t.Errorf("Eval(%d) = %v, want %v (query %q)", i, got, want[i], src) + } + } +} + +func TestRulesToNodeMigration(t *testing.T) { + rules := []Rule{ + {Series: "rpm", Operator: ">", Threshold: 3000}, + {Series: "load", Operator: "<=", Threshold: 80}, + } + got, ok := rulesToNode(rules).toQuery(true) + if !ok { + t.Fatal("toQuery() ok=false") + } + want := "rpm > 3000 and load <= 80" + if got != want { + t.Errorf("migrated query = %q, want %q", got, want) + } +} diff --git a/pkg/widgets/matrixbuilder/matrixbuilder.go b/pkg/widgets/matrixbuilder/matrixbuilder.go new file mode 100644 index 00000000..3bbef794 --- /dev/null +++ b/pkg/widgets/matrixbuilder/matrixbuilder.go @@ -0,0 +1,1669 @@ +// Package matrixbuilder provides a widget that learns a 2D map (matrix) from +// one or more log files. The widget loads the logs itself and merges their +// series, then builds the matrix from three selected series: one drives the X +// axis, one the Y axis and one supplies the Z value written into the cell the +// X/Y pair lands on. Every sample that maps to a cell is accumulated and the +// cell's final value is the average of all its hits. The resulting matrix is +// shown with a mapviewer (colored grid + 3D meshgrid), and both the axis +// breakpoints and the series can be edited or typed by hand. +package matrixbuilder + +import ( + "encoding/json" + "fmt" + "io" + "log" + "math" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + xlayout "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/layout" + "github.com/roffe/txlogger/pkg/logfile" + "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" + "github.com/roffe/txlogger/pkg/widgets/meshgrid" + "github.com/roffe/txlogger/pkg/widgets/progressmodal" +) + +const ( + minAxis = 1 + maxAxis = 40 + defaultCols = 8 + defaultRows = 8 + + // Tolerance is expressed as a percentage of a cell's half-spacing (the + // distance from a breakpoint to the midpoint between it and its neighbor). + // At 100% the whole nearest-neighbor region counts as a hit (the original + // behaviour); lower values reject samples that fall near the cell edges, + // keeping only those close to the breakpoint. + minTolerance = 1 + defaultTolerance = 100 +) + +// Display views for the built matrix. viewMatrix shows the learned Z values; +// viewCoverage shows how many samples landed in each cell. +const ( + viewMatrix = "Matrix" + viewCoverage = "Coverage (hits)" +) + +var _ fyne.Widget = (*MatrixBuilder)(nil) + +type MatrixBuilder struct { + widget.BaseWidget + renderMode meshgrid.RenderBackend + + // values holds every series merged across all loaded log files. All series + // share the same length (nrecords); gaps are padded with NaN so samples + // from different files stay row-aligned. + values map[string][]float64 + order []string + loadedFiles []string + nrecords int + + xSeries, ySeries, zSeries string + + cols, rows int + xAxis []float64 + yAxis []float64 + zData []float64 + + // counts holds the number of samples that landed in each cell during the + // last analyze, parallel to zData. It feeds the Coverage view. + counts []int + + // xTolerance/yTolerance gate how close (as a percentage of the cell's + // half-spacing) a sample must be to its nearest breakpoint to count as a + // Z-hit on that axis. A sample is mapped only if it passes on both axes. + xTolerance, yTolerance float64 + + // built becomes true after the first successful analysis; until then the + // display area shows a placeholder instead of an all-zero grid. + built bool + + // widgets + colsLabel, rowsLabel *widget.Label + status *widget.Label + logsLabel *widget.Label + xBox, yBox *fyne.Container + xEntries, yEntries []*widget.Entry + xSel, ySel, zSel *widget.SelectEntry + xTolSlider, yTolSlider *widget.Slider + xTolLabel, yTolLabel *widget.Label + presetSelect *widget.Select + nameEntry *widget.Entry + viewSelect *widget.Select + display *fyne.Container + + // Filter tree: a nested group/condition builder. rootGroup is the live editor + // state (read into a FilterNode tree on demand); filterHolder is the stable + // container the root group is swapped into when a preset is loaded. + rootGroup *filterGroup + filterHolder *fyne.Container + + // Filter query: an optional text query in the matrix builder query language. + // When non-empty it overrides the visual rules above (see currentFilter). + queryEntry *widget.Entry + queryStatus *widget.Label + + // controls is the scrolling left/right column that holds the rule editor. + // Adding or removing rule rows only re-lays-out the rules box itself, so we + // refresh this ancestor to reposition everything below it (see + // refreshControls). + controls *fyne.Container + + content fyne.CanvasObject +} + +// filterChild is one editable node in the visual filter tree: either a group or +// a leaf condition. node reads the widgets into a FilterNode (returning nil for +// an incomplete leaf); object is the canvas object the parent group lays out. +type filterChild interface { + node() *FilterNode + object() fyne.CanvasObject +} + +// filterGroup is a group node in the editor: a combinator (ALL/ANY), an optional +// "not", and an ordered list of child conditions and sub-groups. +type filterGroup struct { + mb *MatrixBuilder + parent *filterGroup // nil for the root group + combinator *widget.Select + negate *widget.Check + children []filterChild + inner *fyne.Container // holds the children plus the add-buttons footer + footer fyne.CanvasObject + container *fyne.Container +} + +// filterCond is a leaf condition in the editor: " ", where +// is a single number, or a comma-separated list when is "in". +type filterCond struct { + parent *filterGroup + seriesSel *widget.SelectEntry + opSel *widget.Select + value *widget.Entry + container *fyne.Container +} + +// Preset is the on-disk representation of a matrix builder configuration. It +// holds only settings (series, dimensions and axis breakpoints); the learned +// matrix values are never stored. +type Preset struct { + XSeries string `json:"x_series"` + YSeries string `json:"y_series"` + ZSeries string `json:"z_series"` + Cols int `json:"cols"` + Rows int `json:"rows"` + XAxis []float64 `json:"x_axis"` + YAxis []float64 `json:"y_axis"` + // XTolerance/YTolerance are percentages (1..100). Omitted in older presets, + // which decode to 0 and are treated as the default (no filtering) on load. + XTolerance float64 `json:"x_tolerance,omitempty"` + YTolerance float64 `json:"y_tolerance,omitempty"` + // Filter is the root of the visual builder's group/condition tree. Omitted in + // presets saved before the tree existed; those carry Rules instead. + Filter *FilterNode `json:"filter,omitempty"` + // Rules is the legacy flat filter list. Read-only now: still decoded for + // backward compatibility (migrated into an implicit ALL-of group on load), but + // no longer written. Older presets that predate filters decode to nil. + Rules []Rule `json:"rules,omitempty"` + // Query is an optional filter written in the query language. When non-empty + // it overrides the builder (matching the live behaviour). Omitted in older + // presets. + Query string `json:"query,omitempty"` +} + +// FilterNode is one node of the visual builder's filter tree, persisted in a +// preset. It is either a group (Series empty: combines Children with Combinator, +// optionally negated) or a leaf condition (Series set: " +// ", or " in " when Operator is "in"). +type FilterNode struct { + Combinator string `json:"combinator,omitempty"` // group: "and" | "or" + Negate bool `json:"negate,omitempty"` // wrap the node in not(...) + Children []*FilterNode `json:"children,omitempty"` // group members + + Series string `json:"series,omitempty"` // leaf series name + Operator string `json:"operator,omitempty"` // leaf comparison operator + Value float64 `json:"value,omitempty"` // leaf threshold (non-"in" ops) + Values []float64 `json:"values,omitempty"` // leaf membership list ("in" op) +} + +// isGroup reports whether n is a group rather than a leaf. A leaf always names a +// series; a group never does. +func (n *FilterNode) isGroup() bool { return n.Series == "" } + +// toQuery renders n as a query-language fragment, returning ok=false when the +// node contributes nothing (an incomplete leaf, or a group with no usable +// children). top suppresses the redundant outer parentheses on the root group. +func (n *FilterNode) toQuery(top bool) (string, bool) { + if n.isGroup() { + parts := make([]string, 0, len(n.Children)) + for _, c := range n.Children { + if s, ok := c.toQuery(false); ok { + parts = append(parts, s) + } + } + if len(parts) == 0 { + return "", false + } + joiner := " and " + if n.Combinator == "or" { + joiner = " or " + } + s := strings.Join(parts, joiner) + if (len(parts) > 1 && !top) || n.Negate { + s = "(" + s + ")" + } + if n.Negate { + s = "not " + s + } + return s, true + } + + if strings.TrimSpace(n.Series) == "" { + return "", false + } + var s string + if n.Operator == "in" { + if len(n.Values) == 0 { + return "", false + } + members := make([]string, len(n.Values)) + for i, v := range n.Values { + members[i] = formatFloat(v) + } + s = fmt.Sprintf("%s in [%s]", n.Series, strings.Join(members, ", ")) + } else { + op := n.Operator + if op == "" { + op = condOperators[0] + } + s = fmt.Sprintf("%s %s %s", n.Series, op, formatFloat(n.Value)) + } + if n.Negate { + s = "not (" + s + ")" + } + return s, true +} + +// rulesToNode migrates a legacy flat rule list into an implicit ALL-of group. +func rulesToNode(rules []Rule) *FilterNode { + root := &FilterNode{Combinator: "and"} + for _, r := range rules { + root.Children = append(root.Children, &FilterNode{ + Series: r.Series, Operator: r.Operator, Value: r.Threshold, + }) + } + return root +} + +// Rule is a single filter condition. A sample is only counted as a Z-hit when it +// satisfies every active rule: the named series' value at that sample, compared +// against Threshold with Operator, must hold. +type Rule struct { + Series string `json:"series"` + Operator string `json:"operator"` // one of ">", ">=", "<", "<=", "==", "!=", "~" + Threshold float64 `json:"threshold"` +} + +// condOperators lists the operators offered in a condition row, in display +// order. The first entry is the default for a new condition. "~" matches values +// approximately equal to the value (see ruleEpsilonFrac); "in" tests membership +// of a comma-separated list. +var condOperators = []string{">", ">=", "<", "<=", "==", "!=", "~", "in"} + +// combinator labels for a group, mapping to the query language's and/or. +const ( + combinatorAll = "ALL of" // every child must hold -> "and" + combinatorAny = "ANY of" // any child may hold -> "or" +) + +var combinatorOptions = []string{combinatorAll, combinatorAny} + +const ( + // ruleEpsilonFrac sets the half-width of the "~" (approximately-equal) + // operator as a fraction of the threshold's magnitude, so the window scales + // with the value being matched (e.g. 1% of an RPM target vs. 1% of a lambda + // target). + ruleEpsilonFrac = 0.01 + // ruleEpsilonMin is a small absolute floor so "~" stays usable when the + // threshold is at or near zero (where a relative window would collapse to 0). + ruleEpsilonMin = 1e-6 +) + +// New creates an empty MatrixBuilder. Log files are loaded from within the +// widget via a native file dialog. +func New(renderMode meshgrid.RenderBackend) *MatrixBuilder { + mb := &MatrixBuilder{ + values: make(map[string][]float64), + cols: defaultCols, + rows: defaultRows, + xTolerance: defaultTolerance, + yTolerance: defaultTolerance, + renderMode: renderMode, + } + mb.ExtendBaseWidget(mb) + mb.xAxis = make([]float64, mb.cols) + mb.yAxis = make([]float64, mb.rows) + mb.zData = make([]float64, mb.cols*mb.rows) + mb.buildUI() + return mb +} + +func (mb *MatrixBuilder) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(mb.content) +} + +func (mb *MatrixBuilder) buildUI() { + // SelectEntry lets the user pick a loaded series from the dropdown or type a + // name manually. + mb.xSel = widget.NewSelectEntry(mb.order) + mb.xSel.OnChanged = func(s string) { mb.xSeries = s } + mb.ySel = widget.NewSelectEntry(mb.order) + mb.ySel.OnChanged = func(s string) { mb.ySeries = s } + mb.zSel = widget.NewSelectEntry(mb.order) + mb.zSel.OnChanged = func(s string) { mb.zSeries = s } + mb.xSel.PlaceHolder = "X series" + mb.ySel.PlaceHolder = "Y series" + mb.zSel.PlaceHolder = "Z series" + + mb.colsLabel = widget.NewLabel(strconv.Itoa(mb.cols)) + mb.rowsLabel = widget.NewLabel(strconv.Itoa(mb.rows)) + mb.status = widget.NewLabel("") + mb.status.Wrapping = fyne.TextWrapWord + + colsRow := container.NewBorder(nil, nil, + widget.NewLabel("Columns (X)"), + container.NewHBox( + widget.NewButton("-", func() { mb.setCols(mb.cols - 1) }), + mb.colsLabel, + widget.NewButton("+", func() { mb.setCols(mb.cols + 1) }), + widget.NewButton("Auto", func() { mb.autoFill(true) }), + ), + ) + rowsRow := container.NewBorder(nil, nil, + widget.NewLabel("Rows (Y)"), + container.NewHBox( + widget.NewButton("-", func() { mb.setRows(mb.rows - 1) }), + mb.rowsLabel, + widget.NewButton("+", func() { mb.setRows(mb.rows + 1) }), + widget.NewButton("Auto", func() { mb.autoFill(false) }), + ), + ) + + buildBtn := widget.NewButtonWithIcon("Build matrix", theme.GridIcon(), func() { + if err := mb.analyze(); err != nil { + mb.status.SetText(err.Error()) + return + } + mb.rebuildDisplay() + }) + buildBtn.Importance = widget.HighImportance + + // View toggle: switch the display between the learned matrix and a coverage + // heatmap of how many samples landed in each cell. + mb.viewSelect = widget.NewSelect([]string{viewMatrix, viewCoverage}, func(string) { + mb.rebuildDisplay() + }) + // Set the field directly rather than SetSelected: the latter fires OnChanged, + // which calls rebuildDisplay before mb.display exists (panic during buildUI). + mb.viewSelect.Selected = viewMatrix + bottomBar := container.NewBorder(nil, nil, nil, + container.NewHBox(widget.NewLabel("View"), mb.viewSelect), + buildBtn, + ) + + mb.xBox = container.NewHBox() + mb.yBox = container.NewVBox() + mb.rebuildAxisEntries() + + mb.controls = container.NewVBox( + // widget.NewLabelWithStyle("Series", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + colsRow, + rowsRow, + widget.NewSeparator(), + labeled("X", mb.xSel), + labeled("Y", mb.ySel), + labeled("Z", mb.zSel), + widget.NewSeparator(), + mb.buildToleranceSection(), + widget.NewSeparator(), + mb.buildFilterSection(), + xlayout.NewSpacer(), + mb.status, + ) + + controlScroll := container.NewVScroll(mb.controls) + controlScroll.SetMinSize(fyne.NewSize(240, 0)) + + logList := container.NewBorder( + nil, + container.NewVBox( + mb.buildPresetSection(), + ), + nil, + nil, + container.NewVScroll( + mb.buildLogSection(), + ), + ) + + mb.display = container.NewStack(mb.placeholder()) + + // The X scale runs along the horizontal axis of the map; its editor sits as a + // horizontal strip above the display. + xPanel := container.NewBorder(nil, nil, + widget.NewLabelWithStyle("X axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + nil, + container.NewHScroll(mb.xBox), + ) + + mainSplit := container.NewHSplit( + container.NewBorder( + xPanel, + bottomBar, + container.NewVBox( + widget.NewLabelWithStyle("Y axis values", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + mb.yBox, + ), + nil, + mb.display, + ), + logList, + ) + mainSplit.Offset = 0.9 + + /* + mb.content = container.NewBorder( + nil, + nil, + controlScroll, + nil, + mainSplit, + ) + */ + split := container.NewHSplit(controlScroll, mainSplit) + split.Offset = 0.25 + mb.content = split +} + +func (mb *MatrixBuilder) placeholder() fyne.CanvasObject { + return container.NewCenter(widget.NewLabel("Select X, Y and Z series, then click \"Build matrix\"")) +} + +// buildToleranceSection builds the per-axis Z-hit tolerance sliders. Each +// slider sets the maximum distance (as a percentage of the cell's half-spacing) +// a sample may sit from its nearest breakpoint and still count as a hit. +func (mb *MatrixBuilder) buildToleranceSection() fyne.CanvasObject { + mb.xTolLabel = widget.NewLabel(tolText(mb.xTolerance)) + mb.yTolLabel = widget.NewLabel(tolText(mb.yTolerance)) + mb.xTolSlider = mb.newToleranceSlider(true) + mb.yTolSlider = mb.newToleranceSlider(false) + + return container.NewVBox( + widget.NewLabelWithStyle("Z-hit tolerance", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.NewBorder(nil, nil, widget.NewLabel("X"), layout.NewFixedWidth(44, mb.xTolLabel), mb.xTolSlider), + container.NewBorder(nil, nil, widget.NewLabel("Y"), layout.NewFixedWidth(44, mb.yTolLabel), mb.yTolSlider), + ) +} + +// newToleranceSlider builds a 1..100% slider bound to the X or Y tolerance. +// While dragging it only updates the value label; on release it re-runs the +// analysis (if a matrix is already built) so the user sees the effect live. +func (mb *MatrixBuilder) newToleranceSlider(isX bool) *widget.Slider { + cur := mb.yTolerance + if isX { + cur = mb.xTolerance + } + s := widget.NewSlider(minTolerance, 100) + s.Step = 1 + s.SetValue(cur) + s.OnChanged = func(f float64) { + if isX { + mb.xTolerance = f + mb.xTolLabel.SetText(tolText(f)) + } else { + mb.yTolerance = f + mb.yTolLabel.SetText(tolText(f)) + } + } + s.OnChangeEnded = func(float64) { + if !mb.built { + return + } + if err := mb.analyze(); err != nil { + mb.status.SetText(err.Error()) + return + } + mb.rebuildDisplay() + } + return s +} + +// --- filter tree --- + +// buildFilterSection builds the visual query builder: a nestable tree of groups +// (ALL-of / ANY-of, optionally negated) and leaf conditions, plus a free-text +// query box that overrides the tree when non-empty. +func (mb *MatrixBuilder) buildFilterSection() fyne.CanvasObject { + mb.rootGroup = mb.newFilterGroup(nil, &FilterNode{Combinator: "and"}) + mb.filterHolder = container.NewVBox(mb.rootGroup.object()) + + // The query box accepts the matrix builder query language: comparisons + // (> >= < <= == != ~), the "in [...]" membership test, joined with and/or/not + // and grouped with (). A leading "if" is optional. When non-empty it overrides + // the visual builder above. + mb.queryEntry = widget.NewMultiLineEntry() + mb.queryEntry.SetMinRowsVisible(2) + mb.queryEntry.Wrapping = fyne.TextWrapWord + mb.queryEntry.SetPlaceHolder("e.g. (ActualIn.n_Engine > 3000 and Out.X_AccPedal > 50) or ECMStat.ST_ActiveAirDem in [10, 20]") + mb.queryEntry.OnChanged = func(string) { mb.validateQuery() } + + mb.queryStatus = widget.NewLabel("") + mb.queryStatus.Wrapping = fyne.TextWrapWord + + fromTreeBtn := widget.NewButton("Builder->Query", func() { + if s, ok := mb.filterTree().toQuery(true); ok { + mb.queryEntry.SetText(s) + } else { + mb.queryEntry.SetText("") + } + }) + fromTreeBtn.Importance = widget.LowImportance + + return container.NewVBox( + widget.NewLabelWithStyle("Filters (count a hit when…)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + mb.filterHolder, + widget.NewSeparator(), + widget.NewLabel("…or a query (overrides the builder when not empty):"), + mb.queryEntry, + container.NewBorder(nil, nil, nil, fromTreeBtn, mb.queryStatus), + ) +} + +// validateQuery parses the query box live and reports its status: a syntax +// error, any referenced series that aren't loaded, or "ok". An empty box defers +// to the visual builder. +func (mb *MatrixBuilder) validateQuery() { + src := strings.TrimSpace(mb.queryEntry.Text) + if src == "" { + mb.queryStatus.SetText("Empty — using the builder above") + return + } + q, err := compileQuery(src, mb.resolve) + if err != nil { + mb.queryStatus.SetText("⚠ " + err.Error()) + return + } + // Only warn about unknown series once logs are loaded; before that every + // name is "unknown" and the noise isn't helpful. + if len(mb.values) > 0 { + var unknown []string + for _, s := range q.Series() { + if _, ok := mb.values[s]; !ok { + unknown = append(unknown, s) + } + } + if len(unknown) > 0 { + mb.queryStatus.SetText("⚠ unknown series: " + strings.Join(unknown, ", ")) + return + } + } + mb.queryStatus.SetText("✓ query active") +} + +// filterTree reads the live editor into a FilterNode tree (the root group). +func (mb *MatrixBuilder) filterTree() *FilterNode { return mb.rootGroup.node() } + +// setFilterTree replaces the editor with a fresh tree built from model, swapping +// the new root group into the stable holder. A nil or non-group model becomes an +// empty (or single-condition) ALL-of root so there is always a group to edit. +func (mb *MatrixBuilder) setFilterTree(model *FilterNode) { + switch { + case model == nil: + model = &FilterNode{Combinator: "and"} + case !model.isGroup(): + model = &FilterNode{Combinator: "and", Children: []*FilterNode{model}} + } + mb.rootGroup = mb.newFilterGroup(nil, model) + mb.filterHolder.Objects = []fyne.CanvasObject{mb.rootGroup.object()} + mb.filterHolder.Refresh() + mb.refreshControls() +} + +// refreshControls re-lays-out the scrolling controls column after the filter +// tree changes shape. A nested Add/Remove only re-runs the affected group's own +// layout, leaving its ancestors (and everything below the filter section) at +// their old positions until the column is refreshed. +func (mb *MatrixBuilder) refreshControls() { + if mb.controls != nil { + mb.controls.Refresh() + } +} + +// indented wraps obj with a left margin so nested groups read as nested. +func indented(obj fyne.CanvasObject) fyne.CanvasObject { + return container.NewBorder(nil, nil, layout.NewFixedWidth(14, xlayout.NewSpacer()), nil, obj) +} + +// --- group node --- + +// newFilterGroup builds a group widget seeded from model, recursively creating +// its child conditions and sub-groups. parent is nil for the root group. +func (mb *MatrixBuilder) newFilterGroup(parent *filterGroup, model *FilterNode) *filterGroup { + g := &filterGroup{mb: mb, parent: parent} + + g.combinator = widget.NewSelect(combinatorOptions, func(string) {}) + if model != nil && model.Combinator == "or" { + g.combinator.SetSelected(combinatorAny) + } else { + g.combinator.SetSelected(combinatorAll) + } + + g.negate = widget.NewCheck("not", nil) + if model != nil { + g.negate.SetChecked(model.Negate) + } + + addCond := widget.NewButton("+ condition", func() { g.addChild(mb.newFilterCond(g, nil)) }) + addCond.Importance = widget.LowImportance + addGroup := widget.NewButton("+ group", func() { g.addChild(mb.newFilterGroup(g, &FilterNode{Combinator: "and"})) }) + addGroup.Importance = widget.LowImportance + g.footer = container.NewHBox(addCond, addGroup) + + header := container.NewHBox(layout.NewFixedWidth(96, g.combinator), g.negate) + var headerRow fyne.CanvasObject = header + if parent != nil { + remove := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() { parent.removeChild(g) }) + remove.Importance = widget.LowImportance + headerRow = container.NewBorder(nil, nil, nil, remove, header) + } + + g.inner = container.NewVBox() + g.container = container.NewVBox(headerRow, indented(g.inner)) + + if model != nil { + for _, ch := range model.Children { + if ch.isGroup() { + g.children = append(g.children, mb.newFilterGroup(g, ch)) + } else { + g.children = append(g.children, mb.newFilterCond(g, ch)) + } + } + } + g.rebuild() + return g +} + +func (g *filterGroup) object() fyne.CanvasObject { return g.container } + +// node reads the group (and, recursively, its children) into a FilterNode, +// dropping incomplete leaves. A group always yields a node, even when empty, so +// the tree's shape survives a save/load round-trip. +func (g *filterGroup) node() *FilterNode { + combinator := "and" + if g.combinator.Selected == combinatorAny { + combinator = "or" + } + n := &FilterNode{Combinator: combinator, Negate: g.negate.Checked} + for _, c := range g.children { + if cn := c.node(); cn != nil { + n.Children = append(n.Children, cn) + } + } + return n +} + +// addChild appends a new condition or sub-group and re-lays-out the group. +func (g *filterGroup) addChild(c filterChild) { + g.children = append(g.children, c) + g.rebuild() +} + +// removeChild drops c from the group and re-lays-out. +func (g *filterGroup) removeChild(c filterChild) { + for i, child := range g.children { + if child == c { + g.children = append(g.children[:i], g.children[i+1:]...) + break + } + } + g.rebuild() +} + +// rebuild repopulates the group's inner box with its children followed by the +// add-buttons footer, then refreshes the surrounding controls column. +func (g *filterGroup) rebuild() { + g.inner.Objects = g.inner.Objects[:0] + for _, c := range g.children { + g.inner.Add(c.object()) + } + g.inner.Add(g.footer) + g.inner.Refresh() + g.mb.refreshControls() +} + +// eachCond visits every leaf condition in the group's subtree, in order. +func (g *filterGroup) eachCond(fn func(*filterCond)) { + for _, c := range g.children { + switch v := c.(type) { + case *filterCond: + fn(v) + case *filterGroup: + v.eachCond(fn) + } + } +} + +// --- condition node --- + +// newFilterCond builds a leaf-condition widget seeded from model (nil for a +// fresh, empty condition). +func (mb *MatrixBuilder) newFilterCond(parent *filterGroup, model *FilterNode) *filterCond { + c := &filterCond{parent: parent} + + c.seriesSel = widget.NewSelectEntry(mb.order) + c.seriesSel.PlaceHolder = "Series" + + c.value = widget.NewEntry() + // The value field doubles as a list when "in" is selected, so its hint tracks + // the operator. + c.opSel = widget.NewSelect(condOperators, func(op string) { + c.value.SetPlaceHolder(valuePlaceholder(op)) + }) + + op := condOperators[0] + if model != nil && model.Operator != "" { + op = model.Operator + } + c.opSel.SetSelected(op) + c.value.SetPlaceHolder(valuePlaceholder(op)) + + if model != nil { + c.seriesSel.SetText(model.Series) + if model.Operator == "in" { + c.value.SetText(formatList(model.Values)) + } else if model.Series != "" { + c.value.SetText(formatFloat(model.Value)) + } + } + + remove := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() { parent.removeChild(c) }) + remove.Importance = widget.LowImportance + + // Series stretches to fill; operator/value/remove are pinned to the right at + // fixed widths so the narrow panel stays readable. The operator box must fit + // the two-char operators plus the dropdown arrow or the Select truncates to "…". + c.container = container.NewBorder(nil, nil, nil, + container.NewHBox( + layout.NewFixedWidth(72, c.opSel), + layout.NewFixedWidth(72, c.value), + remove, + ), + c.seriesSel, + ) + return c +} + +func (c *filterCond) object() fyne.CanvasObject { return c.container } + +// node reads the condition into a FilterNode, returning nil when it is +// incomplete: no series, an empty/invalid value for a comparison, or an empty +// list for "in". Incomplete conditions are dropped rather than matched. +func (c *filterCond) node() *FilterNode { + series := strings.TrimSpace(c.seriesSel.Text) + if series == "" { + return nil + } + op := c.opSel.Selected + if op == "" { + op = condOperators[0] + } + n := &FilterNode{Series: series, Operator: op} + if op == "in" { + n.Values = parseList(c.value.Text) + if len(n.Values) == 0 { + return nil + } + return n + } + v, ok := parseFloatLoose(c.value.Text) + if !ok { + return nil + } + n.Value = v + return n +} + +// valuePlaceholder hints the value field's content for the selected operator. +func valuePlaceholder(op string) string { + if op == "in" { + return "10, 20, …" + } + return "Value" +} + +// formatList renders a membership list as comma-separated numbers. +func formatList(values []float64) string { + parts := make([]string, len(values)) + for i, v := range values { + parts[i] = formatFloat(v) + } + return strings.Join(parts, ", ") +} + +// parseFloatLoose parses a single value, accepting a comma as the decimal point +// (European keyboards). It reports ok=false for blank or unparseable input. +func parseFloatLoose(s string) (float64, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + v, err := strconv.ParseFloat(strings.ReplaceAll(s, ",", "."), 64) + if err != nil { + return 0, false + } + return v, true +} + +// parseList parses a comma-separated membership list, skipping blank or +// unparseable members. Here a comma separates values, so it is not treated as a +// decimal point. +func parseList(s string) []float64 { + var out []float64 + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if v, err := strconv.ParseFloat(part, 64); err == nil { + out = append(out, v) + } + } + return out +} + +// approxEqual reports whether v is approximately equal to threshold, using a +// window that scales with the threshold's magnitude (see ruleEpsilonFrac) with a +// small absolute floor (ruleEpsilonMin) so it stays usable near zero. Shared by +// the per-row "~" rule and the query language's "~" operator. +func approxEqual(v, threshold float64) bool { + eps := math.Abs(threshold) * ruleEpsilonFrac + if eps < ruleEpsilonMin { + eps = ruleEpsilonMin + } + return math.Abs(v-threshold) <= eps +} + +// resolve returns series' value at sample i, reporting ok=false when the series +// is absent or its reading is NaN. It is the bridge the compiled query uses to +// read log data. +func (mb *MatrixBuilder) resolve(series string, i int) (float64, bool) { + data, ok := mb.values[series] + if !ok || i < 0 || i >= len(data) { + return 0, false + } + v := data[i] + if math.IsNaN(v) { + return 0, false + } + return v, true +} + +// currentFilter returns the predicate that decides whether a sample counts as a +// hit. A non-empty query box takes precedence over the visual builder; +// otherwise the builder's tree is compiled to a query (an empty tree accepts +// every sample). A query that fails to compile is returned as an error so Build +// surfaces it instead of silently filtering nothing. +func (mb *MatrixBuilder) currentFilter() (func(i int) bool, error) { + src := strings.TrimSpace(mb.queryEntry.Text) + if src == "" { + // The builder is just a structured way to author a query, so compile it + // through the same path rather than maintaining a second evaluator. + if s, ok := mb.filterTree().toQuery(true); ok { + src = s + } + } + if src == "" { + return func(int) bool { return true }, nil + } + q, err := compileQuery(src, mb.resolve) + if err != nil { + return nil, fmt.Errorf("query: %w", err) + } + return q.Eval, nil +} + +// rebuildAxisEntries regenerates the editable entry fields for the current +// column/row counts, seeding them from the current axis values. +func (mb *MatrixBuilder) rebuildAxisEntries() { + mb.xEntries = make([]*widget.Entry, mb.cols) + mb.xBox.Objects = mb.xBox.Objects[:0] + for i := 0; i < mb.cols; i++ { + mb.xBox.Add(mb.makeAxisEntry(true, i)) + } + mb.xBox.Refresh() + + mb.yEntries = make([]*widget.Entry, mb.rows) + mb.yBox.Objects = mb.yBox.Objects[:0] + // Y runs highest-at-top to match the mapviewer, so add breakpoints in + // descending index order (the axis itself stays sorted ascending). + for i := mb.rows - 1; i >= 0; i-- { + mb.yBox.Add(mb.makeAxisEntry(false, i)) + } + mb.yBox.Refresh() +} + +func (mb *MatrixBuilder) makeAxisEntry(isX bool, idx int) fyne.CanvasObject { + axis := mb.xAxis + prefix := "X" + if !isX { + axis = mb.yAxis + prefix = "Y" + } + e := widget.NewEntry() + e.SetText(formatFloat(axis[idx])) + e.OnChanged = func(s string) { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return + } + if isX { + mb.xAxis[idx] = v + } else { + mb.yAxis[idx] = v + } + } + // Apply (relabel the displayed map) when the user commits a value. + e.OnSubmitted = func(string) { + if mb.built { + mb.rebuildDisplay() + } + } + if isX { + mb.xEntries[idx] = e + // X breakpoints run horizontally along the top: label above a + // fixed-width entry so the strip stays compact. + label := widget.NewLabelWithStyle(prefix+strconv.Itoa(idx), fyne.TextAlignLeading, fyne.TextStyle{}) + return container.NewVBox(label, layout.NewFixedWidth(64, e)) + } + mb.yEntries[idx] = e + // Y breakpoints stack vertically in the side panel: label beside entry. + return container.NewBorder(nil, nil, widget.NewLabel(prefix+strconv.Itoa(idx)), nil, e) +} + +func (mb *MatrixBuilder) setCols(n int) { + n = clamp(n, minAxis, maxAxis) + if n == mb.cols { + return + } + mb.xAxis = resizeAxis(mb.xAxis, n) + mb.cols = n + mb.zData = make([]float64, mb.cols*mb.rows) + mb.built = false + mb.colsLabel.SetText(strconv.Itoa(n)) + mb.rebuildAxisEntries() + mb.rebuildDisplay() +} + +func (mb *MatrixBuilder) setRows(n int) { + n = clamp(n, minAxis, maxAxis) + if n == mb.rows { + return + } + mb.yAxis = resizeAxis(mb.yAxis, n) + mb.rows = n + mb.zData = make([]float64, mb.cols*mb.rows) + mb.built = false + mb.rowsLabel.SetText(strconv.Itoa(n)) + mb.rebuildAxisEntries() + mb.rebuildDisplay() +} + +// autoFill spreads the selected series' min..max evenly across the axis +// breakpoints. isX selects the X axis, otherwise the Y axis. +func (mb *MatrixBuilder) autoFill(isX bool) { + series := mb.ySeries + axis := mb.yAxis + if isX { + series = mb.xSeries + axis = mb.xAxis + } + + data, ok := mb.values[series] + if !ok || len(data) == 0 { + return + } + + lo, hi := common.FindMinMaxFloat64(data) + n := len(axis) + for i := 0; i < n; i++ { + if n == 1 { + axis[i] = lo + continue + } + axis[i] = lo + (hi-lo)*float64(i)/float64(n-1) + } + mb.syncAxisEntries() + if mb.built { + mb.rebuildDisplay() + } +} + +// syncAxisEntries pushes the current axis values back into the entry widgets. +// The entry/axis lengths track each other via rebuildAxisEntries, but the +// guard keeps a transient desync from panicking the UI thread. +func (mb *MatrixBuilder) syncAxisEntries() { + for i, e := range mb.xEntries { + if i >= len(mb.xAxis) { + break + } + e.SetText(formatFloat(mb.xAxis[i])) + } + for i, e := range mb.yEntries { + if i >= len(mb.yAxis) { + break + } + e.SetText(formatFloat(mb.yAxis[i])) + } +} + +// analyze walks the log and learns the matrix: each sample is assigned to the +// nearest cell and the cell's value becomes the average of its hits. +func (mb *MatrixBuilder) analyze() error { + if len(mb.values) == 0 { + return fmt.Errorf("load a log file first") + } + if mb.xSeries == "" || mb.ySeries == "" || mb.zSeries == "" { + return fmt.Errorf("select X, Y and Z series first") + } + xv, okX := mb.values[mb.xSeries] + yv, okY := mb.values[mb.ySeries] + zv, okZ := mb.values[mb.zSeries] + if !okX || !okY || !okZ { + return fmt.Errorf("selected series not found in the loaded logs") + } + n := min(len(xv), min(len(yv), len(zv))) + if n == 0 { + return fmt.Errorf("selected series contain no samples") + } + + // Sort the axes ascending so the learned map reads like a normal table. + sort.Float64s(mb.xAxis) + sort.Float64s(mb.yAxis) + mb.syncAxisEntries() + + filter, err := mb.currentFilter() + if err != nil { + return err + } + + size := mb.cols * mb.rows + sum := make([]float64, size) + cnt := make([]int, size) + used := 0 + skipped := 0 + filtered := 0 + for i := 0; i < n; i++ { + // Skip rows where any of the three series is missing (NaN padding from + // merging logs with differing channel sets). + if math.IsNaN(xv[i]) || math.IsNaN(yv[i]) || math.IsNaN(zv[i]) { + continue + } + // Drop samples that fail any user-defined filter rule before they can + // contribute to a cell. + if !filter(i) { + filtered++ + continue + } + c := nearestIndex(mb.xAxis, xv[i]) + r := nearestIndex(mb.yAxis, yv[i]) + // Reject samples sitting too far from their nearest breakpoint on + // either axis, so only values close to a cell count as a Z-hit. + if !withinTolerance(mb.xAxis, c, xv[i], mb.xTolerance) || + !withinTolerance(mb.yAxis, r, yv[i], mb.yTolerance) { + skipped++ + continue + } + idx := r*mb.cols + c + sum[idx] += zv[i] + cnt[idx]++ + used++ + } + + mb.zData = make([]float64, size) + mb.counts = cnt + filled := 0 + for i := range sum { + if cnt[i] > 0 { + mb.zData[i] = sum[i] / float64(cnt[i]) + filled++ + } + } + mb.built = true + msg := fmt.Sprintf("Mapped %d samples, %d/%d cells filled", used, filled, size) + if skipped > 0 { + msg += fmt.Sprintf(" (%d skipped by tolerance)", skipped) + } + if filtered > 0 { + msg += fmt.Sprintf(" (%d filtered)", filtered) + } + mb.status.SetText(msg) + return nil +} + +// rebuildDisplay swaps a freshly built mapviewer (grid + 3D mesh) into the +// display area, reflecting the current axes and learned Z data. Before the +// first build it shows the placeholder instead. +func (mb *MatrixBuilder) rebuildDisplay() { + if !mb.built { + mb.display.Objects = []fyne.CanvasObject{mb.placeholder()} + mb.display.Refresh() + return + } + + // Default to the learned matrix; the Coverage view swaps in the per-cell hit + // counts as a read-only heatmap. + zData := mb.zData + zLabel := mb.zSeries + zPrec := precisionFor(mb.zData) + editable := true + if mb.viewSelect != nil && mb.viewSelect.Selected == viewCoverage { + zData = countsToFloat(mb.counts) + zLabel = "Hits" + zPrec = 0 + editable = false + } + + noop := func([]float64) {} + mv, err := mapviewer.New(&mapviewer.Config{ + Name: zLabel, + XData: mb.xAxis, + YData: mb.yAxis, + ZData: zData, + XPrecision: precisionFor(mb.xAxis), + YPrecision: precisionFor(mb.yAxis), + ZPrecision: zPrec, + XLabel: mb.xSeries, + YLabel: mb.ySeries, + ZLabel: zLabel, + MeshView: true, + MeshRenderer: mb.renderMode, + Editable: editable, + ColorblindMode: colors.ModeNormal, + // The matrix is in-memory only; editing cells just mutates zData. + SaveECUFunc: noop, + OnUpdateCell: func(int, []float64) {}, + }) + if err != nil { + mb.display.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel(err.Error()))} + mb.display.Refresh() + return + } + mb.display.Objects = []fyne.CanvasObject{mv} + mb.display.Refresh() +} + +// --- log files --- + +func (mb *MatrixBuilder) buildLogSection() fyne.CanvasObject { + mb.logsLabel = widget.NewLabel("No log files loaded") + mb.logsLabel.Truncation = fyne.TextTruncateEllipsis + + addBtn := widget.NewButtonWithIcon("Add log files", theme.FolderOpenIcon(), mb.openLogDialog) + clearBtn := widget.NewButtonWithIcon("Clear", theme.ContentClearIcon(), mb.clearLogs) + + return container.NewVBox( + // widget.NewLabelWithStyle("Log files", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.NewGridWithColumns(2, addBtn, clearBtn), + mb.logsLabel, + ) +} + +// openLogDialog shows the native multi-file picker and loads the chosen logs. +// Parsing runs off the UI goroutine behind a progress modal, then the parsed +// series are merged on the UI goroutine. +func (mb *MatrixBuilder) openLogDialog() { + widgets.SelectFiles(func(readers []fyne.URIReadCloser) { + c := fyne.CurrentApp().Driver().CanvasForObject(mb) + if c == nil { + if wins := fyne.CurrentApp().Driver().AllWindows(); len(wins) > 0 { + c = wins[0].Canvas() + } + } + var pm *progressmodal.ProgressModal + if c != nil { + pm = progressmodal.New(c, fmt.Sprintf("Parsing %d log file(s)...", len(readers))) + pm.Show() + } + + go func() { + type parsed struct { + name string + local map[string][]float64 + n int + } + var ok []parsed + var failed int + for _, r := range readers { + name := r.URI().Name() + local, n, err := parseLog(name, r) + r.Close() + if err != nil { + log.Println("matrixbuilder:", err) + failed++ + continue + } + ok = append(ok, parsed{name, local, n}) + } + + fyne.Do(func() { + if pm != nil { + pm.Hide() + } + for _, p := range ok { + mb.mergeLog(p.name, p.local, p.n) + } + mb.rebuildOrder() + mb.refreshSeriesOptions() + mb.refreshLogList() + if failed > 0 { + mb.status.SetText(fmt.Sprintf("Loaded %d file(s), %d failed", len(ok), failed)) + } else { + mb.status.SetText(fmt.Sprintf("Loaded %d file(s), %d records total", len(ok), mb.nrecords)) + } + }) + }() + }, "logfile", "t5l", "t7l", "t8l", "csv", "bpl") +} + +// parseLog reads a single log file into a row-aligned series map, padding gaps +// with NaN. It touches no shared state, so it is safe to call off the UI +// goroutine. +func parseLog(name string, r io.Reader) (map[string][]float64, int, error) { + lf, err := logfile.Open(name, r) + if err != nil { + return nil, 0, err + } + defer lf.Close() + + local := make(map[string][]float64) + n := 0 + for { + rec := lf.Next() + if rec.EOF { + break + } + for k, v := range rec.Values { + if k == "Pgm_status" { + continue + } + arr := local[k] + for len(arr) < n { // back-fill records before this key first appeared + arr = append(arr, math.NaN()) + } + local[k] = append(arr, v) + } + n++ + for k, arr := range local { // forward-fill keys missing from this record + for len(arr) < n { + arr = append(arr, math.NaN()) + } + local[k] = arr + } + } + if n == 0 { + return nil, 0, fmt.Errorf("%s contains no records", name) + } + return local, n, nil +} + +// mergeLog merges a parsed log into the global series set, padding so all +// series stay row-aligned. Must run on the UI goroutine. +func (mb *MatrixBuilder) mergeLog(name string, local map[string][]float64, n int) { + base := mb.nrecords + for k, arr := range local { + cur, ok := mb.values[k] + if !ok { + cur = nanSlice(base) + } + mb.values[k] = append(cur, arr...) + } + for k, cur := range mb.values { + if _, ok := local[k]; !ok { + mb.values[k] = append(cur, nanSlice(n)...) + } + } + mb.nrecords = base + n + mb.loadedFiles = append(mb.loadedFiles, name) +} + +func (mb *MatrixBuilder) clearLogs() { + mb.values = make(map[string][]float64) + mb.order = nil + mb.loadedFiles = nil + mb.nrecords = 0 + mb.built = false + mb.refreshSeriesOptions() + mb.refreshLogList() + mb.rebuildDisplay() + mb.status.SetText("Cleared loaded logs") +} + +// rebuildOrder refreshes the sorted list of available series names. +func (mb *MatrixBuilder) rebuildOrder() { + mb.order = make([]string, 0, len(mb.values)) + for k := range mb.values { + mb.order = append(mb.order, k) + } + sort.Slice(mb.order, func(i, j int) bool { + return strings.ToLower(mb.order[i]) < strings.ToLower(mb.order[j]) + }) +} + +func (mb *MatrixBuilder) refreshSeriesOptions() { + mb.xSel.SetOptions(mb.order) + mb.ySel.SetOptions(mb.order) + mb.zSel.SetOptions(mb.order) + mb.rootGroup.eachCond(func(c *filterCond) { + c.seriesSel.SetOptions(mb.order) + }) + // Loaded series changed, so the query's unknown-series check may now differ. + if mb.queryEntry != nil { + mb.validateQuery() + } +} + +func (mb *MatrixBuilder) refreshLogList() { + if len(mb.loadedFiles) == 0 { + mb.logsLabel.SetText("No log files loaded") + return + } + mb.logsLabel.SetText(fmt.Sprintf("%d file(s), %d records:\n%s", + len(mb.loadedFiles), mb.nrecords, buildFilenamesList(mb.loadedFiles))) +} + +func buildFilenamesList(files []string) string { + if len(files) == 0 { + return "" + } + var b strings.Builder + for i, f := range files { + if i > 0 { + b.WriteString("\n") + } + base := filepath.Base(f) + //if len(base) > 25 { + // base = base[:25] + "…" + //} + b.WriteString(base) + } + return b.String() +} + +func nanSlice(n int) []float64 { + s := make([]float64, n) + for i := range s { + s[i] = math.NaN() + } + return s +} + +// --- presets --- + +func (mb *MatrixBuilder) buildPresetSection() fyne.CanvasObject { + mb.presetSelect = widget.NewSelect(mb.listPresets(), func(name string) { + if name == "" { + return + } + if err := mb.loadPreset(name); err != nil { + mb.status.SetText(err.Error()) + } + }) + mb.presetSelect.PlaceHolder = "Load preset" + + refreshBtn := widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), mb.refreshPresets) + + mb.nameEntry = widget.NewEntry() + mb.nameEntry.SetPlaceHolder("Preset name") + saveBtn := widget.NewButtonWithIcon("Save", theme.DocumentSaveIcon(), func() { + name := strings.TrimSpace(mb.nameEntry.Text) + if name == "" { + mb.status.SetText("Enter a preset name to save") + return + } + saved, err := mb.savePreset(name) + if err != nil { + mb.status.SetText(err.Error()) + return + } + mb.refreshPresets() + // Reflect the saved name in the picker without re-triggering a load. + mb.presetSelect.Selected = saved + mb.presetSelect.Refresh() + mb.status.SetText("Saved preset " + saved) + }) + + return container.NewVBox( + // widget.NewLabelWithStyle("Presets", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.NewBorder(nil, nil, nil, refreshBtn, mb.presetSelect), + container.NewBorder(nil, nil, nil, saveBtn, mb.nameEntry), + ) +} + +// listPresets returns the names (without extension) of the saved presets. +func (mb *MatrixBuilder) listPresets() []string { + path, err := common.GetMatrixBuilderPath() + if err != nil { + return nil + } + files, err := common.ListFilesInPathByExtension(path, ".json") + if err != nil { + return nil + } + names := make([]string, len(files)) + for i, f := range files { + names[i] = strings.TrimSuffix(f, ".json") + } + return names +} + +func (mb *MatrixBuilder) refreshPresets() { + mb.presetSelect.Options = mb.listPresets() + mb.presetSelect.Refresh() +} + +// savePreset writes the current configuration (and learned matrix, if any) to +// name.json in the matrix builder directory. It returns the stored preset name +// (without extension), which may differ from name after sanitization. +func (mb *MatrixBuilder) savePreset(name string) (string, error) { + path, err := common.GetMatrixBuilderPath() + if err != nil { + return "", err + } + p := Preset{ + XSeries: mb.xSeries, + YSeries: mb.ySeries, + ZSeries: mb.zSeries, + Cols: mb.cols, + Rows: mb.rows, + XAxis: mb.xAxis, + YAxis: mb.yAxis, + XTolerance: mb.xTolerance, + YTolerance: mb.yTolerance, + Filter: mb.filterTree(), + Query: strings.TrimSpace(mb.queryEntry.Text), + } + b, err := json.MarshalIndent(&p, "", " ") + if err != nil { + return "", err + } + stored := common.SanitizeFilename(name + ".json") + if err := os.WriteFile(filepath.Join(path, stored), b, 0o644); err != nil { + return "", err + } + return strings.TrimSuffix(stored, ".json"), nil +} + +// loadPreset reads name.json and applies it to the builder. +func (mb *MatrixBuilder) loadPreset(name string) error { + path, err := common.GetMatrixBuilderPath() + if err != nil { + return err + } + b, err := os.ReadFile(filepath.Join(path, common.SanitizeFilename(name+".json"))) + if err != nil { + return err + } + var p Preset + if err := json.Unmarshal(b, &p); err != nil { + return fmt.Errorf("failed to decode preset: %w", err) + } + mb.applyPreset(&p) + mb.status.SetText("Loaded preset " + name) + return nil +} + +// applyPreset replaces the current state with the preset's, rebuilding the +// editor and display. +func (mb *MatrixBuilder) applyPreset(p *Preset) { + mb.cols = clamp(p.Cols, minAxis, maxAxis) + mb.rows = clamp(p.Rows, minAxis, maxAxis) + mb.xAxis = resizeAxis(p.XAxis, mb.cols) + mb.yAxis = resizeAxis(p.YAxis, mb.rows) + // A preset carries only settings, so the matrix must be rebuilt after load. + // Clear built first: Slider.SetValue below fires OnChangeEnded, and we must + // not let it re-run analyze() against the half-applied state. + mb.zData = make([]float64, mb.cols*mb.rows) + mb.built = false + + // Older presets predate the tolerance fields and decode to 0; fall back to + // the default (no filtering) rather than rejecting every sample. + mb.xTolerance = toleranceOrDefault(p.XTolerance) + mb.yTolerance = toleranceOrDefault(p.YTolerance) + mb.xTolSlider.SetValue(mb.xTolerance) + mb.yTolSlider.SetValue(mb.yTolerance) + mb.xTolLabel.SetText(tolText(mb.xTolerance)) + mb.yTolLabel.SetText(tolText(mb.yTolerance)) + + // SetText fires OnChanged, which just records the series name (no auto-fill), + // so this is safe and keeps mb.xSeries/etc. in sync. + mb.xSel.SetText(p.XSeries) + mb.ySel.SetText(p.YSeries) + mb.zSel.SetText(p.ZSeries) + + // Replace the filter tree with the preset's, falling back to the legacy flat + // rules for presets saved before the tree existed. Restoring the query box + // fires OnChanged, which re-runs validateQuery. + root := p.Filter + if root == nil && len(p.Rules) > 0 { + root = rulesToNode(p.Rules) + } + mb.setFilterTree(root) + mb.queryEntry.SetText(p.Query) + + mb.colsLabel.SetText(strconv.Itoa(mb.cols)) + mb.rowsLabel.SetText(strconv.Itoa(mb.rows)) + mb.rebuildAxisEntries() + mb.rebuildDisplay() +} + +// --- helpers --- + +func labeled(label string, obj fyne.CanvasObject) fyne.CanvasObject { + return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj) +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// resizeAxis grows or shrinks an axis to length n, preserving existing values +// and padding new slots with the previous value (or zero for an empty axis). +func resizeAxis(old []float64, n int) []float64 { + out := make([]float64, n) + copy(out, old) + for i := len(old); i < n; i++ { + if i > 0 { + out[i] = out[i-1] + } + } + return out +} + +// withinTolerance reports whether v is close enough to breakpoint c on a sorted +// axis to count as a hit. tolPct is the allowed distance expressed as a +// percentage of the half-spacing to the neighbouring breakpoint on v's side, so +// 100% accepts the entire nearest-neighbor region and lower values reject +// samples sitting near the cell boundary. Edge cells use their inner spacing as +// the reference, so a sample far beyond the axis range is rejected too. +func withinTolerance(axis []float64, c int, v, tolPct float64) bool { + if tolPct >= 100 || len(axis) < 2 { + return true + } + bp := axis[c] + var spacing float64 + if v >= bp { // neighbour on the high side, falling back to the low side + switch { + case c+1 < len(axis): + spacing = axis[c+1] - bp + case c-1 >= 0: + spacing = bp - axis[c-1] + } + } else { // neighbour on the low side, falling back to the high side + switch { + case c-1 >= 0: + spacing = bp - axis[c-1] + case c+1 < len(axis): + spacing = axis[c+1] - bp + } + } + if spacing <= 0 { // duplicate/degenerate breakpoints: nothing to gate on + return true + } + maxDist := tolPct / 100.0 * (spacing / 2.0) + return math.Abs(v-bp) <= maxDist +} + +// toleranceOrDefault clamps a stored tolerance into the valid range, mapping the +// zero value (older presets without the field) to the default. +func toleranceOrDefault(v float64) float64 { + if v < minTolerance { + return defaultTolerance + } + if v > 100 { + return 100 + } + return v +} + +func tolText(v float64) string { + return strconv.Itoa(int(v)) + "%" +} + +// nearestIndex returns the index of the axis breakpoint closest to v. +func nearestIndex(axis []float64, v float64) int { + best := 0 + bestDist := math.Abs(axis[0] - v) + for i := 1; i < len(axis); i++ { + d := math.Abs(axis[i] - v) + if d < bestDist { + bestDist = d + best = i + } + } + return best +} + +// countsToFloat converts per-cell hit counts to float64 Z data for the +// mapviewer's Coverage view. +func countsToFloat(counts []int) []float64 { + out := make([]float64, len(counts)) + for i, c := range counts { + out[i] = float64(c) + } + return out +} + +// precisionFor picks a sensible decimal precision: 0 for all-integer data, +// otherwise more decimals for small-magnitude values. +func precisionFor(data []float64) int { + allInt := true + maxAbs := 0.0 + for _, v := range data { + if v != math.Trunc(v) { + allInt = false + } + if a := math.Abs(v); a > maxAbs { + maxAbs = a + } + } + if allInt { + return 0 + } + if maxAbs < 10 { + return 3 + } + return 2 +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} diff --git a/pkg/widgets/matrixbuilder/query.go b/pkg/widgets/matrixbuilder/query.go new file mode 100644 index 00000000..ebf2f310 --- /dev/null +++ b/pkg/widgets/matrixbuilder/query.go @@ -0,0 +1,420 @@ +package matrixbuilder + +// This file implements the matrix builder's filter query language. Rather than +// hand-rolling a lexer and parser we lean on Go's own expression parser +// (go/parser) and walk the resulting syntax tree (go/ast). The query grammar is +// deliberately a subset of Go expressions, so once we normalise a few +// human-friendly tokens into their Go equivalents the standard library does the +// hard work of tokenising, applying operator precedence and handling () +// grouping for us. We then "compile" the AST into a tree of small closures that +// can be evaluated cheaply once per log sample. +// +// Normalisation maps the bits that aren't valid Go onto bits that are: +// +// if (rpm > 3000 and load > 50) or boost ~ 1.2 +// (rpm > 3000 && load > 50) || __approx(boost, 1.2) +// +// - a leading "if" is stripped (it reads nicely but carries no meaning) +// - the keywords "and"/"or"/"not" become "&&"/"||"/"!" +// - the approximately-equal operator "a ~ b" becomes a call "__approx(a, b)" +// - the membership test "a in [x, y, z]" becomes a call "__in(a, x, y, z)" +// +// Series names are written verbatim. A bare name (m_Request) parses as an +// *ast.Ident; a dotted name (ActualIn.n_Engine) parses as an *ast.SelectorExpr, +// which we flatten back into the original dotted string. + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "regexp" + "strconv" + "strings" +) + +// boolNode evaluates a condition for the sample at index i. +type boolNode func(i int) bool + +// numNode evaluates a value (a series reading, a literal or arithmetic of them) +// for the sample at index i. ok is false when a referenced series is missing or +// NaN at that sample, which makes every comparison using it fail — matching the +// "drop incomplete samples" behaviour of the older per-row rules. +type numNode func(i int) (float64, bool) + +// queryFilter is a compiled query. Eval reports whether a sample passes; Series +// lists every series the query references, so the UI can warn about names that +// aren't present in the loaded logs. +type queryFilter struct { + eval boolNode + series []string +} + +func (q *queryFilter) Eval(i int) bool { return q.eval(i) } +func (q *queryFilter) Series() []string { return q.series } + +var ( + // A leading "if " (any case) is cosmetic and dropped. \b keeps "iffy" intact. + leadingIfRe = regexp.MustCompile(`(?i)^if\b\s*`) + // "a ~ b" -> "__approx(a, b)". Both operands are simple atoms: a series name + // (optionally dotted) or a number (optionally negative/decimal). This runs + // before the keyword swaps so the operands are still in their raw form. + approxRe = regexp.MustCompile(`([A-Za-z_][\w.]*|-?\d[\d.]*)\s*~\s*(-?\d[\d.]*|[A-Za-z_][\w.]*)`) + // "a in [x, y, z]" -> "__in(a, x, y, z)". The left operand is a simple atom + // (a series name or number); the bracketed list is captured verbatim and + // dropped in as the remaining call args, so go/parser tokenises the members. + // Runs before the keyword swaps so the operand is still in its raw form. + inRe = regexp.MustCompile(`(?i)([A-Za-z_][\w.]*|-?\d[\d.]*)\s+in\s*\[([^\]]*)\]`) + // Keyword operators -> Go operators. \b stops us from rewriting these letters + // when they appear inside a series name (e.g. "Sensor" keeps its "or"). + andRe = regexp.MustCompile(`(?i)\band\b`) + orRe = regexp.MustCompile(`(?i)\bor\b`) + notRe = regexp.MustCompile(`(?i)\bnot\b`) + // go/parser prefixes messages with a "line:col: " position into our + // normalised string, which is meaningless to the user; strip it. + parserPosRe = regexp.MustCompile(`^\d+:\d+:\s*`) +) + +// normalizeQuery rewrites the human-friendly query into a valid Go expression +// string that go/parser can handle. +func normalizeQuery(src string) string { + s := strings.TrimSpace(src) + if s == "" { + return "" + } + s = leadingIfRe.ReplaceAllString(s, "") + s = approxRe.ReplaceAllString(s, "__approx($1, $2)") + s = inRe.ReplaceAllString(s, "__in($1, $2)") + s = andRe.ReplaceAllString(s, "&&") + s = orRe.ReplaceAllString(s, "||") + s = notRe.ReplaceAllString(s, "!") + return strings.TrimSpace(s) +} + +// compileQuery parses and compiles src into a queryFilter. resolve supplies a +// series' value at a sample (returning ok=false for a missing/NaN reading). +// Unknown series are not an error here — they simply never pass — so a query +// can be validated before any logs are loaded. +func compileQuery(src string, resolve func(series string, i int) (float64, bool)) (*queryFilter, error) { + norm := normalizeQuery(src) + if norm == "" { + return nil, fmt.Errorf("empty query") + } + expr, err := parser.ParseExpr(norm) + if err != nil { + return nil, fmt.Errorf("syntax error: %s", cleanParseError(err)) + } + c := &queryCompiler{resolve: resolve, seen: make(map[string]bool)} + node, err := c.compileBool(expr) + if err != nil { + return nil, err + } + return &queryFilter{eval: node, series: c.series}, nil +} + +// queryCompiler walks the AST, building closures and collecting referenced +// series names along the way. +type queryCompiler struct { + resolve func(series string, i int) (float64, bool) + seen map[string]bool + series []string +} + +// compileBool compiles an expression that must yield a boolean: a comparison, an +// approx() call, a && / || combination, a ! negation, or any of those grouped in +// parentheses. +func (c *queryCompiler) compileBool(e ast.Expr) (boolNode, error) { + switch v := e.(type) { + case *ast.ParenExpr: + return c.compileBool(v.X) + case *ast.UnaryExpr: + if v.Op != token.NOT { + return nil, fmt.Errorf("%q can't start a condition", v.Op) + } + inner, err := c.compileBool(v.X) + if err != nil { + return nil, err + } + return func(i int) bool { return !inner(i) }, nil + case *ast.CallExpr: + if id, ok := v.Fun.(*ast.Ident); ok && id.Name == "__in" { + return c.compileIn(v) + } + return c.compileApprox(v) + case *ast.BinaryExpr: + switch v.Op { + case token.LAND: + return c.combine(v, func(a, b bool) bool { return a && b }) + case token.LOR: + return c.combine(v, func(a, b bool) bool { return a || b }) + case token.EQL, token.NEQ, token.LSS, token.LEQ, token.GTR, token.GEQ: + return c.compileCompare(v) + default: + return nil, fmt.Errorf("operator %q can't join conditions; use and/or", v.Op) + } + } + return nil, fmt.Errorf("expected a condition (e.g. rpm > 3000), got %s", exprString(e)) +} + +// combine compiles both sides of a && / || node and merges them with op. +func (c *queryCompiler) combine(v *ast.BinaryExpr, op func(a, b bool) bool) (boolNode, error) { + l, err := c.compileBool(v.X) + if err != nil { + return nil, err + } + r, err := c.compileBool(v.Y) + if err != nil { + return nil, err + } + return func(i int) bool { return op(l(i), r(i)) }, nil +} + +// compileCompare compiles a comparison "value op value". If either side is +// missing/NaN at a sample the comparison is false. +func (c *queryCompiler) compileCompare(v *ast.BinaryExpr) (boolNode, error) { + l, err := c.compileNum(v.X) + if err != nil { + return nil, err + } + r, err := c.compileNum(v.Y) + if err != nil { + return nil, err + } + op := v.Op + return func(i int) bool { + a, ok := l(i) + if !ok { + return false + } + b, ok := r(i) + if !ok { + return false + } + switch op { + case token.EQL: + return a == b + case token.NEQ: + return a != b + case token.LSS: + return a < b + case token.LEQ: + return a <= b + case token.GTR: + return a > b + case token.GEQ: + return a >= b + } + return false + }, nil +} + +// compileApprox compiles the synthesised __approx(a, b) call that backs the "~" +// operator, reusing the same epsilon window as the per-row "~" rule. +func (c *queryCompiler) compileApprox(call *ast.CallExpr) (boolNode, error) { + id, ok := call.Fun.(*ast.Ident) + if !ok || id.Name != "__approx" { + return nil, fmt.Errorf("unknown function %s(...)", exprString(call.Fun)) + } + if len(call.Args) != 2 { + return nil, fmt.Errorf("~ needs a value on each side") + } + l, err := c.compileNum(call.Args[0]) + if err != nil { + return nil, err + } + r, err := c.compileNum(call.Args[1]) + if err != nil { + return nil, err + } + return func(i int) bool { + a, ok := l(i) + if !ok { + return false + } + b, ok := r(i) + if !ok { + return false + } + return approxEqual(a, b) + }, nil +} + +// compileIn compiles the synthesised __in(value, a, b, ...) call that backs the +// "in [...]" membership test. It passes when value equals any list member. A +// missing/NaN value (or list member) is skipped, matching the "drop incomplete +// samples" behaviour of every other comparison. +func (c *queryCompiler) compileIn(call *ast.CallExpr) (boolNode, error) { + if len(call.Args) < 2 { + return nil, fmt.Errorf("in needs a value and a non-empty list, e.g. rpm in [800, 900]") + } + val, err := c.compileNum(call.Args[0]) + if err != nil { + return nil, err + } + set := make([]numNode, 0, len(call.Args)-1) + for _, arg := range call.Args[1:] { + member, err := c.compileNum(arg) + if err != nil { + return nil, err + } + set = append(set, member) + } + return func(i int) bool { + a, ok := val(i) + if !ok { + return false + } + for _, member := range set { + if b, ok := member(i); ok && a == b { + return true + } + } + return false + }, nil +} + +// compileNum compiles an expression that must yield a number: a literal, a +// series reference, arithmetic of those, or any of them grouped/negated. +func (c *queryCompiler) compileNum(e ast.Expr) (numNode, error) { + switch v := e.(type) { + case *ast.ParenExpr: + return c.compileNum(v.X) + case *ast.BasicLit: + if v.Kind != token.INT && v.Kind != token.FLOAT { + return nil, fmt.Errorf("expected a number, got %s", v.Value) + } + f, err := strconv.ParseFloat(v.Value, 64) + if err != nil { + return nil, fmt.Errorf("bad number %q", v.Value) + } + return func(int) (float64, bool) { return f, true }, nil + case *ast.Ident: + return c.seriesNode(v.Name), nil + case *ast.SelectorExpr: + name, ok := dottedName(v) + if !ok { + return nil, fmt.Errorf("unsupported series reference %s", exprString(v)) + } + return c.seriesNode(name), nil + case *ast.UnaryExpr: + switch v.Op { + case token.SUB: + inner, err := c.compileNum(v.X) + if err != nil { + return nil, err + } + return func(i int) (float64, bool) { x, ok := inner(i); return -x, ok }, nil + case token.ADD: + return c.compileNum(v.X) + case token.NOT: + // "!" binds tighter than comparison, so "not rpm > 3000" parses as + // "(not rpm) > 3000". Point the user at the parenthesised form. + return nil, fmt.Errorf("not negates a condition — wrap it, e.g. not (rpm > 3000)") + } + return nil, fmt.Errorf("%q can't be applied to a value", v.Op) + case *ast.BinaryExpr: + return c.compileArith(v) + } + return nil, fmt.Errorf("expected a series name or number, got %s", exprString(e)) +} + +// compileArith compiles +, -, * and / between two values. Division by zero +// yields ok=false rather than an infinity. +func (c *queryCompiler) compileArith(v *ast.BinaryExpr) (numNode, error) { + op := v.Op + switch op { + case token.ADD, token.SUB, token.MUL, token.QUO: + default: + return nil, fmt.Errorf("operator %q can't be used in a value", op) + } + l, err := c.compileNum(v.X) + if err != nil { + return nil, err + } + r, err := c.compileNum(v.Y) + if err != nil { + return nil, err + } + return func(i int) (float64, bool) { + a, ok := l(i) + if !ok { + return 0, false + } + b, ok := r(i) + if !ok { + return 0, false + } + switch op { + case token.ADD: + return a + b, true + case token.SUB: + return a - b, true + case token.MUL: + return a * b, true + case token.QUO: + if b == 0 { + return 0, false + } + return a / b, true + } + return 0, false + }, nil +} + +// seriesNode builds a value node that reads the named series at a sample, and +// records the name as referenced (deduplicated, in first-seen order). +func (c *queryCompiler) seriesNode(name string) numNode { + if !c.seen[name] { + c.seen[name] = true + c.series = append(c.series, name) + } + resolve := c.resolve + return func(i int) (float64, bool) { return resolve(name, i) } +} + +// dottedName flattens a selector chain (a.b.c) back into its original dotted +// string. Only plain identifiers may appear in the chain. +func dottedName(e ast.Expr) (string, bool) { + switch v := e.(type) { + case *ast.Ident: + return v.Name, true + case *ast.SelectorExpr: + base, ok := dottedName(v.X) + if !ok { + return "", false + } + return base + "." + v.Sel.Name, true + } + return "", false +} + +// exprString renders a small, friendly fragment of an expression for error +// messages, covering just the node kinds this grammar can produce. +func exprString(e ast.Expr) string { + switch v := e.(type) { + case *ast.Ident: + return v.Name + case *ast.BasicLit: + return v.Value + case *ast.SelectorExpr: + if n, ok := dottedName(v); ok { + return n + } + return "selector" + case *ast.ParenExpr: + return "(" + exprString(v.X) + ")" + case *ast.UnaryExpr: + return v.Op.String() + exprString(v.X) + case *ast.BinaryExpr: + return exprString(v.X) + " " + v.Op.String() + " " + exprString(v.Y) + case *ast.CallExpr: + return exprString(v.Fun) + "(...)" + } + return "expression" +} + +// cleanParseError trims go/parser's "line:col: " position prefix, which refers +// to our normalised string and would only confuse the user. +func cleanParseError(err error) string { + return parserPosRe.ReplaceAllString(err.Error(), "") +} diff --git a/pkg/widgets/matrixbuilder/query_test.go b/pkg/widgets/matrixbuilder/query_test.go new file mode 100644 index 00000000..c61f9d3b --- /dev/null +++ b/pkg/widgets/matrixbuilder/query_test.go @@ -0,0 +1,139 @@ +package matrixbuilder + +import ( + "math" + "testing" +) + +// resolverFrom builds a query resolver over an in-memory series map, mirroring +// MatrixBuilder.resolve (missing series or a NaN reading fail with ok=false). +func resolverFrom(series map[string][]float64) func(string, int) (float64, bool) { + return func(name string, i int) (float64, bool) { + data, ok := series[name] + if !ok || i < 0 || i >= len(data) { + return 0, false + } + v := data[i] + if math.IsNaN(v) { + return 0, false + } + return v, true + } +} + +func TestCompileQueryEval(t *testing.T) { + // One sample per row; index i selects the row under test. + series := map[string][]float64{ + "rpm": {1000, 4000, 4000, 4000, math.NaN()}, + "load": {10, 60, 40, 60, 60}, + "boost": {0.5, 1.2, 1.205, 0.8, 1.2}, + "ActualIn.n_Engine": {1000, 4000, 4000, 4000, 4000}, + } + resolve := resolverFrom(series) + + tests := []struct { + name string + query string + want [5]bool + }{ + {"simple gt", "rpm > 3000", [5]bool{false, true, true, true, false}}, + {"and", "rpm > 3000 and load > 50", [5]bool{false, true, false, true, false}}, + // Row 4 has rpm=NaN (so rpm<2000 fails) but load=60>50, so the OR holds. + {"or", "rpm < 2000 or load > 50", [5]bool{true, true, false, true, true}}, + { + // Grouping changes the result vs. default precedence (&& binds tighter). + "grouping", "(rpm == 4000 and load == 40) or rpm == 1000", + [5]bool{true, false, true, false, false}, + }, + { + "precedence no parens", "rpm == 1000 or rpm == 4000 and load == 40", + [5]bool{true, false, true, false, false}, + }, + {"if prefix stripped", "if rpm > 3000", [5]bool{false, true, true, true, false}}, + {"not parenthesized", "not (rpm > 3000)", [5]bool{true, false, false, false, true}}, + {"approx hit", "boost ~ 1.2", [5]bool{false, true, true, false, true}}, + {"ne", "load != 60", [5]bool{true, false, true, false, false}}, + {"dotted series", "ActualIn.n_Engine >= 4000", [5]bool{false, true, true, true, true}}, + // load = {10,60,40,60,60}; only the 40 and the three 60s are members. + {"in set", "load in [40, 60]", [5]bool{false, true, true, true, true}}, + {"in single", "load in [10]", [5]bool{true, false, false, false, false}}, + // Dotted name on the left, float members, combined with another clause. + {"in dotted and", "ActualIn.n_Engine in [1000, 4000] and load < 50", [5]bool{true, false, true, false, false}}, + // not wrapping an in-test; row 4's rpm=NaN value fails the test, so not-> true. + {"not in", "not (rpm in [1000, 4000])", [5]bool{false, false, false, false, true}}, + {"series vs series", "rpm > load", [5]bool{true, true, true, true, false}}, + // rpm/100 = {10,40,40,40,NaN}; load = {10,60,40,60,60}. Row 2 ties (40>40 + // false) and row 4's NaN rpm makes the value fail. + {"arithmetic rhs", "load > rpm / 100", [5]bool{false, true, false, true, false}}, + {"nan fails", "rpm == 4000", [5]bool{false, true, true, true, false}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, err := compileQuery(tt.query, resolve) + if err != nil { + t.Fatalf("compileQuery(%q) error: %v", tt.query, err) + } + for i := 0; i < 5; i++ { + if got := q.Eval(i); got != tt.want[i] { + t.Errorf("Eval(%d) = %v, want %v", i, got, tt.want[i]) + } + } + }) + } +} + +func TestCompileQuerySeries(t *testing.T) { + q, err := compileQuery("rpm > 3000 and ActualIn.n_Engine < 6000 or rpm == 0", resolverFrom(nil)) + if err != nil { + t.Fatalf("compileQuery error: %v", err) + } + got := q.Series() + want := []string{"rpm", "ActualIn.n_Engine"} // first-seen order, deduped + if len(got) != len(want) { + t.Fatalf("Series() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Series()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestCompileQueryErrors(t *testing.T) { + resolve := resolverFrom(nil) + for _, query := range []string{ + "", // empty + "rpm >", // dangling operator + "rpm", // not a condition + "rpm + 10", // value, not a condition + "rpm > 3000 and", // dangling and + "(rpm > 3000", // unbalanced paren + "rpm in []", // empty membership list + } { + if _, err := compileQuery(query, resolve); err == nil { + t.Errorf("compileQuery(%q) expected error, got nil", query) + } + } +} + +func TestNormalizeQuery(t *testing.T) { + tests := map[string]string{ + "if rpm > 3000": "rpm > 3000", + "a and b or c": "a && b || c", + "not (a > 1)": "! (a > 1)", + "boost ~ 1.2": "__approx(boost, 1.2)", + "In.p ~ 1.0 and rpm > 100": "__approx(In.p, 1.0) && rpm > 100", + "load in [40, 60]": "__in(load, 40, 60)", + "rpm in [800] or load > 5": "__in(rpm, 800) || load > 5", + // "in" inside a name must survive (no leading whitespace before "in"). + "MainInput > 1": "MainInput > 1", + // "and"/"or" inside names must survive. + "Sensor > 1 and Brand < 2": "Sensor > 1 && Brand < 2", + } + for in, want := range tests { + if got := normalizeQuery(in); got != want { + t.Errorf("normalizeQuery(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_axis.go b/pkg/widgets/meshgrid/meshgrid_axis.go index 71537489..e7525ded 100644 --- a/pkg/widgets/meshgrid/meshgrid_axis.go +++ b/pkg/widgets/meshgrid/meshgrid_axis.go @@ -3,93 +3,332 @@ package meshgrid import ( "image" "image/color" + "math" + "strconv" + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "golang.org/x/image/font" "golang.org/x/image/font/basicfont" "golang.org/x/image/math/fixed" ) -// Add new struct for axis indicator -type AxisIndicator struct { - origin Vertex - xAxis Vertex - yAxis Vertex - zAxis Vertex - axisScale float64 +// T7Suite-style axis scales: instead of a small orientation gizmo in the +// corner, the X (column), Y (row) and Z (value) scales are drawn along three +// edges of the mesh's own bounding box, labeled with the table's real axis +// values. The geometry is computed once per refresh in original (untransformed) +// coordinates, projected with projectOriginal, and consumed either as canvas +// overlay objects (shader/polygon backends) or rasterized into the image +// (image backend). Both paths share computeAxisGeometry so the projection and +// the label thinning live in one place. + +const ( + axisTextSize float32 = 11 + // axisCharW is a rough per-character width at axisTextSize, used only to + // decide how many tick labels fit before they overlap. + axisCharW = 7.0 + // axisEdgeOffset lifts the whole axis (line, ticks, labels) off the mesh + // edge so it doesn't sit directly on the surface. The distances below are + // measured outward from this shifted line. + axisEdgeOffset = 8.0 + axisTickLen = 7.0 // tick-mark length, px, drawn outward from the edge + axisLabelPad = 24.0 // outward distance of a value label's center from the lifted edge + axisNameGap = 16.0 // extra clearance of the axis-name label past the value labels + // zDivisions is the number of intervals on the vertical value scale, so it + // shows zDivisions+1 ticks from zmin to zmax. + zDivisions = 5 +) + +// axisSeg is one screen-space line: a box edge or a tick mark. +type axisSeg struct { + x1, y1, x2, y2 float32 + col color.RGBA } -func NewAxisIndicator(scale float64) AxisIndicator { - return AxisIndicator{ - origin: Vertex{X: 0, Y: 0, Z: 0}, - xAxis: Vertex{X: scale, Y: 0, Z: 0}, - yAxis: Vertex{X: 0, Y: scale, Z: 0}, - zAxis: Vertex{X: 0, Y: 0, Z: scale}, - axisScale: scale, +// axisLabel is one screen-space text placed centered on (x, y). +type axisLabel struct { + text string + x, y float32 + col color.RGBA +} + +// initAxisObjects allocates the canvas pools for the overlay backends. The +// pools are sized to the worst case (no thinning) so the per-frame update only +// has to position the active entries and hide the rest; nothing here needs a +// driver, so it is safe from the constructor and from tests. +func (m *Meshgrid) initAxisObjects() { + // Worst case: every column/row/Z tick visible, plus a small headroom for + // the always-included last tick on each axis. + maxTicks := m.cols + m.rows + (zDivisions + 1) + 3 + maxLines := 3 + maxTicks // three labeled edges + one tick mark each + maxTexts := maxTicks + 3 // one value label each + three axis names + + m.axisLinePool = make([]*canvas.Line, maxLines) + for i := range m.axisLinePool { + m.axisLinePool[i] = &canvas.Line{StrokeWidth: 1, Hidden: true} + } + m.axisTextPool = make([]*canvas.Text, maxTexts) + for i := range m.axisTextPool { + t := canvas.NewText("", color.White) + t.TextSize = axisTextSize + t.Hidden = true + m.axisTextPool[i] = t } } -func (m *Meshgrid) drawAxisIndicator(img *image.RGBA) { - cornerOffset := 60.0 - indicatorScale := 60.0 +// updateAxisObjects drives the canvas pools from the current axis geometry: +// active entries get positioned and shown, the rest hidden. +func (m *Meshgrid) updateAxisObjects() { + segs, labels := m.computeAxisGeometry() - // Create the indicator at corner position - origin := Vertex{ - X: cornerOffset, - Y: float64(m.size.Height) - float64(m.size.Height/4), + for i, l := range m.axisLinePool { + if i < len(segs) { + s := segs[i] + l.StrokeColor = s.col + l.Position1 = fyne.NewPos(s.x1, s.y1) + l.Position2 = fyne.NewPos(s.x2, s.y2) + if l.Hidden { + l.Show() + } + } else if !l.Hidden { + l.Hide() + } } - // Instead of using just the rotation matrix, we should use the same - // camera transformation that's applied to the mesh vertices + for i, t := range m.axisTextPool { + if i < len(labels) { + lb := labels[i] + t.Text = lb.text + t.Color = lb.col + sz := t.MinSize() + t.Resize(sz) + // canvas.Text positions by its top-left; center it on the anchor. + t.Move(fyne.NewPos(lb.x-sz.Width/2, lb.y-sz.Height/2)) + if t.Hidden { + t.Show() + } + } else if !t.Hidden { + t.Hide() + } + } +} - // Use the camera's view matrix (same as in updateVertexPositions) - viewMatrix := m.cameraRotation +// drawAxisScales rasterizes the same axis geometry into the image backend's +// frame, drawing edges/ticks as Bresenham lines and labels with the bitmap font. +func (m *Meshgrid) drawAxisScales(img *image.RGBA) { + segs, labels := m.computeAxisGeometry() + for _, s := range segs { + drawBresenhamLine(img, int(s.x1), int(s.y1), int(s.x2), int(s.y2), s.col, s.col) + } + for _, l := range labels { + // basicfont.Face7x13 is 7px wide per glyph; the drawer anchors at the + // baseline, so nudge so the label reads roughly centered on its anchor. + w := len(l.text) * 7 + m.drawText(img, l.text, int(l.x)-w/2, int(l.y)+4, l.col) + } +} + +// computeAxisGeometry builds the screen-space edges, tick marks and labels for +// the three axis scales. It reuses the scratch slices and returns them. +func (m *Meshgrid) computeAxisGeometry() ([]axisSeg, []axisLabel) { + segs := m.scratchAxisSegs[:0] + labels := m.scratchAxisLabels[:0] + if m.size.Width <= 0 || m.size.Height <= 0 { + m.scratchAxisSegs, m.scratchAxisLabels = segs, labels + return segs, labels + } + + cw, ch := float64(m.cellWidth), float64(m.cellHeight) + xMax := float64(m.cols) * cw + yMin, yMax := ch, float64(m.rows+1)*ch + zTop := m.depth + + // The four floor corners (z=0). The front-most (largest screen Y) carries + // the X and Y scales on its two outgoing floor edges. The vertical Z scale + // goes on the most side-on of the remaining corners (the leftmost), so its + // edge rides the silhouette in open space instead of being buried behind + // the surface the way the back corner was. + type pt struct{ ox, oy float64 } + floor := [4]pt{{0, yMin}, {xMax, yMin}, {0, yMax}, {xMax, yMax}} + frontIdx := 0 + frontY := float32(math.Inf(-1)) + for i, c := range floor { + _, sy, _ := m.projectOriginal(c.ox, c.oy, 0) + if sy > frontY { + frontY, frontIdx = sy, i + } + } + zIdx := -1 + zX := float32(math.Inf(1)) + for i, c := range floor { + if i == frontIdx { + continue + } + sx, _, _ := m.projectOriginal(c.ox, c.oy, 0) + if sx < zX { + zX, zIdx = sx, i + } + } + front, zCorner := floor[frontIdx], floor[zIdx] - // Transform axis endpoints using the camera's view matrix - transformedX := viewMatrix.MultiplyVector([3]float64{indicatorScale, 0, 0}) - transformedY := viewMatrix.MultiplyVector([3]float64{0, -indicatorScale, 0}) // Negative Y scale - transformedZ := viewMatrix.MultiplyVector([3]float64{0, 0, indicatorScale}) + // "Inside" reference: screen centroid of all eight box corners. Labels are + // pushed outward from it so they sit outside the surface. + var sumX, sumY float32 + for _, oz := range [2]float64{0, zTop} { + for _, c := range floor { + sx, sy, _ := m.projectOriginal(c.ox, c.oy, oz) + sumX += sx + sumY += sy + } + } + inside := fyne.NewPos(sumX/8, sumY/8) - // Calculate endpoints - xEnd := Vertex{ - X: origin.X + transformedX[0], - Y: origin.Y + transformedX[1], + xCol := color.RGBA{R: 255, G: 90, B: 90, A: 255} + yCol := color.RGBA{R: 90, G: 220, B: 90, A: 255} + zCol := color.RGBA{R: 120, G: 170, B: 255, A: 255} + + // X scale: front edge at constant Y, value per column at the cell center. + nx := 0 + if len(m.xData) >= m.cols { + nx = m.cols } - yEnd := Vertex{ - X: origin.X + transformedY[0], - Y: origin.Y + transformedY[1], + segs, labels = m.appendAxis(segs, labels, inside, xCol, + [3]float64{0, front.oy, 0}, [3]float64{xMax, front.oy, 0}, m.xlabel, nx, + func(k int) [3]float64 { return [3]float64{(float64(k) + 0.5) * cw, front.oy, 0} }, + func(k int) string { return strconv.FormatFloat(m.xData[k], 'f', m.xPrec, 64) }) + + // Y scale: side edge at constant X. Data row 0 sits at the high-Y (far) + // end, so row k maps to Oy = (rows+0.5-k)*ch. + ny := 0 + if len(m.yData) >= m.rows { + ny = m.rows } - zEnd := Vertex{ - X: origin.X + transformedZ[0], - Y: origin.Y + transformedZ[1], + segs, labels = m.appendAxis(segs, labels, inside, yCol, + [3]float64{front.ox, yMin, 0}, [3]float64{front.ox, yMax, 0}, m.ylabel, ny, + func(k int) [3]float64 { return [3]float64{front.ox, (float64(m.rows) + 0.5 - float64(k)) * ch, 0} }, + func(k int) string { return strconv.FormatFloat(m.yData[k], 'f', m.yPrec, 64) }) + + // Z scale: vertical edge at the side corner, zmin..zmax mapped to 0..zTop. + nz := 0 + if m.zrange > 0 && zTop > 0 { + nz = zDivisions + 1 } + segs, labels = m.appendAxis(segs, labels, inside, zCol, + [3]float64{zCorner.ox, zCorner.oy, 0}, [3]float64{zCorner.ox, zCorner.oy, zTop}, m.zlabel, nz, + func(k int) [3]float64 { return [3]float64{zCorner.ox, zCorner.oy, float64(k) / zDivisions * zTop} }, + func(k int) string { + return strconv.FormatFloat(m.zmin+float64(k)/zDivisions*m.zrange, 'f', m.zPrec, 64) + }) + + m.scratchAxisSegs, m.scratchAxisLabels = segs, labels + return segs, labels +} - // Draw the axes - ox, oy := int(origin.X), int(origin.Y) +// appendAxis appends one labeled axis: the edge line from p0 to p1 (original +// coords) lifted off the mesh by axisEdgeOffset, the axis name centered on the +// middle of that edge, and a thinned set of tick marks plus value labels at the +// original-space points returned by pointAt(k), k in [0,n). Everything is +// offset along one outward edge normal so the ticks stay parallel and the whole +// scale sits clear of the surface. The name rides the middle so it doesn't +// collide with the corner tick values. +func (m *Meshgrid) appendAxis(segs []axisSeg, labels []axisLabel, inside fyne.Position, col color.RGBA, + p0, p1 [3]float64, name string, n int, pointAt func(int) [3]float64, valueAt func(int) string, +) ([]axisSeg, []axisLabel) { + sx0, sy0, _ := m.projectOriginal(p0[0], p0[1], p0[2]) + sx1, sy1, _ := m.projectOriginal(p1[0], p1[1], p1[2]) - // X axis (red) - ex, ey := int(xEnd.X), int(xEnd.Y) - drawBresenhamLine(img, ox, oy, ex, ey, color.RGBA{R: 255, G: 0, B: 0, A: 255}, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + // One outward normal for the whole edge keeps the ticks parallel. The mesh + // transform is affine, so the tick points stay collinear with the edge and + // land on the offset line after the same shift. + nx, ny := edgeOutwardNormal(sx0, sy0, sx1, sy1, inside) + ox, oy := nx*axisEdgeOffset, ny*axisEdgeOffset - m.drawText(img, m.xlabel, - int(ex+5), int(ey), - color.RGBA{R: 255, G: 0, B: 0, A: 255}) + ex0, ey0 := sx0+ox, sy0+oy + ex1, ey1 := sx1+ox, sy1+oy + segs = append(segs, axisSeg{ex0, ey0, ex1, ey1, col}) - // Y axis (green) - ey = int(yEnd.Y) - ex = int(yEnd.X) - drawBresenhamLine(img, ox, oy, ex, ey, color.RGBA{R: 0, G: 255, B: 0, A: 255}, color.RGBA{R: 0, G: 255, B: 0, A: 255}) + if name != "" { + // Sit the name on the edge midpoint, just past the value-label band so + // it never overlaps the corner ticks (and tracks axisLabelPad changes). + mx, my := (sx0+sx1)*0.5, (sy0+sy1)*0.5 + nameDist := float32(axisEdgeOffset + axisLabelPad + axisNameGap) + labels = append(labels, axisLabel{name, mx + nx*nameDist, my + ny*nameDist, col}) + } + if n <= 0 { + return segs, labels + } - m.drawText(img, m.ylabel, - int(ex+5), int(ey), - color.RGBA{R: 0, G: 255, B: 0, A: 255}) + // Thin labels to the count that fits along the projected edge length. + L := math.Hypot(float64(ex1-ex0), float64(ey1-ey0)) + maxChars := 1 + for k := 0; k < n; k++ { + if c := len(valueAt(k)); c > maxChars { + maxChars = c + } + } + step := axisLabelStep(n, L, maxChars) + + appendTick := func(k int) { + p := pointAt(k) + sx, sy, _ := m.projectOriginal(p[0], p[1], p[2]) + bx, by := sx+ox, sy+oy // tick base sits on the lifted edge line + segs = append(segs, axisSeg{bx, by, bx + nx*axisTickLen, by + ny*axisTickLen, col}) + labels = append(labels, axisLabel{valueAt(k), bx + nx*axisLabelPad, by + ny*axisLabelPad, col}) + } + for k := 0; k < n; k += step { + appendTick(k) + } + // Always label the last tick so the axis' full extent is annotated. + if last := n - 1; last%step != 0 { + appendTick(last) + } + return segs, labels +} - // Z axis (blue) - ex = int(zEnd.X) - ey = int(zEnd.Y) - drawBresenhamLine(img, ox, oy, ex, ey, color.RGBA{R: 0, G: 0, B: 255, A: 255}, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - m.drawText(img, m.zlabel, - int(ex+5), int(ey), - color.RGBA{R: 0, G: 0, B: 255, A: 255}) +// edgeOutwardNormal returns the unit screen-space normal of edge (s0->s1) that +// points away from the inside reference, used to lift the axis and its ticks +// outward in one consistent direction. +func edgeOutwardNormal(sx0, sy0, sx1, sy1 float32, inside fyne.Position) (float32, float32) { + dx, dy := float64(sx1-sx0), float64(sy1-sy0) + L := math.Hypot(dx, dy) + if L < 1e-6 { + return outward(sx0, sy0, inside) // degenerate edge: fall back to radial + } + nx, ny := -dy/L, dx/L + mx, my := float64(sx0+sx1)*0.5, float64(sy0+sy1)*0.5 + if nx*(mx-float64(inside.X))+ny*(my-float64(inside.Y)) < 0 { + nx, ny = -nx, -ny + } + return float32(nx), float32(ny) +} + +// axisLabelStep returns the index stride that keeps drawn labels at least one +// label-width apart along a projected edge of screen length L. +func axisLabelStep(n int, L float64, maxChars int) int { + if n <= 1 || L <= 0 { + return 1 + } + minSpacing := float64(maxChars)*axisCharW + 8 + fit := int(L / minSpacing) + if fit < 1 { + fit = 1 + } + step := (n + fit - 1) / fit + if step < 1 { + step = 1 + } + return step +} + +// outward returns the unit screen-space direction from the inside reference +// toward (px, py), i.e. the direction to push a label so it clears the surface. +func outward(px, py float32, inside fyne.Position) (float32, float32) { + dx, dy := float64(px-inside.X), float64(py-inside.Y) + d := math.Hypot(dx, dy) + if d < 1e-3 { + return 0, 1 + } + return float32(dx / d), float32(dy / d) } func (m *Meshgrid) drawText(img *image.RGBA, text string, x, y int, col color.RGBA) { diff --git a/pkg/widgets/meshgrid/meshgrid_draw.go b/pkg/widgets/meshgrid/meshgrid_draw.go index d4f023ae..86b77227 100644 --- a/pkg/widgets/meshgrid/meshgrid_draw.go +++ b/pkg/widgets/meshgrid/meshgrid_draw.go @@ -7,6 +7,7 @@ import ( "slices" "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" ) // lineSegment indexes into the precomputed projected/color slices so we don't @@ -33,14 +34,18 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { img = image.NewRGBA(image.Rect(0, 0, w, h)) m.scratchImg = img } else { - clearPix(img.Pix) + clear(img.Pix) } + // Vertices sit on cell corners, so the grid is one larger than the data + // in each direction (one quad per table cell). + vRows, vCols := m.rows+1, m.cols+1 + // Find min/max of the view-space Z for depth shading. minZ, maxZ := math.Inf(1), math.Inf(-1) - for i := 0; i < m.rows; i++ { + for i := 0; i < vRows; i++ { row := m.vertices[i] - for j := 0; j < m.cols; j++ { + for j := 0; j < vCols; j++ { z := row[j].Z if z < minZ { minZ = z @@ -56,7 +61,7 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { } // Precompute screen-space projection and color for each vertex once. - n := m.rows * m.cols + n := vRows * vCols if cap(m.scratchProjX) < n { m.scratchProjX = make([]int, n) m.scratchProjY = make([]int, n) @@ -68,31 +73,39 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { cx := float64(m.size.Width) * 0.5 cy := float64(m.size.Height) * 0.5 - for i := 0; i < m.rows; i++ { + for i := 0; i < vRows; i++ { row := m.vertices[i] - base := i * m.cols - for j := 0; j < m.cols; j++ { + base := i * vCols + for j := 0; j < vCols; j++ { v := row[j] idx := base + j projX[idx] = int(cx + v.X) projY[idx] = int(cy + v.Y) depth := (v.Z - minZ) / zRange - vertCol[idx] = m.getColorWithDepth(m.values[idx], depth) + vertCol[idx] = m.getColorWithDepth(v.V, depth) } } + mode := m.renderMode + + if mode != RenderModeWireframe { + m.drawSurface(img, projX, projY, vertCol, mode == RenderModeSolidWireframe) + m.drawAxisScales(img) + return img + } + // Collect line segments using cached projections. segs := m.scratchLines[:0] - for i := 0; i < m.rows; i++ { - for j := 0; j < m.cols; j++ { - idx := i*m.cols + j + for i := 0; i < vRows; i++ { + for j := 0; j < vCols; j++ { + idx := i*vCols + j x1, y1 := projX[idx], projY[idx] // neighbors: (+1,0) down, (0,+1) right, (+1,-1) diagonal tryAddSeg := func(ni, nj int) { - if ni >= m.rows || nj < 0 || nj >= m.cols { + if ni >= vRows || nj < 0 || nj >= vCols { return } - nidx := ni*m.cols + nj + nidx := ni*vCols + nj x2, y2 := projX[nidx], projY[nidx] dx, dy := x2-x1, y2-y1 if dx*dx+dy*dy < 4 { @@ -105,7 +118,7 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { y1: y1, x2: x2, y2: y2, - depth: -(m.vertices[i][j].Z + m.vertices[ni][nj].Z) * 0.5, + depth: (m.vertices[i][j].Z + m.vertices[ni][nj].Z) * 0.5, diagonal: x1 != x2 && y1 != y2, }) } @@ -138,21 +151,87 @@ func (m *Meshgrid) drawMeshgridLines() *image.RGBA { drawBresenhamLine(img, s.x1, s.y1, s.x2, s.y2, c1, c2) } - m.drawAxisIndicator(img) + m.drawAxisScales(img) return img } -func clearPix(p []uint8) { - for i := range p { - p[i] = 0 +// cursorScreenPosition projects the tracking-marker cell position set by +// SetCursor onto the screen so the marker rides the surface the shader draws. +func (m *Meshgrid) cursorScreenPosition() (float32, float32) { + if m.dataVertexMode() { + // The shader's vertices are the cell values themselves, so the marker + // rides the triangulated data surface: project the cell-centered data + // point at the (fractional) cursor, with the same Ox/Oy convention the + // axis uses. SetCursor clamps the indices to the data grid. + cw, ch := float64(m.cellWidth), float64(m.cellHeight) + ox := (m.cursorX + 0.5) * cw + oy := (float64(m.rows) + 0.5 - m.cursorY) * ch + zr := m.zrange + if zr == 0 { + zr = 1 + } + oz := (m.sampleValue(m.cursorX, m.cursorY) - m.zmin) / zr * m.depth + sx, sy, _ := m.projectOriginal(ox, oy, oz) + return sx, sy } + + // Corner-averaged fallback: MapViewer indices are cell-centered while mesh + // vertices sit on cell corners; +0.5 lands the marker mid-cell on the corner + // grid. The camera transform is linear, so bilinearly interpolating the + // transformed corners lands on the same point as transforming the + // interpolated one. + sx := m.cursorX + 0.5 + sy := m.cursorY + 0.5 + + x0 := int(sx) + y0 := int(sy) + x1 := min(x0+1, m.cols) + y1 := min(y0+1, m.rows) + fx := sx - float64(x0) + fy := sy - float64(y0) + + v00 := m.vertices[y0][x0] + v01 := m.vertices[y0][x1] + v10 := m.vertices[y1][x0] + v11 := m.vertices[y1][x1] + + vx := (1-fy)*((1-fx)*v00.X+fx*v01.X) + fy*((1-fx)*v10.X+fx*v11.X) + vy := (1-fy)*((1-fx)*v00.Y+fx*v01.Y) + fy*((1-fx)*v10.Y+fx*v11.Y) + + return float32(float64(m.size.Width)*0.5 + vx), float32(float64(m.size.Height)*0.5 + vy) +} + +// sampleValue bilinearly interpolates the cell values at the fractional cell +// index (fx = column, fy = row), clamped to the data grid. +func (m *Meshgrid) sampleValue(fx, fy float64) float64 { + cx0 := min(max(int(math.Floor(fx)), 0), m.cols-1) + cy0 := min(max(int(math.Floor(fy)), 0), m.rows-1) + cx1 := min(cx0+1, m.cols-1) + cy1 := min(cy0+1, m.rows-1) + tx := fx - float64(cx0) + if tx < 0 { + tx = 0 + } else if tx > 1 { + tx = 1 + } + ty := fy - float64(cy0) + if ty < 0 { + ty = 0 + } else if ty > 1 { + ty = 1 + } + v00 := m.values[cy0*m.cols+cx0] + v01 := m.values[cy0*m.cols+cx1] + v10 := m.values[cy1*m.cols+cx0] + v11 := m.values[cy1*m.cols+cx1] + return (1-ty)*((1-tx)*v00+tx*v01) + ty*((1-tx)*v10+tx*v11) } // getColorWithDepth combines color interpolation and depth enhancement in one step func (m *Meshgrid) getColorWithDepth(value, depthFactor float64) color.RGBA { // Get base color from value - //baseColor := m.getColorInterpolation(value) + // baseColor := m.getColorInterpolation(value) baseColor := colors.GetColorInterpolation( m.zmin, m.zmax, @@ -184,13 +263,15 @@ func (m *Meshgrid) getColorWithDepth(value, depthFactor float64) color.RGBA { } } -// Fade a color by a factor (used for diagonals) +// Fade a color by a factor (used for diagonals). Alpha is left untouched: +// the buffer uses straight alpha, so dimming RGB and A together would fade +// the line twice over once composited. func fadeColor(c color.RGBA, factor float64) color.RGBA { return color.RGBA{ R: uint8(float64(c.R) * factor), G: uint8(float64(c.G) * factor), B: uint8(float64(c.B) * factor), - A: uint8(float64(c.A) * factor), + A: c.A, } } @@ -201,14 +282,17 @@ func drawBresenhamLine(img *image.RGBA, x0, y0, x1, y1 int, c1, c2 color.RGBA) { return // fully outside } - // Translate to image origin for indexing - ox, oy := r.Min.X, r.Min.Y + // Translate to image origin once so the pixel loop indexes directly. + x0 -= r.Min.X + x1 -= r.Min.X + y0 -= r.Min.Y + y1 -= r.Min.Y stride := img.Stride pix := img.Pix // Bresenham setup - dx := abs(x1 - x0) - dy := -abs(y1 - y0) + dx := common.Abs(x1 - x0) + dy := -common.Abs(y1 - y0) sx := 1 if x0 > x1 { sx = -1 @@ -225,7 +309,7 @@ func drawBresenhamLine(img *image.RGBA, x0, y0, x1, y1 int, c1, c2 color.RGBA) { total = -dy } if total == 0 { - setPix(pix, stride, x0-ox, y0-oy, c1) + setPix(pix, stride, x0, y0, c1) return } @@ -242,7 +326,7 @@ func drawBresenhamLine(img *image.RGBA, x0, y0, x1, y1 int, c1, c2 color.RGBA) { // Draw for i := 0; ; i++ { - setPixRGBAFixed(pix, stride, x0-ox, y0-oy, accR, accG, accB, accA) + setPixRGBAFixed(pix, stride, x0, y0, accR, accG, accB, accA) if x0 == x1 && y0 == y1 { break @@ -350,10 +434,3 @@ func clipCohenSutherland(x0, y0, x1, y1 *int, xmin, ymin, xmax, ymax int) bool { *x0, *y0, *x1, *y1 = x0i, y0i, x1i, y1i return true } - -func abs(v int) int { - if v < 0 { - return -v - } - return v -} diff --git a/pkg/widgets/meshgrid/meshgrid_mouse.go b/pkg/widgets/meshgrid/meshgrid_mouse.go index de35a652..9d640a2a 100644 --- a/pkg/widgets/meshgrid/meshgrid_mouse.go +++ b/pkg/widgets/meshgrid/meshgrid_mouse.go @@ -18,8 +18,9 @@ func (m *Meshgrid) MouseMoved(event *desktop.MouseEvent) { dy := float64(event.Position.Y - m.lastMouseY) if m.dragging { if event.Button&desktop.MouseButtonPrimary == desktop.MouseButtonPrimary { - //m.orbit(dx*rotationScale, -dy*rotationScale) - m.rotateMeshgrid(-dy*rotationScale, dx*rotationScale, 0) + // Drag left spins clockwise, drag right counter-clockwise; + // drag up tilts backwards, drag down tilts forward. + m.orbit(-dx*rotationScale, -dy*rotationScale) m.throttledRefresh() } else if event.Button&desktop.MouseButtonSecondary == desktop.MouseButtonSecondary { roll := (dx + dy) * rollScale diff --git a/pkg/widgets/meshgrid/meshgrid_poly.go b/pkg/widgets/meshgrid/meshgrid_poly.go new file mode 100644 index 00000000..7728d1cb --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_poly.go @@ -0,0 +1,282 @@ +package meshgrid + +import ( + "image/color" + "math" + "slices" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" +) + +// Polygon-based renderer experiment: instead of rasterizing the mesh into an +// RGBA image (and re-uploading that texture) every frame, each grid cell is a +// reusable canvas.ArbitraryPolygon that fyne's GL painter draws as a single +// SDF shader quad. A frame update only mutates points, colors and the +// painter's order of the object list — nothing is rasterized on the CPU. +// +// Trade-offs vs the image renderer: +// - Flat shading: a polygon has one fill color, so the four corner colors +// are averaged instead of Gouraud-interpolated across the quad. +// - Wireframe mode uses the quad outlines (stroke), so the cell diagonals +// are not drawn. +// - fyne does not clip renderer objects to the widget bounds, so a zoomed +// mesh can spill outside the widget area. + +// polyPad expands each polygon's bounding box so the 1px stroke and the +// shader's antialiased edge aren't clipped at the object bounds (the painter +// clamps points to the object's own size). +const polyPad float32 = 1.5 + +// initPolygons creates the reusable cell polygons. Geometry and colors are +// filled in by updatePolygons; nothing here needs a driver, so it is safe to +// call from the constructor (and from tests without an app). +func (m *Meshgrid) initPolygons() { + m.polys = make([]*canvas.ArbitraryPolygon, m.rows*m.cols) + for i := range m.polys { + m.polys[i] = &canvas.ArbitraryPolygon{Points: make([]fyne.Position, 4)} + } +} + +// updatePolygons projects the mesh and updates the reusable canvas objects: +// quad geometry, flat fill/stroke colors and the back-to-front painter's +// order of the renderer's object list. +func (m *Meshgrid) updatePolygons() { + w, h := m.size.Width, m.size.Height + if w <= 0 || h <= 0 || len(m.polys) == 0 { + return + } + + vRows, vCols := m.rows+1, m.cols+1 + + // View-space depth range for the depth shading, same as the image path. + minZ, maxZ := math.Inf(1), math.Inf(-1) + for i := 0; i < vRows; i++ { + row := m.vertices[i] + for j := 0; j < vCols; j++ { + z := row[j].Z + if z < minZ { + minZ = z + } + if z > maxZ { + maxZ = z + } + } + } + zRange := maxZ - minZ + if zRange == 0 { + zRange = 1 + } + + // Per-vertex screen projection and color. Each vertex is snapped to a whole + // pixel: the GL painter renders a cell's corner at + // round(origin*scale) + round((corner-origin)*scale), and since every cell + // uses its own bounding-box origin, an un-snapped (fractional) corner rounds + // to a different device pixel for each of the two cells that share it - the + // gaps/overlaps that made cell spacing look uneven. Snapping the projection + // to integer pixels (as the image path also does) cancels the per-cell + // origin out at integer pixel-scales, so a shared corner lands identically + // for both cells and their edges line up. + n := vRows * vCols + if cap(m.scratchFX) < n { + m.scratchFX = make([]float32, n) + m.scratchFY = make([]float32, n) + } + if cap(m.scratchColors) < n { + m.scratchColors = make([]color.RGBA, n) + } + fxs := m.scratchFX[:n] + fys := m.scratchFY[:n] + vertCol := m.scratchColors[:n] + + cx := float64(w) * 0.5 + cy := float64(h) * 0.5 + for i := 0; i < vRows; i++ { + row := m.vertices[i] + base := i * vCols + for j := 0; j < vCols; j++ { + v := row[j] + idx := base + j + fxs[idx] = float32(math.Round(cx + v.X)) + fys[idx] = float32(math.Round(cy + v.Y)) + depth := (v.Z - minZ) / zRange + vertCol[idx] = m.getColorWithDepth(v.V, depth) + } + } + + // Fixed light direction in view space, normalized once per frame. + lx, ly, lz := 0.3, -0.5, 0.8 + il := 1 / math.Sqrt(lx*lx+ly*ly+lz*lz) + lx, ly, lz = lx*il, ly*il, lz*il + + mode := m.renderMode + + quads := m.scratchQuads[:0] + for i := 0; i < m.rows; i++ { + for j := 0; j < m.cols; j++ { + ai := i*vCols + j // top-left + bi := ai + 1 // top-right + di := ai + vCols // bottom-left + ci := di + 1 // bottom-right + + z := m.vertices[i][j].Z + m.vertices[i][j+1].Z + m.vertices[i+1][j].Z + m.vertices[i+1][j+1].Z + quads = append(quads, quadRef{i: i, j: j, depth: z * 0.25}) + + poly := m.polys[i*m.cols+j] + + ax, ay := fxs[ai], fys[ai] + bx, by := fxs[bi], fys[bi] + ccx, ccy := fxs[ci], fys[ci] + dx, dy := fxs[di], fys[di] + + minx := min(min(ax, bx), min(ccx, dx)) + maxx := max(max(ax, bx), max(ccx, dx)) + miny := min(min(ay, by), min(ccy, dy)) + maxy := max(max(ay, by), max(ccy, dy)) + + x0, y0 := ax-minx+polyPad, ay-miny+polyPad + x1, y1 := bx-minx+polyPad, by-miny+polyPad + x2, y2 := ccx-minx+polyPad, ccy-miny+polyPad + x3, y3 := dx-minx+polyPad, dy-miny+polyPad + + // A quad folding over itself at the mesh silhouette projects to a + // self-intersecting outline, which both fyne painters mangle (the + // software stroker floods the whole bounding box). Swap a corner + // pair to make the polygon simple again. + if segmentsCross(x0, y0, x1, y1, x2, y2, x3, y3) { + x1, y1, x2, y2 = x2, y2, x1, y1 + } else if segmentsCross(x1, y1, x2, y2, x3, y3, x0, y0) { + x2, y2, x3, y3 = x3, y3, x2, y2 + } + + // Corners of an edge-on cell project onto each other, giving the + // polygon zero-length edges. The GL shader normalize()s the edge + // vectors, so those become NaN and flood the bounding box with + // color. Drop collapsed corners (the quad degrades to a triangle); + // under three distinct corners the cell is invisible anyway. + pts := appendDistinct(poly.Points[:0], [4]fyne.Position{ + {X: x0, Y: y0}, {X: x1, Y: y1}, {X: x2, Y: y2}, {X: x3, Y: y3}, + }) + poly.Points = pts + if len(pts) < 3 { + if !poly.Hidden { + poly.Hide() + } + continue + } + if poly.Hidden { + poly.Show() + } + + // Flat shade: average the four corner colors, then apply the same + // Lambert term the image renderer uses per quad. + shade := m.quadShade(i, j, lx, ly, lz) + fill := avgQuadColor(vertCol[ai], vertCol[bi], vertCol[ci], vertCol[di], shade) + + switch mode { + case RenderModeSolidWireframe: + poly.FillColor = fill + poly.StrokeColor = fadeColor(fill, surfaceEdgeFade) + poly.StrokeWidth = 1 + case RenderModeSolid: + poly.FillColor = fill + poly.StrokeColor = color.Transparent + poly.StrokeWidth = 0 + case RenderModeWireframe: + poly.FillColor = color.Transparent + poly.StrokeColor = fill + poly.StrokeWidth = 1 + } + + // The painter clamps points to the object's own bounds, so size + // the object to the quad's padded bbox and make points relative. + poly.Move(fyne.NewPos(minx-polyPad, miny-polyPad)) + poly.Resize(fyne.NewSize(maxx-minx+2*polyPad, maxy-miny+2*polyPad)) + } + } + m.scratchQuads = quads + + // Back-to-front painter's order, applied by reordering the object list. + slices.SortFunc(quads, func(a, b quadRef) int { + switch { + case a.depth < b.depth: + return -1 + case a.depth > b.depth: + return 1 + default: + return 0 + } + }) + + objs := m.polyObjects[:0] + for _, q := range quads { + objs = append(objs, m.polys[q.i*m.cols+q.j]) + } + m.updateAxisObjects() + for _, l := range m.axisLinePool { + objs = append(objs, l) + } + for _, t := range m.axisTextPool { + objs = append(objs, t) + } + objs = append(objs, m.cursor) + m.polyObjects = objs +} + +// minEdgeSq is the squared minimum polygon edge length below which two +// corners count as collapsed onto each other. The GL painter rounds every +// point to whole device pixels before the shader sees it, so corners must +// stay far enough apart that they can't land on the same pixel after +// rounding: distance d keeps the larger coordinate delta ≥ d/√2, which +// survives rounding while d·pixScale > √2 — at d=2 that covers any +// pixScale ≥ 0.75. +const minEdgeSq float32 = 4 + +// appendDistinct appends the corners to dst, skipping ones that collapse +// onto the previously kept corner (including last-onto-first wrap-around), +// so every edge of the resulting polygon has a usable direction vector. +func appendDistinct(dst []fyne.Position, corners [4]fyne.Position) []fyne.Position { + for _, p := range corners { + if len(dst) > 0 { + last := dst[len(dst)-1] + dx, dy := p.X-last.X, p.Y-last.Y + if dx*dx+dy*dy < minEdgeSq { + continue + } + } + dst = append(dst, p) + } + for len(dst) >= 2 { + first, last := dst[0], dst[len(dst)-1] + dx, dy := first.X-last.X, first.Y-last.Y + if dx*dx+dy*dy >= minEdgeSq { + break + } + dst = dst[:len(dst)-1] + } + return dst +} + +// segmentsCross reports whether segments ab and cd properly cross (shared or +// collinear endpoints don't count, which is fine for untwisting quads). +func segmentsCross(ax, ay, bx, by, cx, cy, dx, dy float32) bool { + orient := func(px, py, qx, qy, rx, ry float32) float32 { + return (qx-px)*(ry-py) - (qy-py)*(rx-px) + } + d1 := orient(ax, ay, bx, by, cx, cy) + d2 := orient(ax, ay, bx, by, dx, dy) + d3 := orient(cx, cy, dx, dy, ax, ay) + d4 := orient(cx, cy, dx, dy, bx, by) + return (d1 > 0) != (d2 > 0) && (d3 > 0) != (d4 > 0) +} + +// avgQuadColor averages the four corner colors and applies the flat Lambert +// shade. shade is <= 1 so the components can't overflow. +func avgQuadColor(a, b, c, d color.RGBA, shade float64) color.RGBA { + return color.RGBA{ + R: uint8(float64((int(a.R)+int(b.R)+int(c.R)+int(d.R))>>2) * shade), + G: uint8(float64((int(a.G)+int(b.G)+int(c.G)+int(d.G))>>2) * shade), + B: uint8(float64((int(a.B)+int(b.B)+int(c.B)+int(d.B))>>2) * shade), + A: 255, + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_render_test.go b/pkg/widgets/meshgrid/meshgrid_render_test.go new file mode 100644 index 00000000..b4602425 --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_render_test.go @@ -0,0 +1,367 @@ +package meshgrid + +import ( + "image/png" + "math" + "os" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + "github.com/roffe/txlogger/pkg/colors" +) + +func testGrid(t testing.TB) *Meshgrid { + t.Helper() + cols, rows := 16, 16 + values := make([]float64, cols*rows) + for i := 0; i < rows; i++ { + for j := 0; j < cols; j++ { + x := float64(j-cols/2) / 3 + y := float64(i-rows/2) / 3 + values[i*cols+j] = 100 / (1 + x*x + y*y) // central hump + } + } + xData, yData := axisValues(cols, rows) + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, xData, yData, 0, 0, 0, colors.ModeNormal, backendFromEnv()) + if err != nil { + t.Fatal(err) + } + m.size = fyne.NewSize(800, 500) + return m +} + +// axisValues builds simple monotonic column/row axis ticks for tests. +func axisValues(cols, rows int) (xData, yData []float64) { + xData = make([]float64, cols) + for j := range xData { + xData[j] = float64(j * 500) + } + yData = make([]float64, rows) + for i := range yData { + yData[i] = float64(i * 10) + } + return xData, yData +} + +// TestToggleZoomStable guards the regression where toggling the mesh off (the +// split pane collapses to zero height) and back on made it creep more zoomed-in +// each cycle: a degenerate size must not become the layout baseline. +func TestToggleZoomStable(t *testing.T) { + m := testGrid(t) + m.size = fyne.Size{} // force the first Layout to fit + m.refreshPending = true // suppress the async throttledRefresh in tests + r := &meshgridRenderer{MG: m} + + full := fyne.NewSize(800, 500) + r.Layout(full) // initial auto-fit + want := m.scale + + // One toggle cycle as Fyne actually lays it out: off collapses the pane to + // zero height, on restores it through a non-zero intermediate. The zero frame + // must not become the baseline, or the restore over-grows the scale. + for i := 0; i < 5; i++ { + r.Layout(fyne.NewSize(800, 0)) // off: pane collapses to zero + r.Layout(fyne.NewSize(800, 100)) // on: pane reappears small + r.Layout(full) // on: pane expands to full + } + if math.Abs(m.scale-want)/want > 1e-9 { + t.Fatalf("scale drifted after toggles: got %v want %v", m.scale, want) + } +} + +// TestRenderRotated renders an asymmetric surface (tall corner spike) from +// four yaw angles so painter's-order mistakes show up as the spike being +// overdrawn by cells that are behind it. +func TestRenderRotated(t *testing.T) { + if os.Getenv("MESHGRID_DUMP") == "" { + t.Skip("set MESHGRID_DUMP=1 to dump rotation PNGs") + } + cols, rows := 16, 16 + for n, yaw := range []float64{0, 90, 180, 270} { + values := make([]float64, cols*rows) + for i := 0; i < rows; i++ { + for j := 0; j < cols; j++ { + x := float64(j-2) / 1.5 + y := float64(i-2) / 1.5 + values[i*cols+j] = 10 + 100/(1+x*x+y*y) // spike near one corner + } + } + xData, yData := axisValues(cols, rows) + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, xData, yData, 0, 0, 0, colors.ModeNormal, backendFromEnv()) + if err != nil { + t.Fatal(err) + } + m.size = fyne.NewSize(800, 500) + m.rotateMeshgrid(0, yaw, 0) + img := m.drawMeshgridLines() + f, err := os.Create("/tmp/meshgrid_rot_" + string(rune('0'+n)) + ".png") + if err != nil { + t.Fatal(err) + } + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + f.Close() + } +} + +// the tracking marker overlay must land on the surface: a cursor centered on +// a vertex (cell 7.5 + the 0.5 corner offset = vertex 8) must project to +// exactly that vertex's screen position +func TestCursorScreenPosition(t *testing.T) { + m := testGrid(t) + m.cursorX, m.cursorY, m.showCursor = 7.5, 7.5, true + px, py := m.cursorScreenPosition() + v := m.vertices[8][8] + wantX := float32(float64(m.size.Width)*0.5 + v.X) + wantY := float32(float64(m.size.Height)*0.5 + v.Y) + if px != wantX || py != wantY { + t.Fatalf("cursor at (%v, %v), want vertex projection (%v, %v)", px, py, wantX, wantY) + } + if px < 0 || px > m.size.Width || py < 0 || py > m.size.Height { + t.Fatalf("cursor (%v, %v) outside widget %v", px, py, m.size) + } +} + +// the axis-scale geometry must contain the three labeled box edges, the axis +// names and the real first/last tick values for each axis, all projected inside +// a sane neighborhood of the widget. +func TestAxisGeometry(t *testing.T) { + m := testGrid(t) // 16x16, xData[j]=j*500, yData[i]=i*10, "RPM"/"Load"/"Fuel" + segs, labels := m.computeAxisGeometry() + + if len(segs) < 3 { + t.Fatalf("got %d axis segments, want at least the 3 box edges", len(segs)) + } + + have := make(map[string]bool, len(labels)) + for _, l := range labels { + have[l.text] = true + } + // axis names plus the first/last X and Y tick values, which appendAxis + // always labels regardless of thinning + for _, want := range []string{"RPM", "Load", "Fuel", "7500", "150"} { + if !have[want] { + t.Errorf("axis labels missing %q; have %v", want, have) + } + } + + // every label and tick endpoint must land near the widget (a broad sanity + // bound: outward-offset labels may sit a little outside the frame) + const margin = 200 + for _, l := range labels { + if l.x < -margin || l.x > m.size.Width+margin || l.y < -margin || l.y > m.size.Height+margin { + t.Errorf("label %q at (%v,%v) far outside widget %v", l.text, l.x, l.y, m.size) + } + } +} + +// updateAxisObjects must drive the canvas pools without a driver panic and +// leave at least the three edges and three axis names visible. +func TestUpdateAxisObjectsOverlay(t *testing.T) { + test.NewApp() // canvas.Text.MinSize needs a (test) driver + m := testGrid(t) + m.updateAxisObjects() + + visibleLines, visibleTexts := 0, 0 + for _, l := range m.axisLinePool { + if !l.Hidden { + visibleLines++ + } + } + for _, tx := range m.axisTextPool { + if !tx.Hidden { + visibleTexts++ + } + } + if visibleLines < 3 { + t.Errorf("want at least the 3 box-edge lines visible, got %d", visibleLines) + } + if visibleTexts < 3 { + t.Errorf("want at least the 3 axis-name labels visible, got %d", visibleTexts) + } +} + +func BenchmarkDrawSurface(b *testing.B) { + m := testGrid(b) + m.renderMode = RenderModeSolidWireframe + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.drawMeshgridLines() + } +} + +// usePolyBackend switches a test grid to the polygon renderer (the default +// is the shader backend, whose per-frame work happens on the GPU). +func usePolyBackend(m *Meshgrid) { + m.backend = BackendPolygons + m.initPolygons() +} + +// CPU-side cost of a polygon-renderer frame (projection, colors, painter's +// sort, object updates). The GPU draw calls aren't measurable here; compare +// against BenchmarkDrawSurface, which also excludes that path's per-frame +// texture upload. +func BenchmarkUpdatePolygons(b *testing.B) { + m := testGrid(b) + usePolyBackend(m) + m.renderMode = RenderModeSolidWireframe + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.updatePolygons() + } +} + +// TestPolygonDegenerateQuads sweeps the camera across many angles over a +// surface with flat plateaus (rows of identical values, like real boost maps) +// and asserts every visible polygon is well-formed: at least three corners, +// no near-zero edges (those become NaN in the GL shader's normalize() and +// flood the bounding box with color) and no self-intersecting outlines. +func TestPolygonDegenerateQuads(t *testing.T) { + cols, rows := 16, 16 + values := make([]float64, cols*rows) + for i := 0; i < rows; i++ { + for j := 0; j < cols; j++ { + // Stepped plateaus: blocks of identical values produce coplanar + // cells that collapse to slivers when viewed edge-on. + values[i*cols+j] = float64((i / 4) * 100) + } + } + xData, yData := axisValues(cols, rows) + m, err := NewMeshgrid("RPM", "Load", "Fuel", values, cols, rows, xData, yData, 0, 0, 0, colors.ModeNormal, backendFromEnv()) + if err != nil { + t.Fatal(err) + } + m.size = fyne.NewSize(800, 500) + usePolyBackend(m) + m.renderMode = RenderModeSolidWireframe + + for pitch := 0; pitch < 180; pitch += 15 { + for yaw := 0; yaw < 360; yaw += 15 { + m.cameraRotation = RotationMatrixX(float64(pitch)).Multiply(NewMatrix3x3()).Multiply(RotationMatrixZ(float64(yaw))) + m.updateVertexPositions() + m.updatePolygons() + + for idx, p := range m.polys { + if p.Hidden { + continue + } + pts := p.Points + if len(pts) < 3 { + t.Fatalf("pitch=%d yaw=%d cell %d: visible polygon with %d points", pitch, yaw, idx, len(pts)) + } + for k := range pts { + a, b := pts[k], pts[(k+1)%len(pts)] + dx, dy := b.X-a.X, b.Y-a.Y + if dx*dx+dy*dy < minEdgeSq { + t.Fatalf("pitch=%d yaw=%d cell %d: collapsed edge %d: %v", pitch, yaw, idx, k, pts) + } + } + if len(pts) == 4 { + if segmentsCross(pts[0].X, pts[0].Y, pts[1].X, pts[1].Y, pts[2].X, pts[2].Y, pts[3].X, pts[3].Y) || + segmentsCross(pts[1].X, pts[1].Y, pts[2].X, pts[2].Y, pts[3].X, pts[3].Y, pts[0].X, pts[0].Y) { + t.Fatalf("pitch=%d yaw=%d cell %d: self-intersecting polygon: %v", pitch, yaw, idx, pts) + } + } + } + } + } +} + +func TestAppendDistinct(t *testing.T) { + p := func(x, y float32) fyne.Position { return fyne.NewPos(x, y) } + for _, tc := range []struct { + name string + corners [4]fyne.Position + want int + }{ + {"all distinct", [4]fyne.Position{p(0, 0), p(10, 0), p(10, 10), p(0, 10)}, 4}, + {"one collapsed edge", [4]fyne.Position{p(0, 0), p(10, 0), p(10.1, 0.1), p(0, 10)}, 3}, + {"wrap-around collapse", [4]fyne.Position{p(0, 0), p(10, 0), p(10, 10), p(0.1, 0.1)}, 3}, + {"line", [4]fyne.Position{p(0, 0), p(10, 0), p(10.1, 0), p(0.2, 0.1)}, 2}, + {"point", [4]fyne.Position{p(5, 5), p(5.1, 5), p(5, 5.1), p(5.1, 5.1)}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + got := appendDistinct(make([]fyne.Position, 0, 4), tc.corners) + if len(got) != tc.want { + t.Fatalf("got %d points %v, want %d", len(got), got, tc.want) + } + for k := 0; len(got) >= 2 && k < len(got); k++ { + a, b := got[k], got[(k+1)%len(got)] + dx, dy := b.X-a.X, b.Y-a.Y + if dx*dx+dy*dy < minEdgeSq { + t.Fatalf("result has collapsed edge %d: %v", k, got) + } + } + }) + } +} + +// TestPolygonRender captures the polygon renderer through the software +// painter so the output can be eyeballed without a GL window. +func TestPolygonRender(t *testing.T) { + if os.Getenv("MESHGRID_DUMP") == "" { + t.Skip("set MESHGRID_DUMP=1 to dump polygon render PNGs") + } + test.NewApp() + for _, tc := range []struct { + name string + mode RenderMode + }{ + {"solidwire", RenderModeSolidWireframe}, + {"solid", RenderModeSolid}, + {"wireframe", RenderModeWireframe}, + } { + t.Run(tc.name, func(t *testing.T) { + m := testGrid(t) + usePolyBackend(m) + m.renderMode = tc.mode + w := test.NewWindow(m) + defer w.Close() + w.Resize(fyne.NewSize(820, 520)) + m.refresh() + img := w.Canvas().Capture() + f, err := os.Create("/tmp/meshgrid_poly_" + tc.name + ".png") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestRenderModes(t *testing.T) { + for _, tc := range []struct { + name string + mode RenderMode + }{ + {"solidwire", RenderModeSolidWireframe}, + {"solid", RenderModeSolid}, + {"wireframe", RenderModeWireframe}, + } { + t.Run(tc.name, func(t *testing.T) { + m := testGrid(t) + m.renderMode = tc.mode + img := m.drawMeshgridLines() + if img.Bounds().Dx() != 800 || img.Bounds().Dy() != 500 { + t.Fatalf("unexpected bounds %v", img.Bounds()) + } + if os.Getenv("MESHGRID_DUMP") != "" { + f, err := os.Create("/tmp/meshgrid_" + tc.name + ".png") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + } + }) + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_shader.go b/pkg/widgets/meshgrid/meshgrid_shader.go new file mode 100644 index 00000000..071ee970 --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_shader.go @@ -0,0 +1,613 @@ +package meshgrid + +import ( + "fmt" + "image" + "image/color" + "math" + "sync/atomic" + + "fyne.io/fyne/v2/canvas" + "github.com/roffe/txlogger/pkg/colors" +) + +// GPU renderer: the whole mesh is drawn by a single canvas.Shader. The mesh +// values live in a small data texture and the camera in a handful of float +// uniforms; the fragment shader reconstructs each pixel's orthographic view +// ray, walks the grid with a 2D DDA and intersects the two triangles of each +// visited cell. Rotating, zooming and panning therefore cost the CPU nothing +// but a uniform update - no projection, sorting or rasterization per frame - +// and a data edit re-uploads only the value texture. +// +// The vertices are the raw cell values (dataVertexMode): the texture is cols x +// rows, the grid has (cols-1)x(rows-1) cells and each cell triangulates the +// four data points around it, so the surface passes exactly through every value +// the way T7Suite's mesh does - a low cell only drops the two triangles meeting +// at that corner, never the wider neighbourhood that averaging values onto a +// shared corner grid would. Degenerate 1xN / Nx1 maps fall back to a corner +// grid (cols+1 x rows+1, value averaged per corner); the GLSL is identical, only +// grid_cols/grid_rows, the texture and the centre uniforms differ. +// +// The grid-space conventions shared between the Go side and the GLSL below: +// - one grid unit = one cell = cellWidth (32) logical px before scaling +// - in dataVertexMode the vertex for data cell (r, c) sits at grid (c, +// rows-1-r); +Y is the low-index data rows. center_gx = (cols-1)/2 places +// it half a cell in from the corner grid, aligning the mesh with the axis +// ticks (drawn at cell centres) and with projectOriginal +// - vertex height = z_off + z_gain * value16, reproducing +// (V - zmin)/zrange * depth in grid units +// - view = R * ((grid - center) * scale_px) - cam; the viewer sits at +Z +// looking along -Z, so the nearest surface has the largest view Z + +// shaderSeq makes each widget's Shader.Name unique: the painter caches both +// the compiled program and the bound textures per name, so two open map +// windows sharing a name would evict each other's data texture every frame. +var shaderSeq atomic.Int64 + +const meshShaderPreludeGL = "#version 110\n" + +const meshShaderPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const meshShaderBody = ` +#define MAX_STEPS 160 + +uniform vec2 frame_size; +uniform vec4 rect_coords; + +uniform sampler2D mesh_tex; // cols x rows cell values (or corner grid), 16 bit in RG +uniform sampler2D colormap_tex; // 256x1 value -> base color lookup + +// camera rotation R, row major; view = R * model +uniform float r0; +uniform float r1; +uniform float r2; +uniform float r3; +uniform float r4; +uniform float r5; +uniform float r6; +uniform float r7; +uniform float r8; + +uniform float grid_cols; // cells in X +uniform float grid_rows; // cells in Y +uniform float scale_px; // logical px per grid unit +uniform float height_units; // full value range height, grid units +uniform float z_off; // corner height = z_off + z_gain * value16 +uniform float z_gain; +uniform float center_gx; // mesh center, grid units +uniform float center_gy; +uniform float center_gz; +uniform float cam_x; // camera pan, logical px +uniform float cam_y; +uniform float size_w; // widget size, logical px +uniform float size_h; +uniform float view_zmin; // view-space depth extent of the mesh, logical px +uniform float view_zrange; +uniform float render_mode; // 0 solid+wire, 1 solid, 2 wireframe +uniform float light_x; // light direction in model space, unit length +uniform float light_y; +uniform float light_z; + +const float BIG = 100000.0; + +float corner_height(float gx, float gy) { + vec2 uv = vec2((gx + 0.5) / (grid_cols + 1.0), (gy + 0.5) / (grid_rows + 1.0)); + vec4 t = texture2D(mesh_tex, uv); + return z_off + z_gain * (t.r * 65280.0 + t.g * 255.0) / 65535.0; +} + +// narrow the line/AABB overlap [umin, umax] by one slab +void slab(float o, float d, float lo, float hi, inout float umin, inout float umax) { + if (abs(d) < 0.00000001) { + if (o < lo || o > hi) { + umin = BIG; + umax = -BIG; + } + return; + } + float t1 = (lo - o) / d; + float t2 = (hi - o) / d; + if (t1 > t2) { + float tmp = t1; + t1 = t2; + t2 = tmp; + } + umin = max(umin, t1); + umax = min(umax, t2); +} + +// Moeller-Trumbore; on a hit t is the ray parameter and bary the (a, b, c) weights +bool ray_tri(vec3 ro, vec3 rd, vec3 a, vec3 b, vec3 c, out float t, out vec3 bary) { + t = 0.0; + bary = vec3(0.0); + vec3 e1 = b - a; + vec3 e2 = c - a; + vec3 pv = cross(rd, e2); + float det = dot(e1, pv); + if (abs(det) < 0.0000000001) { + return false; + } + float inv_det = 1.0 / det; + vec3 tv = ro - a; + float u = dot(tv, pv) * inv_det; + if (u < -0.0001 || u > 1.0001) { + return false; + } + vec3 qv = cross(tv, e1); + float v = dot(rd, qv) * inv_det; + if (v < -0.0001 || u + v > 1.0001) { + return false; + } + t = dot(e2, qv) * inv_det; + bary = vec3(1.0 - u - v, u, v); + return true; +} + +// grid point -> (device px x, device px y, view-space z in logical px) +vec3 project_grid(mat3 rot, vec3 g, float pix_scale) { + vec3 v = rot * ((g - vec3(center_gx, center_gy, center_gz)) * scale_px) - vec3(cam_x, cam_y, 0.0); + return vec3((v.xy + 0.5 * vec2(size_w, size_h)) * pix_scale, v.z); +} + +// value color with the depth shading of getColorWithDepth; h in grid units +vec3 height_color(float h, float view_z) { + float val = clamp(h / height_units, 0.0, 1.0); + vec4 base = texture2D(colormap_tex, vec2(val * 0.99609375 + 0.001953125, 0.5)); + float df = clamp((view_z - view_zmin) / view_zrange, 0.0, 1.0); + vec3 rgb = base.rgb * (0.6 + 0.4 * df); + rgb.b = min(1.0, rgb.b + (1.0 - df) * 0.05882353); + // Yellow emphasis from getColorWithDepth, but ramped smoothly: the CPU + // path applies the 10% boost per vertex and lets Gouraud blur its edge, + // while this shader runs per pixel, so a hard threshold would draw a + // visible band where the boost switches on. smoothstep fades it in around + // pure yellow so the mid-range stays a continuous gradient. + float yellow = smoothstep(0.6, 0.95, base.r) * smoothstep(0.6, 0.95, base.g) * (1.0 - smoothstep(0.2, 0.4, base.b)); + float boost = 1.0 + 0.1 * yellow; + rgb.r = min(1.0, rgb.r * boost); + rgb.g = min(1.0, rgb.g * boost); + return rgb; +} + +// closest-point parameter of p on segment a-b +float seg_param(vec2 p, vec2 a, vec2 b) { + vec2 e = b - a; + float ee = dot(e, e); + if (ee < 0.000001) { + return 0.0; + } + return clamp(dot(p - a, e) / ee, 0.0, 1.0); +} + +// anti-aliased coverage of a line of the given half width at distance d +float line_mask(float d, float half_w) { + return 1.0 - smoothstep(half_w - 0.6, half_w + 0.6, d); +} + +// front-to-back "under" compositing of one wireframe segment +void wire_seg(vec2 p_dev, mat3 rot, float pix_scale, float half_w, vec3 a, vec3 b, float fade, inout vec3 acc, inout float acc_a) { + vec3 pa = project_grid(rot, a, pix_scale); + vec3 pb = project_grid(rot, b, pix_scale); + float h = seg_param(p_dev, pa.xy, pb.xy); + float d = distance(p_dev, mix(pa.xy, pb.xy, h)); + float mask = line_mask(d, half_w); + if (mask <= 0.0) { + return; + } + vec3 rgb = height_color(mix(a.z, b.z, h), mix(pa.z, pb.z, h)) * fade; + acc += (1.0 - acc_a) * mask * rgb; + acc_a += (1.0 - acc_a) * mask; +} + +// track the nearest cell border for the solid+wireframe grid lines +void edge_check(vec2 p_dev, vec3 pa, vec3 pb, float ha, float hb, inout float best_d, inout float best_h, inout float best_z) { + float t = seg_param(p_dev, pa.xy, pb.xy); + float d = distance(p_dev, mix(pa.xy, pb.xy, t)); + if (d < best_d) { + best_d = d; + best_h = mix(ha, hb, t); + best_z = mix(pa.z, pb.z, t); + } +} + +// fake ambient occlusion: darken concave cells (valleys, creases) using the +// height-field Laplacian sampled at the cell centre and its four neighbours. +// A positive Laplacian means the centre sits below its surroundings, so it +// would be shadowed by them; convex ridges (negative) are left untouched. +float cell_ao(float cx, float cy) { + float cxm = max(cx - 0.5, 0.0); + float cxp = min(cx + 1.5, grid_cols); + float cym = max(cy - 0.5, 0.0); + float cyp = min(cy + 1.5, grid_rows); + float hc = corner_height(cx + 0.5, cy + 0.5); + float lap = corner_height(cxp, cy + 0.5) + corner_height(cxm, cy + 0.5) + + corner_height(cx + 0.5, cyp) + corner_height(cx + 0.5, cym) - 4.0 * hc; + float c = clamp(lap / max(height_units, 0.0001), 0.0, 1.0); + return 1.0 - 0.4 * c; +} + +// Blinn-Phong shading with an ambient floor and fake AO. n is the raw cell +// normal from cross(C-A, D-B), which points along -Z for a flat cell, so it is +// flipped to face up. light and view_dir are unit vectors in grid space; the +// specular term is gated to the lit side and the ambient term keeps shadowed +// faces readable instead of black. +vec3 shade_surface(vec3 base, vec3 n, vec3 light, vec3 view_dir, float ao) { + float nl = length(n); + if (nl <= 0.0) { + return base * ao; + } + vec3 N = -n / nl; + float diff = max(dot(N, light), 0.0); + vec3 H = normalize(light + view_dir); + float spec = (diff > 0.0) ? pow(max(dot(N, H), 0.0), 32.0) : 0.0; + vec3 col = base * ((0.32 + 0.68 * diff) * ao); + col += vec3(0.25 * spec); + return col; +} + +void main() { + mat3 rot = mat3(r0, r3, r6, r1, r4, r7, r2, r5, r8); + + float pix_scale = (rect_coords.y - rect_coords.x) / max(size_w, 1.0); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > rect_coords.y - rect_coords.x || p_dev.y > rect_coords.w - rect_coords.z) { + discard; + } + + vec2 view_xy = p_dev / pix_scale - 0.5 * vec2(size_w, size_h) + vec2(cam_x, cam_y); + + // pixel ray in grid space: g(u) = g0 + u * dg with u the view-space + // depth; g0 = transpose(R) * (view_xy, 0) / scale + center + vec3 g0 = vec3( + (r0 * view_xy.x + r3 * view_xy.y) / scale_px + center_gx, + (r1 * view_xy.x + r4 * view_xy.y) / scale_px + center_gy, + (r2 * view_xy.x + r5 * view_xy.y) / scale_px + center_gz); + vec3 dg = vec3(r6, r7, r8) / scale_px; + + float z_lo = min(z_off, z_off + z_gain) - 0.05; + float z_hi = max(z_off, z_off + z_gain) + 0.05; + + float umin = -BIG; + float umax = BIG; + slab(g0.x, dg.x, 0.0, grid_cols, umin, umax); + slab(g0.y, dg.y, 0.0, grid_rows, umin, umax); + slab(g0.z, dg.z, z_lo, z_hi, umin, umax); + if (umax <= umin) { + discard; + } + + // march from the near side (largest view z) toward the far side + vec3 ro = g0 + umax * dg; + vec3 rd = -dg; + float tend = umax - umin; + ro += rd * 0.0001; + + float cx = clamp(floor(ro.x), 0.0, grid_cols - 1.0); + float cy = clamp(floor(ro.y), 0.0, grid_rows - 1.0); + + float step_x = rd.x > 0.0 ? 1.0 : -1.0; + float step_y = rd.y > 0.0 ? 1.0 : -1.0; + float td_x = abs(rd.x) < 0.00000001 ? BIG : 1.0 / abs(rd.x); + float td_y = abs(rd.y) < 0.00000001 ? BIG : 1.0 / abs(rd.y); + float tm_x = abs(rd.x) < 0.00000001 ? BIG : (rd.x > 0.0 ? cx + 1.0 - ro.x : ro.x - cx) / abs(rd.x); + float tm_y = abs(rd.y) < 0.00000001 ? BIG : (rd.y > 0.0 ? cy + 1.0 - ro.y : ro.y - cy) / abs(rd.y); + + int mode = int(render_mode + 0.5); + float half_w = 0.5 * pix_scale; + vec3 light = vec3(light_x, light_y, light_z); + // grid-space direction from the surface toward the camera: the view ray + // marches from near to far along rd = -(r6,r7,r8), so the viewer lies along + // +(r6,r7,r8), already unit length since the rotation is orthonormal + vec3 view_dir = vec3(r6, r7, r8); + + vec3 acc = vec3(0.0); + float acc_a = 0.0; + + for (int i = 0; i < MAX_STEPS; i++) { + float h_bl = corner_height(cx, cy); + float h_br = corner_height(cx + 1.0, cy); + float h_tl = corner_height(cx, cy + 1.0); + float h_tr = corner_height(cx + 1.0, cy + 1.0); + + // cell corners; the solid fill chooses its diagonal per cell (below) + // while the wireframe diagonal runs B-D like the CPU line mesh + vec3 A = vec3(cx, cy + 1.0, h_tl); + vec3 B = vec3(cx + 1.0, cy + 1.0, h_tr); + vec3 C = vec3(cx + 1.0, cy, h_br); + vec3 D = vec3(cx, cy, h_bl); + + if (mode == 2) { + wire_seg(p_dev, rot, pix_scale, half_w, A, B, 1.0, acc, acc_a); + wire_seg(p_dev, rot, pix_scale, half_w, B, C, 1.0, acc, acc_a); + wire_seg(p_dev, rot, pix_scale, half_w, C, D, 1.0, acc, acc_a); + wire_seg(p_dev, rot, pix_scale, half_w, D, A, 1.0, acc, acc_a); + wire_seg(p_dev, rot, pix_scale, half_w, B, D, 0.7, acc, acc_a); + if (acc_a > 0.995) { + break; + } + } else { + // Two triangles per cell, but the diagonal is chosen per cell so the + // fold runs between the two closest corners (the smaller of the two + // diagonal height gaps). A lone outlier - a high peak or a low dip - + // then falls on a single triangle: the other triangle keeps its three + // similar corners as a near-flat plateau and only the outlier's + // triangle slopes. That is the "plateau triangle + sloping triangle" + // look of T7Suite, instead of the whole quad sagging toward the + // outlier. Per-triangle normals let the plateau read flat while the + // slope catches the light. + bool fold_ac = abs(h_tl - h_br) <= abs(h_tr - h_bl); + float best_t = BIG; + float hit_h = 0.0; + vec3 hit_n = vec3(0.0, 0.0, -1.0); + float ts; + vec3 bcs; + if (fold_ac) { + if (ray_tri(ro, rd, A, B, C, ts, bcs) && ts < best_t) { + best_t = ts; + hit_h = bcs.x * A.z + bcs.y * B.z + bcs.z * C.z; + hit_n = cross(B - A, C - A); + } + if (ray_tri(ro, rd, A, C, D, ts, bcs) && ts < best_t) { + best_t = ts; + hit_h = bcs.x * A.z + bcs.y * C.z + bcs.z * D.z; + hit_n = cross(C - A, D - A); + } + } else { + if (ray_tri(ro, rd, A, B, D, ts, bcs) && ts < best_t) { + best_t = ts; + hit_h = bcs.x * A.z + bcs.y * B.z + bcs.z * D.z; + hit_n = cross(B - A, D - A); + } + if (ray_tri(ro, rd, B, C, D, ts, bcs) && ts < best_t) { + best_t = ts; + hit_h = bcs.x * B.z + bcs.y * C.z + bcs.z * D.z; + hit_n = cross(C - B, D - B); + } + } + if (best_t < BIG) { + float view_z = umax - best_t; + vec3 rgb = height_color(hit_h, view_z); + + float ao = cell_ao(cx, cy); + rgb = shade_surface(rgb, hit_n, light, view_dir, ao); + + if (mode == 0) { + vec3 pa = project_grid(rot, A, pix_scale); + vec3 pb = project_grid(rot, B, pix_scale); + vec3 pc = project_grid(rot, C, pix_scale); + vec3 pd = project_grid(rot, D, pix_scale); + float best_d = BIG; + float line_h = 0.0; + float line_z = 0.0; + edge_check(p_dev, pa, pb, A.z, B.z, best_d, line_h, line_z); + edge_check(p_dev, pb, pc, B.z, C.z, best_d, line_h, line_z); + edge_check(p_dev, pc, pd, C.z, D.z, best_d, line_h, line_z); + edge_check(p_dev, pd, pa, D.z, A.z, best_d, line_h, line_z); + // only the cell borders are drawn; the per-cell fold diagonal + // is left in the cell colour so the two triangles blend + float lm = line_mask(best_d, half_w); + if (lm > 0.0) { + rgb = mix(rgb, height_color(line_h, line_z) * 0.45, lm); + } + } + + gl_FragColor = vec4(rgb, 1.0); + return; + } + } + + if (min(tm_x, tm_y) >= tend) { + break; + } + if (tm_x < tm_y) { + cx += step_x; + tm_x += td_x; + } else { + cy += step_y; + tm_y += td_y; + } + if (cx < -0.5 || cx > grid_cols - 0.5 || cy < -0.5 || cy > grid_rows - 0.5) { + break; + } + } + + if (mode == 2 && acc_a > 0.003) { + gl_FragColor = vec4(acc / acc_a, acc_a); + return; + } + discard; +} +` + +func (m *Meshgrid) initShader() { + m.shader = canvas.NewShader( + fmt.Sprintf("meshgrid-%d", shaderSeq.Add(1)), + []byte(meshShaderPreludeGL+meshShaderBody), + []byte(meshShaderPreludeES+meshShaderBody), + ) + m.shader.Textures = make(map[string]image.Image, 2) + m.shader.Uniforms = make(map[string]float32, 32) + m.updateShaderData() + m.updateShaderColormap() + m.updateShaderUniforms() +} + +// dataVertexMode reports whether the shader treats raw cell values as the mesh +// vertices (the T7Suite-style triangulated surface) rather than averaging them +// onto a corner grid. It needs at least 2x2 cells to span a quad between data +// points; a 1xN / Nx1 map falls back to the corner-averaged path, which already +// renders those degenerate "ribbons" sensibly. +func (m *Meshgrid) dataVertexMode() bool { + return m.cols >= 2 && m.rows >= 2 +} + +// updateShaderData re-encodes the mesh values into the data texture and the +// height-mapping uniforms. In dataVertexMode the texture holds the raw cell +// values (one texel per data cell) so the shader's per-cell triangulation +// passes exactly through each value: a low cell only pulls down the two +// triangles touching that corner, never the wider neighbourhood that corner +// averaging would. Otherwise it falls back to the (cols+1)x(rows+1) averaged +// corner grid. A fresh image is allocated on purpose: the painter re-uploads a +// texture only when the map entry points at a new image. +func (m *Meshgrid) updateShaderData() { + if m.shader == nil { + return + } + + // Values are normalized against the actual data extent so the 16-bit + // quantization keeps full resolution even when zmin/zmax (set by + // LoadFloat64s) span a wider range than the data. + dataMin, dataMax := math.Inf(1), math.Inf(-1) + for _, v := range m.values { + if v < dataMin { + dataMin = v + } + if v > dataMax { + dataMax = v + } + } + dataRange := dataMax - dataMin + encode := func(v float64) color.RGBA { + norm := 0.0 + if dataRange > 0 { + norm = (v - dataMin) / dataRange + } + q := uint16(norm*65535 + 0.5) + return color.RGBA{R: uint8(q >> 8), G: uint8(q), A: 0xff} + } + + var img *image.RGBA + if m.dataVertexMode() { + // One texel per data cell; cell (r, c) is the vertex at grid (c, + // rows-1-r), so data row 0 stays at the far (high-Y) edge. + img = image.NewRGBA(image.Rect(0, 0, m.cols, m.rows)) + for r := 0; r < m.rows; r++ { + for c := 0; c < m.cols; c++ { + img.SetRGBA(c, m.rows-1-r, encode(m.values[r*m.cols+c])) + } + } + } else { + // Corner-averaged grid: vertex row i sits at grid Y rows-i, which is + // also its texture row. + vRows, vCols := m.rows+1, m.cols+1 + img = image.NewRGBA(image.Rect(0, 0, vCols, vRows)) + for i := 0; i < vRows; i++ { + row := m.vertices[i] + for j := 0; j < vCols; j++ { + img.SetRGBA(j, m.rows-i, encode(row[j].V)) + } + } + } + m.shader.Textures["mesh_tex"] = img + + // Heights in grid units: z_off + z_gain*norm reproduces + // (V - zmin)/zrange * depth, in units of one cell. + zr := m.zrange + if zr == 0 { + zr = 1 + } + hmax := m.depth / float64(m.cellWidth) + m.shader.Uniforms["z_off"] = float32((dataMin - m.zmin) / zr * hmax) + m.shader.Uniforms["z_gain"] = float32(dataRange / zr * hmax) +} + +// updateShaderColormap rebuilds the 256x1 value-to-color lookup texture. +func (m *Meshgrid) updateShaderColormap() { + if m.shader == nil { + return + } + lut := image.NewRGBA(image.Rect(0, 0, 256, 1)) + for k := 0; k < 256; k++ { + var c color.RGBA + if m.zrange == 0 { + // GetColorInterpolation resolves 0/0 to gray; match it + c = color.RGBA{R: 128, G: 128, B: 128, A: 255} + } else { + c = colors.GetColorInterpolation(0, 255, float64(k), m.colorMode) + } + lut.SetRGBA(k, 0, c) + } + m.shader.Textures["colormap_tex"] = lut +} + +// updateShaderUniforms pushes the camera and shading state; this is the whole +// per-frame CPU cost of the shader backend. +func (m *Meshgrid) updateShaderUniforms() { + if m.shader == nil { + return + } + + // View-space depth extent of the transformed mesh, for the same + // depth-shading ramp the CPU rasterizer applies. + minZ, maxZ := math.Inf(1), math.Inf(-1) + for i := range m.vertices { + row := m.vertices[i] + for j := range row { + z := row[j].Z + if z < minZ { + minZ = z + } + if z > maxZ { + maxZ = z + } + } + } + zRange := maxZ - minZ + if zRange == 0 { + zRange = 1 + } + + // Fixed view-space light from drawSurface, moved to model space so the + // shader can shade with grid-space normals directly. + lx, ly, lz := 0.3, -0.5, 0.8 + il := 1 / math.Sqrt(lx*lx+ly*ly+lz*lz) + lx, ly, lz = lx*il, ly*il, lz*il + + r := m.cameraRotation + cw := float64(m.cellWidth) + + // Grid extent and centre depend on the surface model. In dataVertexMode the + // vertices are the cols x rows data points, so there are (cols-1)x(rows-1) + // cells and the data point for column c sits half a cell in from the corner + // grid (centre_gx = (cols-1)/2), which keeps the mesh aligned with the axis + // ticks (drawn at cell centres) and with projectOriginal. The corner-grid + // fallback keeps the old cols x rows cells and centre derived from the + // averaged vertices. + gridCols, gridRows := float32(m.cols), float32(m.rows) + centerGx := float32(m.centerX / cw) + centerGy := float32(m.centerY/cw - 1) + if m.dataVertexMode() { + gridCols, gridRows = float32(m.cols-1), float32(m.rows-1) + centerGx = float32(float64(m.cols-1) / 2) + centerGy = float32(float64(m.rows-1) / 2) + } + + u := m.shader.Uniforms + u["r0"], u["r1"], u["r2"] = float32(r[0][0]), float32(r[0][1]), float32(r[0][2]) + u["r3"], u["r4"], u["r5"] = float32(r[1][0]), float32(r[1][1]), float32(r[1][2]) + u["r6"], u["r7"], u["r8"] = float32(r[2][0]), float32(r[2][1]), float32(r[2][2]) + u["grid_cols"] = gridCols + u["grid_rows"] = gridRows + u["scale_px"] = float32(cw * m.scale) + u["height_units"] = float32(m.depth / cw) + u["center_gx"] = centerGx + u["center_gy"] = centerGy + u["center_gz"] = float32(m.centerZ / cw) + u["cam_x"] = float32(m.cameraPosition[0]) + u["cam_y"] = float32(m.cameraPosition[1]) + u["size_w"] = float32(m.size.Width) + u["size_h"] = float32(m.size.Height) + u["view_zmin"] = float32(minZ) + u["view_zrange"] = float32(zRange) + u["render_mode"] = float32(m.renderMode) + u["light_x"] = float32(r[0][0]*lx + r[1][0]*ly + r[2][0]*lz) + u["light_y"] = float32(r[0][1]*lx + r[1][1]*ly + r[2][1]*lz) + u["light_z"] = float32(r[0][2]*lx + r[1][2]*ly + r[2][2]*lz) +} diff --git a/pkg/widgets/meshgrid/meshgrid_shader_test.go b/pkg/widgets/meshgrid/meshgrid_shader_test.go new file mode 100644 index 00000000..96a242ff --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_shader_test.go @@ -0,0 +1,146 @@ +package meshgrid + +import ( + "image" + "math" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/roffe/txlogger/pkg/colors" +) + +// In dataVertexMode the data texture plus z_off/z_gain must reproduce every +// cell's flat-top height (V - zmin)/zrange * depth in grid units. Data cell +// (r, c) is stored at texel (c, rows-1-r) so grid Y runs with the data rows. +func TestShaderDataEncoding(t *testing.T) { + m := testGrid(t) + if !m.dataVertexMode() { + t.Fatalf("testGrid is %dx%d, expected dataVertexMode", m.cols, m.rows) + } + tex, ok := m.shader.Textures["mesh_tex"].(*image.RGBA) + if !ok { + t.Fatal("mesh_tex missing or not RGBA") + } + if tex.Bounds().Dx() != m.cols || tex.Bounds().Dy() != m.rows { + t.Fatalf("mesh_tex is %v, want %dx%d", tex.Bounds(), m.cols, m.rows) + } + + zOff := float64(m.shader.Uniforms["z_off"]) + zGain := float64(m.shader.Uniforms["z_gain"]) + hmax := m.depth / float64(m.cellWidth) + + for r := 0; r < m.rows; r++ { + for c := 0; c < m.cols; c++ { + px := tex.RGBAAt(c, m.rows-1-r) + norm := (float64(px.R)*256 + float64(px.G)) / 65535 + gotH := zOff + zGain*norm + wantH := (m.values[r*m.cols+c] - m.zmin) / m.zrange * hmax + // 16-bit quantization of the encoded value range + if math.Abs(gotH-wantH) > zGain/65535+1e-6 { + t.Fatalf("cell (%d,%d): height %v, want %v", r, c, gotH, wantH) + } + } + } +} + +func TestShaderColormap(t *testing.T) { + m := testGrid(t) + lut, ok := m.shader.Textures["colormap_tex"].(*image.RGBA) + if !ok { + t.Fatal("colormap_tex missing or not RGBA") + } + if lut.Bounds().Dx() != 256 || lut.Bounds().Dy() != 1 { + t.Fatalf("colormap_tex is %v, want 256x1", lut.Bounds()) + } + for k := 0; k < 256; k++ { + want := colors.GetColorInterpolation(0, 255, float64(k), m.colorMode) + if got := lut.RGBAAt(k, 0); got != want { + t.Fatalf("lut[%d] = %v, want %v", k, got, want) + } + } +} + +// The shader projects grid points with +// +// view = R*((g - center)*scale_px) - cam; screen = view.xy + size/2 +// +// which must land on the same screen position as the CPU-transformed +// vertices, for any camera. Otherwise the mesh and the cursor/axis overlays +// drift apart. +func TestShaderProjectionMatchesVertices(t *testing.T) { + m := testGrid(t) + m.orbit(23, -41) + m.panMeshgrid(15, -7) + m.scaleMeshgrid(1.3) + m.updateShaderUniforms() + + u := m.shader.Uniforms + r := [3][3]float64{ + {float64(u["r0"]), float64(u["r1"]), float64(u["r2"])}, + {float64(u["r3"]), float64(u["r4"]), float64(u["r5"])}, + {float64(u["r6"]), float64(u["r7"]), float64(u["r8"])}, + } + cg := [3]float64{float64(u["center_gx"]), float64(u["center_gy"]), float64(u["center_gz"])} + scalePx := float64(u["scale_px"]) + cw := float64(m.cellWidth) + + for i := 0; i <= m.rows; i += 4 { + for j := 0; j <= m.cols; j += 4 { + v := m.vertices[i][j] + // dataVertexMode shifts the grid half a cell: the averaged corner + // (i, j) maps to shader grid (j-0.5, rows-i-0.5), which projects to + // the same screen point as the CPU corner transform. + g := [3]float64{float64(j) - 0.5, float64(m.rows-i) - 0.5, v.Oz / cw} + + var view [3]float64 + for a := 0; a < 3; a++ { + view[a] = r[a][0]*(g[0]-cg[0])*scalePx + + r[a][1]*(g[1]-cg[1])*scalePx + + r[a][2]*(g[2]-cg[2])*scalePx + } + view[0] -= float64(u["cam_x"]) + view[1] -= float64(u["cam_y"]) + + if math.Abs(view[0]-v.X) > 1e-3 || math.Abs(view[1]-v.Y) > 1e-3 || math.Abs(view[2]-v.Z) > 1e-3 { + t.Fatalf("vertex (%d,%d): shader view (%v,%v,%v), CPU view (%v,%v,%v)", + i, j, view[0], view[1], view[2], v.X, v.Y, v.Z) + } + } + } +} + +// CPU-side cost of a shader-backend frame: this plus the axis/cursor overlay +// moves is everything the CPU does per rotation/zoom frame. Compare against +// BenchmarkDrawSurface (image backend) and BenchmarkUpdatePolygons. +func BenchmarkUpdateShaderUniforms(b *testing.B) { + m := testGrid(b) + m.renderMode = RenderModeSolidWireframe + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.updateShaderUniforms() + } +} + +// Both shader variants must at least compile; glslangValidator checks them +// against the GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": meshShaderPreludeGL + meshShaderBody, + "es.frag": meshShaderPreludeES + meshShaderBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_surface.go b/pkg/widgets/meshgrid/meshgrid_surface.go new file mode 100644 index 00000000..afbea12f --- /dev/null +++ b/pkg/widgets/meshgrid/meshgrid_surface.go @@ -0,0 +1,232 @@ +package meshgrid + +import ( + "image" + "image/color" + "math" + "slices" +) + +// RenderMode selects how the mesh is drawn. +type RenderMode int + +const ( + // RenderModeSolidWireframe draws the filled surface with grid lines on top. + RenderModeSolidWireframe RenderMode = iota + // RenderModeSolid draws only the filled surface. + RenderModeSolid + // RenderModeWireframe draws the classic line mesh. + RenderModeWireframe + + renderModeCount +) + +// quadRef identifies one grid cell for the painter's-order surface pass. +type quadRef struct { + i, j int + depth float64 +} + +// Grid lines drawn on top of the filled surface are dimmed so they read as a +// grid instead of blending into the identically colored fill. +const surfaceEdgeFade = 0.45 + +// drawSurface fills each grid cell with two Gouraud-shaded triangles, +// back-to-front. Like the shader backend, the split diagonal is chosen per cell +// to fold between the two closest corners (by value, so it stays put as the +// camera rotates): a lone peak or dip then lands on a single triangle and the +// other stays a flat plateau, instead of the whole quad sagging toward it. Each +// triangle is flat-shaded from its own normal so the plateau reads flat while +// the slope catches the light. When edges is true the cell outline is drawn +// right after its fill, which keeps lines on hidden faces correctly occluded by +// nearer quads. +func (m *Meshgrid) drawSurface(img *image.RGBA, projX, projY []int, vertCol []color.RGBA, edges bool) { + // One quad per data cell; the corner-vertex grid is (rows+1) x (cols+1). + quads := m.scratchQuads[:0] + for i := 0; i < m.rows; i++ { + for j := 0; j < m.cols; j++ { + z := m.vertices[i][j].Z + m.vertices[i][j+1].Z + m.vertices[i+1][j].Z + m.vertices[i+1][j+1].Z + quads = append(quads, quadRef{i: i, j: j, depth: z * 0.25}) + } + } + m.scratchQuads = quads + + // Back-to-front: larger view-space Z is nearer the viewer (the depth + // shading brightens large Z), so draw small-Z quads first. + slices.SortFunc(quads, func(a, b quadRef) int { + switch { + case a.depth < b.depth: + return -1 + case a.depth > b.depth: + return 1 + default: + return 0 + } + }) + + // Fixed light direction in view space, normalized once per frame. + lx, ly, lz := 0.3, -0.5, 0.8 + il := 1 / math.Sqrt(lx*lx+ly*ly+lz*lz) + lx, ly, lz = lx*il, ly*il, lz*il + + vCols := m.cols + 1 + for _, q := range quads { + i, j := q.i, q.j + ai := i*vCols + j // top-left + bi := ai + 1 // top-right + di := ai + vCols // bottom-left + ci := di + 1 // bottom-right + + a := &m.vertices[i][j] + b := &m.vertices[i][j+1] + c := &m.vertices[i+1][j+1] + d := &m.vertices[i+1][j] + + // Fold along whichever diagonal has the smaller corner-value gap, so an + // outlier is isolated in one sloping triangle (see the doc comment). + if math.Abs(a.V-c.V) <= math.Abs(b.V-d.V) { + s1 := triShade(a, b, c, lx, ly, lz) + s2 := triShade(a, c, d, lx, ly, lz) + fillTriangle(img, projX[ai], projY[ai], projX[bi], projY[bi], projX[ci], projY[ci], + fadeColor(vertCol[ai], s1), fadeColor(vertCol[bi], s1), fadeColor(vertCol[ci], s1)) + fillTriangle(img, projX[ai], projY[ai], projX[ci], projY[ci], projX[di], projY[di], + fadeColor(vertCol[ai], s2), fadeColor(vertCol[ci], s2), fadeColor(vertCol[di], s2)) + } else { + s1 := triShade(a, b, d, lx, ly, lz) + s2 := triShade(b, c, d, lx, ly, lz) + fillTriangle(img, projX[ai], projY[ai], projX[bi], projY[bi], projX[di], projY[di], + fadeColor(vertCol[ai], s1), fadeColor(vertCol[bi], s1), fadeColor(vertCol[di], s1)) + fillTriangle(img, projX[bi], projY[bi], projX[ci], projY[ci], projX[di], projY[di], + fadeColor(vertCol[bi], s2), fadeColor(vertCol[ci], s2), fadeColor(vertCol[di], s2)) + } + + if edges { + ea := fadeColor(vertCol[ai], surfaceEdgeFade) + eb := fadeColor(vertCol[bi], surfaceEdgeFade) + ec := fadeColor(vertCol[ci], surfaceEdgeFade) + ed := fadeColor(vertCol[di], surfaceEdgeFade) + drawBresenhamLine(img, projX[ai], projY[ai], projX[bi], projY[bi], ea, eb) + drawBresenhamLine(img, projX[bi], projY[bi], projX[ci], projY[ci], eb, ec) + drawBresenhamLine(img, projX[ci], projY[ci], projX[di], projY[di], ec, ed) + drawBresenhamLine(img, projX[di], projY[di], projX[ai], projY[ai], ed, ea) + } + } +} + +// quadShade computes a flat Lambert term for the cell at (i,j) from its +// view-space normal (cross product of the diagonals, which is robust for +// non-planar quads). The absolute dot product is used since the surface is +// single-sided and may be viewed from below. +func (m *Meshgrid) quadShade(i, j int, lx, ly, lz float64) float64 { + a := m.vertices[i][j] + b := m.vertices[i][j+1] + c := m.vertices[i+1][j+1] + d := m.vertices[i+1][j] + + ux, uy, uz := c.X-a.X, c.Y-a.Y, c.Z-a.Z + vx, vy, vz := d.X-b.X, d.Y-b.Y, d.Z-b.Z + + nx := uy*vz - uz*vy + ny := uz*vx - ux*vz + nz := ux*vy - uy*vx + + nl := math.Sqrt(nx*nx + ny*ny + nz*nz) + if nl == 0 { + return 1 + } + dot := (nx*lx + ny*ly + nz*lz) / nl + if dot < 0 { + dot = -dot + } + return 0.6 + 0.4*dot +} + +// triShade computes a flat Lambert term for one triangle from its view-space +// normal (cross of two edges). The absolute dot product is used since the +// surface is single-sided and may be viewed from below. This is the per-cell +// quadShade applied per triangle so each facet of the chosen split shades on +// its own slope. +func triShade(a, b, c *Vertex, lx, ly, lz float64) float64 { + ux, uy, uz := b.X-a.X, b.Y-a.Y, b.Z-a.Z + vx, vy, vz := c.X-a.X, c.Y-a.Y, c.Z-a.Z + + nx := uy*vz - uz*vy + ny := uz*vx - ux*vz + nz := ux*vy - uy*vx + + nl := math.Sqrt(nx*nx + ny*ny + nz*nz) + if nl == 0 { + return 1 + } + dot := (nx*lx + ny*ly + nz*lz) / nl + if dot < 0 { + dot = -dot + } + return 0.6 + 0.4*dot +} + +// fillTriangle rasterizes a triangle with per-vertex (Gouraud) color +// interpolation using incremental integer edge functions, clipped to the +// image bounds via the bounding box. +func fillTriangle(img *image.RGBA, x0, y0, x1, y1, x2, y2 int, c0, c1, c2 color.RGBA) { + area := (x1-x0)*(y2-y0) - (y1-y0)*(x2-x0) + if area == 0 { + return + } + if area < 0 { + x1, y1, x2, y2 = x2, y2, x1, y1 + c1, c2 = c2, c1 + area = -area + } + + r := img.Rect + minX := max(min(x0, min(x1, x2)), r.Min.X) + maxX := min(max(x0, max(x1, x2)), r.Max.X-1) + minY := max(min(y0, min(y1, y2)), r.Min.Y) + maxY := min(max(y0, max(y1, y2)), r.Max.Y-1) + if minX > maxX || minY > maxY { + return + } + + // Edge function values at (minX, minY); stepping +1 in x adds the A term, + // +1 in y adds the B term. + ef := func(ax, ay, bx, by, px, py int) int { + return (bx-ax)*(py-ay) - (by-ay)*(px-ax) + } + w0Row := ef(x1, y1, x2, y2, minX, minY) + w1Row := ef(x2, y2, x0, y0, minX, minY) + w2Row := ef(x0, y0, x1, y1, minX, minY) + a0, b0 := y1-y2, x2-x1 + a1, b1 := y2-y0, x0-x2 + a2, b2 := y0-y1, x1-x0 + + invArea := 1.0 / float64(area) + r0, g0, bl0 := float64(c0.R), float64(c0.G), float64(c0.B) + r1, g1, bl1 := float64(c1.R), float64(c1.G), float64(c1.B) + r2, g2, bl2 := float64(c2.R), float64(c2.G), float64(c2.B) + + stride := img.Stride + pix := img.Pix + for y := minY; y <= maxY; y++ { + w0, w1, w2 := w0Row, w1Row, w2Row + idx := (y-r.Min.Y)*stride + (minX-r.Min.X)*4 + for x := minX; x <= maxX; x++ { + if w0 >= 0 && w1 >= 0 && w2 >= 0 { + fw0 := float64(w0) * invArea + fw1 := float64(w1) * invArea + fw2 := float64(w2) * invArea + pix[idx+0] = uint8(fw0*r0 + fw1*r1 + fw2*r2) + pix[idx+1] = uint8(fw0*g0 + fw1*g1 + fw2*g2) + pix[idx+2] = uint8(fw0*bl0 + fw1*bl1 + fw2*bl2) + pix[idx+3] = 255 + } + w0 += a0 + w1 += a1 + w2 += a2 + idx += 4 + } + w0Row += b0 + w1Row += b1 + w2Row += b2 + } +} diff --git a/pkg/widgets/meshgrid/meshgrid_widget.go b/pkg/widgets/meshgrid/meshgrid_widget.go index 63b6f4d2..c56d3129 100644 --- a/pkg/widgets/meshgrid/meshgrid_widget.go +++ b/pkg/widgets/meshgrid/meshgrid_widget.go @@ -5,6 +5,8 @@ import ( "image" "image/color" "log" + "math" + "os" "time" "fyne.io/fyne/v2" @@ -13,13 +15,45 @@ import ( "github.com/roffe/txlogger/pkg/colors" ) +// Vertex is a corner of the mesh. Values are cell-centered (one per table +// cell) while vertices sit on cell corners, so the vertex grid is one larger +// than the data grid in each direction and V holds the average of the +// adjacent cell values. type Vertex struct { Ox, Oy, Oz float64 // Original coordinates X, Y, Z float64 // Transformed coordinates for rendering + V float64 // Data value at this corner (average of adjacent cells) } var _ fyne.Widget = (*Meshgrid)(nil) +// renderBackend selects how the mesh is drawn. The GPU shader is the +// default; TXLOGGER_MESH_RENDERER=poly|image selects the older paths. +type RenderBackend int + +const ( + // backendShader ray-casts the whole mesh in one fragment shader + // (meshgrid_shader.go); per-frame CPU cost is a uniform update. + BackendShader RenderBackend = iota + // backendPolygons draws one canvas.ArbitraryPolygon per cell + // (meshgrid_poly.go). + BackendPolygons + // backendImage rasterizes on the CPU into a canvas.Image + // (meshgrid_draw.go). + BackendImage +) + +func backendFromEnv() RenderBackend { + switch os.Getenv("TXLOGGER_MESH_RENDERER") { + case "poly": + return BackendPolygons + case "image": + return BackendImage + default: + return BackendShader + } +} + type Meshgrid struct { widget.BaseWidget @@ -40,6 +74,34 @@ type Meshgrid struct { scratchProjY []int scratchColors []color.RGBA scratchLines []lineSegment + scratchQuads []quadRef + + // Scratch geometry for the axis-scale overlay, rebuilt each refresh by + // computeAxisGeometry and consumed by either the canvas pools or the raster. + scratchAxisSegs []axisSeg + scratchAxisLabels []axisLabel + + backend RenderBackend + + // Shader backend (meshgrid_shader.go): the whole mesh in one object. + shader *canvas.Shader + + // Polygon backend (meshgrid_poly.go): one reusable + // canvas.ArbitraryPolygon per cell instead of rasterizing into an image. + polys []*canvas.ArbitraryPolygon + polyObjects []fyne.CanvasObject + scratchFX []float32 + scratchFY []float32 + + // Axis-scale overlay shared by the shader and polygon backends (the image + // backend draws the same geometry into the raster). The line pool holds the + // three labeled box edges plus a tick mark per visible tick; the text pool + // holds a value label per visible tick plus the three axis-name labels. + // Both pools are sized to the worst case at init and Show/Hide per frame. + axisLinePool []*canvas.Line + axisTextPool []*canvas.Text + + renderMode RenderMode lastMouseX, lastMouseY float32 @@ -52,12 +114,33 @@ type Meshgrid struct { rotationMatrix Matrix3x3 scale float64 + // fitted is set once the widget has received its first real size and the + // mesh has been auto-scaled to fit. Subsequent resizes scale relative to + // the size change so the user's manual zoom is preserved. + fitted bool + cameraRotation Matrix3x3 // Camera's rotation matrix cameraPosition [3]float64 // Camera's position in world space mousePosition image.Point + // Live tracking marker (fractional cell indices), mirroring the + // mapviewer crosshair. Hidden until SetCursor is first called. The marker + // is a canvas primitive overlaid on the mesh image so moving it only + // repaints the scene instead of re-rasterizing the whole mesh. + cursorX, cursorY float64 + showCursor bool + cursor *canvas.Circle + xlabel, ylabel, zlabel string + // Axis tick values (one per column / row) and their display precision, + // used to draw the T7Suite-style scales along the mesh edges. xData has + // cols entries, yData has rows; either may be nil/short, in which case that + // axis' value labels are skipped. zPrec formats the height (Z) scale, whose + // values run from zmin to zmax. + xData, yData []float64 + xPrec, yPrec, zPrec int + refreshPending bool colorMode colors.ColorBlindMode @@ -67,10 +150,24 @@ type Meshgrid struct { OnMouseDown func() } +// Marker colors match the mapviewer crosshair so the two tracking +// indicators read as the same thing. +var ( + cursorFillColor = color.RGBA{R: 165, G: 55, B: 253, A: 255} + cursorRingColor = color.RGBA{R: 255, G: 255, B: 255, A: 255} +) + +const cursorRadius = 6 + // NewMeshgrid creates a new Meshgrid given width, height, depth and spacing. -func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int, colorBlindMode colors.ColorBlindMode) (*Meshgrid, error) { +// xData/yData carry the per-column/row axis tick values (with xPrec/yPrec/zPrec +// display precision) used to draw the axis scales along the mesh edges; either +// may be nil to skip that axis' value labels. +func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int, xData, yData []float64, xPrec, yPrec, zPrec int, colorBlindMode colors.ColorBlindMode, backend RenderBackend) (*Meshgrid, error) { + cols = max(1, cols) + rows = max(1, rows) // Check if the provided values slice has the correct number of elements - if len(values) != max(1, cols)*max(1, rows) { + if len(values) != cols*rows { return nil, fmt.Errorf("the number of Z values does not match the meshgrid dimensions") } // Find min and max Z values for normalization @@ -98,17 +195,30 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int ylabel: ylabel, zlabel: zlabel, + xData: xData, + yData: yData, + xPrec: xPrec, + yPrec: yPrec, + zPrec: zPrec, + colorMode: colorBlindMode, + + backend: backend, } - m.createVertices(fyne.Max(float32(m.cols), 1), fyne.Max(float32(m.rows), 1)) + m.createVertices() m.scaleMeshgrid(0.3) if cols == 1 { m.rotateMeshgrid(0, 90, 0) } else { - m.rotateMeshgrid(60, 0, -30) + // T7Suite-style starting view: ~30° elevation (pitch 60° from + // top-down) with the mesh spun 35° around its vertical axis. Starting + // from identity, the RotZ term is a model-space turntable spin (the + // same composition orbit() applies), not a camera roll. + m.cameraRotation = RotationMatrixX(60).Multiply(m.cameraRotation).Multiply(RotationMatrixZ(-35)) + m.updateVertexPositions() } m.ExtendBaseWidget(m) @@ -118,17 +228,59 @@ func NewMeshgrid(xlabel, ylabel, zlabel string, values []float64, cols, rows int m.image.FillMode = canvas.ImageFillOriginal m.image.ScaleMode = canvas.ImageScaleFastest + m.cursor = &canvas.Circle{ + // Position2 sets the bounds directly; Resize would trigger a canvas + // refresh before the widget is even shown. + Position2: fyne.NewPos(cursorRadius*2, cursorRadius*2), + FillColor: cursorFillColor, + StrokeColor: cursorRingColor, + StrokeWidth: 2, + Hidden: true, + } + + m.initAxisObjects() + switch m.backend { + case BackendShader: + m.initShader() + case BackendPolygons: + m.initPolygons() + } + return m, nil } +// SetRenderMode switches between solid surface, solid+wireframe and pure +// wireframe rendering. +func (m *Meshgrid) SetRenderMode(mode RenderMode) { + if m.renderMode != mode { + m.renderMode = mode + m.refresh() + } +} + +func (m *Meshgrid) RenderMode() RenderMode { + return m.renderMode +} + +// CycleRenderMode steps to the next render mode (surface → solid → wireframe). +func (m *Meshgrid) CycleRenderMode() { + m.renderMode = (m.renderMode + 1) % renderModeCount + m.refresh() +} + func (m *Meshgrid) SetColorBlindMode(mode colors.ColorBlindMode) { if m.colorMode != mode { m.colorMode = mode + m.updateShaderColormap() } m.refresh() } -func (m *Meshgrid) createVertices(width, height float32) { +// createVertices builds the corner-vertex grid: one quad per table cell, so +// the mesh shows exactly cols x rows cells like the map above. The grid is +// (rows+1) x (cols+1); each corner takes the average of the 1-4 cell values +// touching it. +func (m *Meshgrid) createVertices() { // Guard against a zero range (e.g. all values identical / all zero) so we // produce a flat mesh at Z=0 instead of NaN from a div-by-zero. zrange := m.zrange @@ -136,16 +288,19 @@ func (m *Meshgrid) createVertices(width, height float32) { zrange = 1 } - vertices := make([][]Vertex, 0, m.rows) - valueIndex := 0 + vRows, vCols := m.rows+1, m.cols+1 + vertices := make([][]Vertex, 0, vRows) var sumX, sumY, sumZ float64 var count int - for i := m.rows; i > 0; i-- { - row := make([]Vertex, 0, m.cols) - for j := 0; j < m.cols; j++ { - x := -float64(width)*.5 + float64(j)*float64(m.cellWidth) - y := -float64(height)*.5 + float64(i)*float64(m.cellHeight) - z := ((m.values[valueIndex] - m.zmin) / zrange) * m.depth + for i := 0; i < vRows; i++ { + row := make([]Vertex, 0, vCols) + for j := 0; j < vCols; j++ { + value := m.cornerValue(i, j) + x := float64(j) * float64(m.cellWidth) + // Data row 0 is the bottom row in the map; keep it at the high-Y + // end of the mesh like before, so the orientation is unchanged. + y := float64(vRows-i) * float64(m.cellHeight) + z := ((value - m.zmin) / zrange) * m.depth row = append(row, Vertex{ Ox: x, Oy: y, @@ -153,12 +308,12 @@ func (m *Meshgrid) createVertices(width, height float32) { X: x, Y: y, Z: z, + V: value, }) sumX += x sumY += y sumZ += z count++ - valueIndex++ } vertices = append(vertices, row) } @@ -172,19 +327,151 @@ func (m *Meshgrid) createVertices(width, height float32) { } } +// cornerValue averages the cell values adjacent to corner (vi, vj): four in +// the interior, two along edges and one at the outer corners. +func (m *Meshgrid) cornerValue(vi, vj int) float64 { + r0 := max(vi-1, 0) + r1 := min(vi, m.rows-1) + c0 := max(vj-1, 0) + c1 := min(vj, m.cols-1) + + var sum float64 + var n int + for r := r0; r <= r1; r++ { + for c := c0; c <= c1; c++ { + sum += m.values[r*m.cols+c] + n++ + } + } + return sum / float64(n) +} + func (m *Meshgrid) scaleMeshgrid(factor float64) { m.scale = m.scale * factor m.updateVertexPositions() } -// orbit performs a Fusion 360-style "turntable" orbit. Yaw is applied around -// the world Y axis (right-multiplied so it rotates the world before the -// camera), pitch is applied around the camera-local X axis (left-multiplied). -// Composing the two this way prevents roll from sneaking in on diagonal drags. -func (m *Meshgrid) orbit(yawDelta, pitchDelta float64) { +// fitMargin leaves a border around the mesh so it isn't drawn flush to the +// widget edges (and so the axis indicator, which extends past the mesh, has +// some room). +const fitMargin = 0.85 + +// projectedBounds returns the min/max of the mesh's current projected +// (orthographic) screen positions. Both reflect the current scale, rotation +// and pan. +func (m *Meshgrid) projectedBounds() (minX, maxX, minY, maxY float64) { + minX, maxX = math.Inf(1), math.Inf(-1) + minY, maxY = math.Inf(1), math.Inf(-1) + for i := range m.vertices { + row := m.vertices[i] + for j := range row { + v := &row[j] + if v.X < minX { + minX = v.X + } + if v.X > maxX { + maxX = v.X + } + if v.Y < minY { + minY = v.Y + } + if v.Y > maxY { + maxY = v.Y + } + } + } + return +} + +// projectedExtent returns the width and height, in logical pixels, of the +// mesh's current projected bounding box. It reflects the current scale and +// rotation; panning shifts every vertex equally so the extent is +// pan-invariant. +func (m *Meshgrid) projectedExtent() (w, h float64) { + minX, maxX, minY, maxY := m.projectedBounds() + if math.IsInf(minX, 0) || math.IsInf(minY, 0) { + return 0, 0 + } + return maxX - minX, maxY - minY +} + +// centerInView pans the camera so the mesh's projected bounding box is +// centered in the widget. Used on the initial fit only; the camera offset it +// produces scales with the mesh on later resizes, so the centering (and any +// user pan applied on top) is preserved. +func (m *Meshgrid) centerInView() { + minX, maxX, minY, maxY := m.projectedBounds() + if math.IsInf(minX, 0) || math.IsInf(minY, 0) { + return + } + // v.{X,Y} = base - cam, so adding the current box center to the camera + // moves the box center to the view origin (which maps to screen center). + m.cameraPosition[0] += (minX + maxX) / 2 + m.cameraPosition[1] += (minY + maxY) / 2 + m.updateVertexPositions() +} + +// fitScaleForSize returns the m.scale value that makes the mesh's projected +// bounding box fill the given widget size (minus fitMargin) at the current +// rotation. The bounding box is linear in m.scale, so it is normalized to a +// unit scale first. +func (m *Meshgrid) fitScaleForSize(size fyne.Size) float64 { + w, h := m.projectedExtent() + if w <= 0 || h <= 0 || m.scale == 0 { + return m.scale + } + wPer := w / m.scale + hPer := h / m.scale + sx := float64(size.Width) * fitMargin / wPer + sy := float64(size.Height) * fitMargin / hPer + return math.Min(sx, sy) +} + +// adaptZoom keeps the mesh sized to the widget across layout changes. The +// first real layout fits the mesh to the view and centers it; later resizes +// scale the mesh (and the camera pan) by the ratio of the new fit-scale to +// the old. This grows and shrinks the mesh with the window while preserving +// whatever zoom and pan the user has applied — it adjusts the existing scale +// and pan multiplicatively rather than resetting them. +func (m *Meshgrid) adaptZoom(oldSize, newSize fyne.Size) { + if newSize.Width <= 0 || newSize.Height <= 0 { + return + } + if !m.fitted { + m.scale = m.fitScaleForSize(newSize) + m.fitted = true + m.updateVertexPositions() + m.centerInView() + return + } + if oldSize.Width <= 0 || oldSize.Height <= 0 { + return + } + oldFit := m.fitScaleForSize(oldSize) + if oldFit == 0 { + return + } + ratio := m.fitScaleForSize(newSize) / oldFit + if ratio <= 0 || math.IsInf(ratio, 0) || math.IsNaN(ratio) { + return + } + m.scale *= ratio + // Pan lives in the same scaled view space, so scale it alongside the mesh + // to keep the centering and any user pan in the same relative spot. + m.cameraPosition[0] *= ratio + m.cameraPosition[1] *= ratio + m.updateVertexPositions() +} + +// orbit performs a Fusion 360-style "turntable" orbit. Spin is applied around +// the mesh's own vertical axis — data Z, the height axis (right-multiplied so +// it rotates the model before the camera) — pitch around the camera-local X +// axis (left-multiplied). Composing the two this way prevents roll from +// sneaking in on diagonal drags. +func (m *Meshgrid) orbit(spinDelta, pitchDelta float64) { pitchRot := RotationMatrixX(pitchDelta) - yawRot := RotationMatrixY(yawDelta) - m.cameraRotation = pitchRot.Multiply(m.cameraRotation).Multiply(yawRot) + spinRot := RotationMatrixZ(spinDelta) + m.cameraRotation = pitchRot.Multiply(m.cameraRotation).Multiply(spinRot) m.updateVertexPositions() } @@ -230,42 +517,92 @@ func (m *Meshgrid) updateVertexPositions() { } } -func (m *Meshgrid) SetFloat64(idx int, value float64) { - log.Println("SetFloat64", idx, value) - m.values[idx] = value - m.zmin, m.zmax, m.zrange = findMinMaxRange(m.values) - zrange := m.zrange - if zrange == 0 { - zrange = 1 - } - m.vertices[idx/m.cols][idx%m.cols].Z = ((value - m.zmin) / zrange) * m.depth - m.refresh() +// projectOriginal maps a point in the mesh's original (untransformed) coordinate +// space to screen pixels, applying the same camera transform as +// updateVertexPositions and the same screen mapping as cursorScreenPosition / +// updatePolygons. It returns the screen x/y and the view-space depth (z), where +// a larger z is nearer the viewer. This lets the axis overlay project arbitrary +// box-edge points, not just stored vertices. +func (m *Meshgrid) projectOriginal(ox, oy, oz float64) (sx, sy, vz float32) { + vx := (ox - m.centerX) * m.scale + vy := (oy - m.centerY) * m.scale + vz3 := (oz - m.centerZ) * m.scale + r := m.cameraRotation + x := r[0][0]*vx + r[0][1]*vy + r[0][2]*vz3 - m.cameraPosition[0] + y := r[1][0]*vx + r[1][1]*vy + r[1][2]*vz3 - m.cameraPosition[1] + z := r[2][0]*vx + r[2][1]*vy + r[2][2]*vz3 - m.cameraPosition[2] + return float32(float64(m.size.Width)*0.5 + x), float32(float64(m.size.Height)*0.5 + y), float32(z) } -func (m *Meshgrid) SetFloat642(idx int, value float64) { +// SetFloat64 updates a single cell value. The whole mesh is rebuilt since a +// new value can shift zmin/zmax and with it every vertex's normalized height. +func (m *Meshgrid) SetFloat64(idx int, value float64) { + if idx < 0 || idx >= len(m.values) { + return + } m.values[idx] = value m.zmin, m.zmax, m.zrange = findMinMaxRange(m.values) - m.createVertices(fyne.Max(float32(m.cols), 1), fyne.Max(float32(m.rows), 1)) + m.createVertices() m.updateVertexPositions() + m.updateShaderData() m.refresh() } // Update LoadFloat64s to use the new vertex position update method func (m *Meshgrid) LoadFloat64s(min, max float64, floats []float64) { + if len(floats) != m.rows*m.cols { + log.Printf("meshgrid: LoadFloat64s got %d values, want %d (%dx%d)", len(floats), m.rows*m.cols, m.cols, m.rows) + return + } m.zmin = min m.zmax = max m.zrange = m.zmax - m.zmin m.values = floats - if len(floats) == 0 { - return - } - m.createVertices(fyne.Max(float32(m.cols), 1), fyne.Max(float32(m.rows), 1)) + m.createVertices() m.updateVertexPositions() + m.updateShaderData() m.refresh() } +// SetCursor positions the tracking marker at the (fractional) cell index, +// mirroring the crosshair in the map above. The marker rides on the mesh +// surface, interpolated between the four surrounding vertices. +func (m *Meshgrid) SetCursor(xIdx, yIdx float64) { + if xIdx < 0 { + xIdx = 0 + } else if max := float64(m.cols - 1); xIdx > max { + xIdx = max + } + if yIdx < 0 { + yIdx = 0 + } else if max := float64(m.rows - 1); yIdx > max { + yIdx = max + } + if m.showCursor && xIdx == m.cursorX && yIdx == m.cursorY { + return + } + m.cursorX = xIdx + m.cursorY = yIdx + m.showCursor = true + m.moveCursor() +} + +// moveCursor repositions the overlay marker on the projected surface point. +// Moving a canvas primitive only repaints the scene from cached textures, +// so cursor updates don't re-rasterize the mesh. +func (m *Meshgrid) moveCursor() { + if !m.showCursor { + return + } + px, py := m.cursorScreenPosition() + m.cursor.Move(fyne.NewPos(px-cursorRadius, py-cursorRadius)) + if m.cursor.Hidden { + m.cursor.Show() + } +} + // returns the min, max and range across the data func findMinMaxRange(values []float64) (float64, float64, float64) { minZ, maxZ := values[0], values[0] @@ -280,22 +617,33 @@ func findMinMaxRange(values []float64) (float64, float64, float64) { return minZ, maxZ, maxZ - minZ } -func (m *Meshgrid) project(v Vertex) (int, int) { - centerX := float64(m.size.Width) * 0.5 - centerY := float64(m.size.Height) * 0.5 - screenX := centerX + v.X - screenY := centerY + v.Y - return int(screenX), int(screenY) -} - func (m *Meshgrid) Refresh() { m.refresh() } func (m *Meshgrid) refresh() { - m.image.Image = m.drawMeshgridLines() - m.image.Resize(m.size) - m.image.Refresh() + switch m.backend { + case BackendShader: + // All per-frame state lives in shader uniforms; the GPU re-renders + // from them on the next paint. Only the overlays move on the CPU. + m.updateShaderUniforms() + m.updateAxisObjects() + m.moveCursor() + canvas.Refresh(m) + case BackendPolygons: + // Geometry/color updates on the reusable polygons; the canvas.Refresh + // marks the scene dirty so color-only changes repaint too. + m.updatePolygons() + m.moveCursor() + canvas.Refresh(m) + default: + m.image.Image = m.drawMeshgridLines() + m.image.Resize(m.size) + m.image.Refresh() + // Rotation, scale, resize and data changes all move the projected + // surface point under the marker. + m.moveCursor() + } } func (m *Meshgrid) throttledRefresh() { @@ -304,24 +652,46 @@ func (m *Meshgrid) throttledRefresh() { } m.refreshPending = true time.AfterFunc(10*time.Millisecond, func() { // ~100fps - m.refresh() - m.refreshPending = false + // AfterFunc fires on a timer goroutine; hop back to the fyne thread. + // refreshPending is then only ever touched on the fyne thread. + fyne.Do(func() { + m.refresh() + m.refreshPending = false + }) }) } + func (m *Meshgrid) CreateRenderer() fyne.WidgetRenderer { - return &meshgridRenderer{m} + return &meshgridRenderer{MG: m} } type meshgridRenderer struct { - *Meshgrid + MG *Meshgrid + objects []fyne.CanvasObject } func (m *meshgridRenderer) Layout(size fyne.Size) { - if size == m.size { + if size == m.MG.size { return } - m.size = size - m.throttledRefresh() + // A collapsed split pane (toggling the mesh off) lays us out at zero height. + // Letting that become the baseline breaks adaptZoom's reversible scaling and + // the mesh creeps more zoomed-in on every toggle, so keep the last good size. + if size.Width <= 0 || size.Height <= 0 { + return + } + oldSize := m.MG.size + m.MG.size = size + // Auto-fit on the first real size and scale with the window thereafter, + // preserving any zoom the user has applied. + m.MG.adaptZoom(oldSize, size) + switch m.MG.backend { + case BackendShader: + m.MG.shader.Resize(size) + case BackendImage: + m.MG.image.Resize(size) + } + m.MG.throttledRefresh() } func (m *meshgridRenderer) MinSize() fyne.Size { @@ -329,12 +699,38 @@ func (m *meshgridRenderer) MinSize() fyne.Size { } func (m *meshgridRenderer) Refresh() { - m.Meshgrid.refresh() + m.MG.refresh() } func (m *meshgridRenderer) Destroy() { } func (m *meshgridRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{m.image} + switch m.MG.backend { + case BackendShader: + if m.objects == nil { + m.MG.updateAxisObjects() + objs := []fyne.CanvasObject{m.MG.shader} + for _, l := range m.MG.axisLinePool { + objs = append(objs, l) + } + for _, t := range m.MG.axisTextPool { + objs = append(objs, t) + } + m.objects = append(objs, m.MG.cursor) + } + return m.objects + case BackendPolygons: + // updatePolygons rebuilds the list in painter's order every frame; + // populate it here for the first paint. + if len(m.MG.polyObjects) == 0 { + m.MG.updatePolygons() + } + return m.MG.polyObjects + default: + if m.objects == nil { + m.objects = []fyne.CanvasObject{m.MG.image, m.MG.cursor} + } + return m.objects + } } diff --git a/pkg/widgets/msglist/msglist.go b/pkg/widgets/msglist/msglist.go index 658cd7df..befdeccb 100644 --- a/pkg/widgets/msglist/msglist.go +++ b/pkg/widgets/msglist/msglist.go @@ -2,7 +2,6 @@ package msglist import ( "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/data/binding" "fyne.io/fyne/v2/widget" ) @@ -12,73 +11,50 @@ var _ fyne.Widget = (*MsgList)(nil) type MsgList struct { widget.BaseWidget msgs binding.StringList - output *widget.List + list *widget.List listener binding.DataListener } func New(data binding.StringList) *MsgList { - m := &MsgList{ - msgs: data, - } + m := &MsgList{msgs: data} m.ExtendBaseWidget(m) - m.output = widget.NewListWithData( - m.msgs, + m.list = widget.NewListWithData( + data, func() fyne.CanvasObject { - w := widget.NewLabel("") - w.Alignment = fyne.TextAlignLeading - w.Selectable = true - return w + l := widget.NewLabel("") + l.Selectable = true + return l }, func(item binding.DataItem, obj fyne.CanvasObject) { - i := item.(binding.String) - txt, err := i.Get() + txt, err := item.(binding.String).Get() if err != nil { - fyne.LogError("Failed to get string", err) + fyne.LogError("msglist: get string", err) return } obj.(*widget.Label).SetText(txt) }, ) - m.listener = binding.NewDataListener(func() { - m.output.ScrollToBottom() - }) + m.listener = binding.NewDataListener(m.list.ScrollToBottom) return m } func (m *MsgList) CreateRenderer() fyne.WidgetRenderer { m.msgs.AddListener(m.listener) - return &msgListRenderer{ - m: m, - container: container.NewScroll(m.output), - } + return &msgListRenderer{m: m, objects: []fyne.CanvasObject{m.list}} } var _ fyne.WidgetRenderer = (*msgListRenderer)(nil) type msgListRenderer struct { - m *MsgList - container *container.Scroll + m *MsgList + objects []fyne.CanvasObject } -func (r *msgListRenderer) MinSize() fyne.Size { - return fyne.NewSize(300, 100) -} - -func (r *msgListRenderer) Layout(size fyne.Size) { - r.container.Resize(size) -} - -func (r *msgListRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{r.container} -} - -func (r *msgListRenderer) Refresh() { - -} - -func (r *msgListRenderer) Destroy() { - r.m.msgs.RemoveListener(r.m.listener) -} +func (r *msgListRenderer) MinSize() fyne.Size { return fyne.NewSize(300, 200) } +func (r *msgListRenderer) Layout(size fyne.Size) { r.m.list.Resize(size) } +func (r *msgListRenderer) Objects() []fyne.CanvasObject { return r.objects } +func (r *msgListRenderer) Refresh() { r.m.list.Refresh() } +func (r *msgListRenderer) Destroy() { r.m.msgs.RemoveListener(r.m.listener) } diff --git a/pkg/widgets/multiwindow/arrange.go b/pkg/widgets/multiwindow/arrange.go index 9d2e1584..f43565f7 100644 --- a/pkg/widgets/multiwindow/arrange.go +++ b/pkg/widgets/multiwindow/arrange.go @@ -71,10 +71,13 @@ func (f *FloatingArranger) Layout(maxSize fyne.Size, confined bool, windows []*I return } - maxSteps := int(fyne.Min( + maxSteps := int(min( (maxSize.Width-initOffset)/20, (maxSize.Height-initOffset)/20, )) + if maxSteps < 1 { + maxSteps = 1 // ponytail: guard i%maxSteps panic when viewport is tiny/unsized + } for i, window := range windows { step := i % maxSteps @@ -87,14 +90,14 @@ func (f *FloatingArranger) Layout(maxSize fyne.Size, confined bool, windows []*I // Clamp positions to keep windows within bounds maxX := maxSize.Width - window.MinSize().Width maxY := maxSize.Height - window.MinSize().Height - posX = fyne.Min(posX, maxX) - posY = fyne.Min(posY, maxY) + posX = min(posX, maxX) + posY = min(posY, maxY) } pos := fyne.NewPos(posX, posY) size := fyne.NewSize( - fyne.Max(maxSize.Width/2, window.MinSize().Width), - fyne.Max(maxSize.Height/2, window.MinSize().Height), + max(maxSize.Width/2, window.MinSize().Width), + max(maxSize.Height/2, window.MinSize().Height), ) f.setWindowState(window, pos, size, false) @@ -174,7 +177,7 @@ func (p *PackArranger) expandWindows(spaces []windowSpace, maxSize fyne.Size) { if node.pos.Y < otherNode.pos.Y+otherNode.size.Height && node.pos.Y+node.size.Height > otherNode.pos.Y { if otherNode.pos.X > node.pos.X { - maxWidth = fyne.Min(maxWidth, otherNode.pos.X-node.pos.X-padding) + maxWidth = min(maxWidth, otherNode.pos.X-node.pos.X-padding) } } @@ -182,14 +185,14 @@ func (p *PackArranger) expandWindows(spaces []windowSpace, maxSize fyne.Size) { if node.pos.X < otherNode.pos.X+otherNode.size.Width && node.pos.X+node.size.Width > otherNode.pos.X { if otherNode.pos.Y > node.pos.Y { - maxHeight = fyne.Min(maxHeight, otherNode.pos.Y-node.pos.Y-padding) + maxHeight = min(maxHeight, otherNode.pos.Y-node.pos.Y-padding) } } } // Ensure we don't exceed the container bounds - maxWidth = fyne.Min(maxWidth, maxSize.Width-node.pos.X-padding) - maxHeight = fyne.Min(maxHeight, maxSize.Height-node.pos.Y-padding) + maxWidth = min(maxWidth, maxSize.Width-node.pos.X-padding) + maxHeight = min(maxHeight, maxSize.Height-node.pos.Y-padding) // Calculate expanded size while maintaining aspect ratio minSize := window.MinSize() @@ -211,6 +214,9 @@ func (p *PackArranger) expandWindows(spaces []windowSpace, maxSize fyne.Size) { } func (p *PackArranger) findSpace(node *packNode, size fyne.Size) *packNode { + if node == nil { + return nil + } if node.used { if right := p.findSpace(node.right, size); right != nil { return right @@ -296,8 +302,8 @@ func (p *PreservingArranger) Layout(maxSize fyne.Size, confined bool, windows [] // Ensure window stays within bounds maxWidth := maxSize.Width - r.pos.X maxHeight := maxSize.Height - r.pos.Y - newSize.Width = fyne.Min(newSize.Width, maxWidth) - newSize.Height = fyne.Min(newSize.Height, maxHeight) + newSize.Width = min(newSize.Width, maxWidth) + newSize.Height = min(newSize.Height, maxHeight) } p.setWindowState(r.window, r.pos, newSize, false) } diff --git a/pkg/widgets/multiwindow/innerwindow.go b/pkg/widgets/multiwindow/innerwindow.go index be0b03ba..93c68414 100644 --- a/pkg/widgets/multiwindow/innerwindow.go +++ b/pkg/widgets/multiwindow/innerwindow.go @@ -30,6 +30,9 @@ const ( modeIcon ) +// minimizedWidth is the fixed width of a window collapsed into the bottom tray. +const minimizedWidth float32 = 200 + type resizeDirection int const ( @@ -64,7 +67,7 @@ type InnerWindow struct { Persist bool // Persist through layout changes IgnoreSave bool // Ignore saving to layout - //minBtn, maxBtn, closeBtn *borderButton + // minBtn, maxBtn, closeBtn *borderButton title string bg *canvas.Rectangle @@ -72,11 +75,15 @@ type InnerWindow struct { content *fyne.Container maximized bool + minimized bool active bool preMaximizedSize fyne.Size preMaximizedPos fyne.Position + preMinimizedSize fyne.Size + preMinimizedPos fyne.Position + onClose func() `json:"-"` } @@ -218,12 +225,12 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { if isLeading { // Left side (darwin default or explicit left alignment) buttons = container.NewHBox(close, min, max) - //bar = container.NewBorder(nil, nil, buttons, borderIcon, title) + // bar = container.NewBorder(nil, nil, buttons, borderIcon, title) bar = container.NewBorder(nil, nil, buttons, borderIcon, container.New(layout.NewCustomPaddedLayout(topPad, 0, 0, 0), title)) } else { // Right side (Windows/Linux default and explicit right alignment) buttons = container.NewHBox(min, max, close) - //bar = container.NewBorder(nil, nil, borderIcon, buttons, title) + // bar = container.NewBorder(nil, nil, borderIcon, buttons, title) bar = container.NewBorder(nil, nil, borderIcon, buttons, container.New(layout.NewCustomPaddedLayout(topPad, 0, 0, 0), title)) } @@ -234,6 +241,7 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { var leftTopCorner, rightTopCorner, leftBottomCorner, rightBottomCorner *draggableCorner var topBorder, bottomBorder, leftBorder, rightBorder *draggableBorder + var borders []fyne.CanvasObject objects := []fyne.CanvasObject{w.bg, contentBG, bar, w.content} @@ -247,14 +255,15 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { leftBottomCorner = newDraggableCorner(w, resizeDownLeft) rightBottomCorner = newDraggableCorner(w, resizeDownRight) - // objects = append(objects, leftCorner, rightCorner) - objects = append(objects, topBorder, bottomBorder, leftBorder, rightBorder, leftTopCorner, rightTopCorner, leftBottomCorner, rightBottomCorner) + borders = []fyne.CanvasObject{topBorder, bottomBorder, leftBorder, rightBorder, leftTopCorner, rightTopCorner, leftBottomCorner, rightBottomCorner} + objects = append(objects, borders...) } r := &innerWindowRenderer{ ShadowingRenderer: NewShadowingRenderer(objects, SubmergedContentLevel), win: w, bar: bar, + title: title, buttons: []*borderButton{min, max, close}, bg: w.bg, topBorder: topBorder, @@ -265,7 +274,9 @@ func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer { rightTopCorner: rightTopCorner, leftBottomCorner: leftBottomCorner, rightBottomCorner: rightBottomCorner, - contentBG: contentBG} + borders: borders, + contentBG: contentBG, + } r.Layout(w.Size()) return r } @@ -299,6 +310,7 @@ var _ fyne.WidgetRenderer = (*innerWindowRenderer)(nil) type innerWindowRenderer struct { win *InnerWindow bar *fyne.Container + title *draggableLabel buttons []*borderButton bg, contentBG *canvas.Rectangle @@ -313,6 +325,8 @@ type innerWindowRenderer struct { leftBottomCorner fyne.CanvasObject rightBottomCorner fyne.CanvasObject + borders []fyne.CanvasObject // all border/corner handles, for show/hide + *ShadowingRenderer } @@ -334,6 +348,16 @@ func (i *innerWindowRenderer) Layout(size fyne.Size) { i.bar.Move(fyne.NewPos(padding, 0)) i.bar.Resize(fyne.NewSize(size.Width-doublePadd, barHeight)) + // When minimized only the title bar is shown (tray-style). + if i.win.minimized { + i.contentBG.Hide() + i.win.content.Hide() + i.setBordersVisible(false) + return + } + i.contentBG.Show() + i.win.content.Show() + // Layout main content area contentPos := fyne.NewPos(padding, barHeight) contentDimensions := fyne.NewSize(adjustedWidth, contentSize.Height-padding-barHeight) @@ -345,10 +369,21 @@ func (i *innerWindowRenderer) Layout(size fyne.Size) { // Layout corners if !i.win.DisableResize { + i.setBordersVisible(true) i.layoutCorners(size) } } +func (i *innerWindowRenderer) setBordersVisible(visible bool) { + for _, b := range i.borders { // empty when DisableResize + if visible { + b.Show() + } else { + b.Hide() + } + } +} + // Helper method to handle corner layout func (i *innerWindowRenderer) layoutCorners(size fyne.Size) { cornerSize := fyne.NewSize(10, 10) @@ -381,9 +416,12 @@ func (i *innerWindowRenderer) layoutCorners(size fyne.Size) { func (i *innerWindowRenderer) MinSize() fyne.Size { th := i.win.Theme() pad := th.Size(theme.SizeNamePadding) - contentMin := i.win.content.MinSize() barHeight := th.Size(theme.SizeNameWindowTitleBarHeight) - innerWidth := fyne.Max(i.bar.MinSize().Width, contentMin.Width) + if i.win.minimized { + return fyne.NewSize(minimizedWidth, barHeight+pad) + } + contentMin := i.win.content.MinSize() + innerWidth := max(i.bar.MinSize().Width, contentMin.Width) return fyne.NewSize(innerWidth+pad*2, contentMin.Height+pad+barHeight) } @@ -399,14 +437,17 @@ func (i *innerWindowRenderer) Refresh() { b.setTheme(th, i.win.active) } i.bar.Refresh() - title := i.bar.Objects[0].(*fyne.Container).Objects[0].(*draggableLabel) - title.SetText(i.win.title) + if i.title.Text != i.win.title { + i.title.SetText(i.win.title) + } i.ShadowingRenderer.RefreshShadow() } -var _ desktop.Mouseable = (*draggableLabel)(nil) -var _ fyne.Draggable = (*draggableLabel)(nil) -var _ fyne.Focusable = (*draggableLabel)(nil) +var ( + _ desktop.Mouseable = (*draggableLabel)(nil) + _ fyne.Draggable = (*draggableLabel)(nil) + _ fyne.Focusable = (*draggableLabel)(nil) +) type draggableLabel struct { widget.Label @@ -595,7 +636,7 @@ func (b *buttonTheme) Size(n fyne.ThemeSizeName) float32 { //n = theme.SizeNameWindowButtonRadius return 4 case theme.SizeNameInlineIcon: - //n = theme.SizeNameWindowButtonIcon + // n = theme.SizeNameWindowButtonIcon return 20 } diff --git a/pkg/widgets/multiwindow/layout.go b/pkg/widgets/multiwindow/layout.go index ae63c655..f3fa8af7 100644 --- a/pkg/widgets/multiwindow/layout.go +++ b/pkg/widgets/multiwindow/layout.go @@ -3,24 +3,18 @@ package multiwindow import "fyne.io/fyne/v2" type multiWinLayout struct { + mw *MultipleWindows } func (m *multiWinLayout) Layout(objects []fyne.CanvasObject, _ fyne.Size) { for _, w := range objects { // update the windows so they have real size w.Resize(w.MinSize().Max(w.Size())) } + if m.mw != nil { + m.mw.layoutTray() // keep minimized windows docked to the bottom on resize + } } func (m *multiWinLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { return fyne.Size{Width: 700, Height: 400} } - -func clamp32(value, min, max float32) float32 { - if value < min { - return min - } - if value > max { - return max - } - return value -} diff --git a/pkg/widgets/multiwindow/multiplewindows.go b/pkg/widgets/multiwindow/multiplewindows.go index 460300a6..109d97fd 100644 --- a/pkg/widgets/multiwindow/multiplewindows.go +++ b/pkg/widgets/multiwindow/multiplewindows.go @@ -2,6 +2,7 @@ package multiwindow import ( "encoding/json" + "errors" "time" "fyne.io/fyne/v2" @@ -9,6 +10,7 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" ) type WindowRatio struct { @@ -32,7 +34,8 @@ type MultipleWindows struct { windows []*InnerWindow - content *fyne.Container + content *fyne.Container + childBuf []fyne.CanvasObject // reused backing slice for content.Objects openOffset fyne.Position @@ -50,7 +53,7 @@ func NewMultipleWindows(wins ...*InnerWindow) *MultipleWindows { } func (m *MultipleWindows) CreateRenderer() fyne.WidgetRenderer { - m.content = container.New(&multiWinLayout{}) + m.content = container.New(&multiWinLayout{mw: m}) m.refreshChildren() return widget.NewSimpleRenderer(m.content) } @@ -75,11 +78,11 @@ func (m *MultipleWindows) Add(w *InnerWindow, startPosition ...fyne.Position) bo if m.LockViewport { size := w.MinSize() bounds := m.content.Size() - startPosition[0].X = clamp32(startPosition[0].X, 0, bounds.Width-size.Width) - startPosition[0].Y = clamp32(startPosition[0].Y, 0, bounds.Height-size.Height) - //bounds.Subtract(size).Max(startPosition[0]) + startPosition[0].X = common.Clamp(startPosition[0].X, 0, bounds.Width-size.Width) + startPosition[0].Y = common.Clamp(startPosition[0].Y, 0, bounds.Height-size.Height) + // bounds.Subtract(size).Max(startPosition[0]) } - //w.Move(startPosition[0].SubtractXY(w.MinSize().Width*0.5, 80)) + // w.Move(startPosition[0].SubtractXY(w.MinSize().Width*0.5, 80)) w.Move(startPosition[0]) } @@ -189,15 +192,39 @@ func (m *MultipleWindows) raise(w *InnerWindow) { m.refreshChildren() } +// layoutTray docks every minimized window into a row along the bottom edge, +// mimicking a taskbar/system tray. +func (m *MultipleWindows) layoutTray() { + if m.content == nil { + return + } + const margin float32 = 5 + x := margin + bottom := m.content.Size().Height + for _, w := range m.windows { + if !w.minimized { + continue + } + size := w.MinSize() + w.Resize(size) + w.Move(fyne.NewPos(x, bottom-size.Height-margin)) + x += size.Width + margin + } +} + func (m *MultipleWindows) refreshChildren() { if m.content == nil { return } - objs := make([]fyne.CanvasObject, len(m.windows)) + if cap(m.childBuf) < len(m.windows) { + m.childBuf = make([]fyne.CanvasObject, len(m.windows)) + } else { + m.childBuf = m.childBuf[:len(m.windows)] + } for i, w := range m.windows { - objs[i] = w + m.childBuf[i] = w } - m.content.Objects = objs + m.content.Objects = m.childBuf m.content.Refresh() } @@ -205,8 +232,13 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { w.OnDragged = func(ev *fyne.DragEvent) { if w.maximized { w.maximized = false - w.Move(ev.AbsolutePosition.SubtractXY(w.preMaximizedSize.Width*0.5, 78)) w.Resize(w.preMaximizedSize) + // Convert the cursor's canvas-relative position into a position + // relative to our container, so this works regardless of where the + // container sits on the canvas (e.g. below a toolbar/menu). + local := ev.AbsolutePosition.Subtract(fyne.CurrentApp().Driver().AbsolutePositionForObject(m.content)) + barHeight := w.Theme().Size(theme.SizeNameWindowTitleBarHeight) + w.Move(local.SubtractXY(w.preMaximizedSize.Width*0.5, barHeight*0.5)) return } @@ -214,8 +246,8 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { if m.LockViewport { size := w.Size() bounds := m.content.Size() - newPos.X = clamp32(newPos.X, 0, bounds.Width-size.Width) - newPos.Y = clamp32(newPos.Y, 0, bounds.Height-size.Height) + newPos.X = common.Clamp(newPos.X, 0, bounds.Width-size.Width) + newPos.Y = common.Clamp(newPos.Y, 0, bounds.Height-size.Height) } w.Move(newPos) } @@ -228,7 +260,7 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { case resizeUp: actualDY := ev.Dragged.DY if actualDY > 0 { - actualDY = fyne.Min(actualDY, currentSize.Height-minSize.Height) + actualDY = min(actualDY, currentSize.Height-minSize.Height) } else if w.Position().Y+actualDY < 0 { actualDY = -w.Position().Y } @@ -240,7 +272,7 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { actualDX := ev.Dragged.DX if actualDX > 0 { // When shrinking (dragging right), limit by remaining width - actualDX = fyne.Min(actualDX, currentSize.Width-minSize.Width) + actualDX = min(actualDX, currentSize.Width-minSize.Width) } else if w.Position().X+actualDX < 0 { // Prevent dragging past left edge actualDX = -w.Position().X @@ -252,14 +284,14 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { case resizeUpLeft: actualDY := ev.Dragged.DY if actualDY > 0 { - actualDY = fyne.Min(actualDY, currentSize.Height-minSize.Height) + actualDY = min(actualDY, currentSize.Height-minSize.Height) } else if w.Position().Y+actualDY < 0 { actualDY = -w.Position().Y } actualDX := ev.Dragged.DX if actualDX > 0 { // When shrinking (dragging right), limit by remaining width - actualDX = fyne.Min(actualDX, currentSize.Width-minSize.Width) + actualDX = min(actualDX, currentSize.Width-minSize.Width) } else if w.Position().X+actualDX < 0 { // Prevent dragging past left edge actualDX = -w.Position().X @@ -269,7 +301,7 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { case resizeUpRight: actualDY := ev.Dragged.DY if actualDY > 0 { - actualDY = fyne.Min(actualDY, currentSize.Height-minSize.Height) + actualDY = min(actualDY, currentSize.Height-minSize.Height) } else if w.Position().Y+actualDY < 0 { actualDY = -w.Position().Y } @@ -279,7 +311,7 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { actualDX := ev.Dragged.DX if actualDX > 0 { // When shrinking (dragging right), limit by remaining width - actualDX = fyne.Min(actualDX, currentSize.Width-minSize.Width) + actualDX = min(actualDX, currentSize.Width-minSize.Width) } else if w.Position().X+actualDX < 0 { // Prevent dragging past left edge actualDX = -w.Position().X @@ -296,8 +328,8 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { pos := w.Position() maxWidth := contentSize.Width - pos.X maxHeight := contentSize.Height - pos.Y - newSize.Width = fyne.Min(newSize.Width, maxWidth) - newSize.Height = fyne.Min(newSize.Height, maxHeight) + newSize.Width = min(newSize.Width, maxWidth) + newSize.Height = min(newSize.Height, maxHeight) } w.Resize(newSize.Max(minSize)) @@ -305,7 +337,23 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { } w.OnTappedBar = func() { - //m.Raise(w) + if w.minimized { + w.OnMinimized() // single click on a tray bar restores it + } + } + + w.OnMinimized = func() { + w.minimized = !w.minimized + if w.minimized { + w.preMinimizedSize = w.Size() + w.preMinimizedPos = w.Position() + } else { + w.Resize(w.preMinimizedSize) + w.Move(w.preMinimizedPos) + m.Raise(w) + } + w.Refresh() + m.layoutTray() } w.OnMouseDown = func() { @@ -313,12 +361,16 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { if c := fyne.CurrentApp().Driver().CanvasForObject(w); c != nil { c.Focus(f) } - //c.SetOnTypedKey(f.TypedKey) + // c.SetOnTypedKey(f.TypedKey) } m.Raise(w) } w.OnMaximized = func() { + if w.minimized { + w.OnMinimized() // restore from tray instead of maximizing + return + } if !w.maximized { w.preMaximizedSize = w.Size() w.preMaximizedPos = w.Position() @@ -354,6 +406,10 @@ func (m *MultipleWindows) setupChild(w *InnerWindow) { func (m *MultipleWindows) LoadLayout(windows []WindowProperties) error { viewportSize := m.Size() + if viewportSize.Width == 0 || viewportSize.Height == 0 { + // Viewport not laid out yet; positions/sizes would all collapse to zero. + return nil + } for _, h := range windows { for _, w := range m.windows { if w.Title() == h.Title { @@ -388,6 +444,10 @@ func (m *MultipleWindows) LoadLayout(windows []WindowProperties) error { func (wm *MultipleWindows) JsonLayout() ([]byte, error) { var history []WindowProperties viewportSize := wm.Size() + if viewportSize.Width == 0 || viewportSize.Height == 0 { + // Avoid Inf/NaN ratios (which json.Marshal rejects) when not yet sized. + return nil, errors.New("multiwindow: cannot save layout before viewport is sized") + } for _, w := range wm.windows { if w.IgnoreSave { @@ -395,6 +455,10 @@ func (wm *MultipleWindows) JsonLayout() ([]byte, error) { } pos := w.Position() size := w.Size() + if w.minimized { // save real geometry, not the tiny tray bar + pos = w.preMinimizedPos + size = w.preMinimizedSize + } preMaxPos := w.PreMaximizedPos() preMaxSize := w.PreMaximizedSize() diff --git a/pkg/widgets/newsettings/can.go b/pkg/widgets/newsettings/can.go deleted file mode 100644 index 11c9fa87..00000000 --- a/pkg/widgets/newsettings/can.go +++ /dev/null @@ -1,54 +0,0 @@ -package settings - -import ( - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" -) - -type LoggingSettingsWidget struct { - widget.BaseWidget - - container *fyne.Container -} - -func NewTest(minSize fyne.Size) *LoggingSettingsWidget { - t := &LoggingSettingsWidget{} - t.ExtendBaseWidget(t) - t.render() - return t -} - -func (t *LoggingSettingsWidget) render() { - t.container = container.NewStack() -} - -func (t *LoggingSettingsWidget) CreateRenderer() fyne.WidgetRenderer { - return &LoggingSettingsWidgetRenderer{ - t: t, - } -} - -type LoggingSettingsWidgetRenderer struct { - t *LoggingSettingsWidget -} - -func (tr *LoggingSettingsWidgetRenderer) Layout(space fyne.Size) { - tr.t.container.Resize(space) - // do stuff -} - -func (tr *LoggingSettingsWidgetRenderer) MinSize() fyne.Size { - return tr.t.container.MinSize() -} - -func (tr *LoggingSettingsWidgetRenderer) Refresh() { - -} - -func (tr *LoggingSettingsWidgetRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{tr.t.container} -} - -func (tr *LoggingSettingsWidgetRenderer) Destroy() { -} diff --git a/pkg/widgets/newsettings/settings.go b/pkg/widgets/newsettings/settings.go deleted file mode 100644 index 8420f40a..00000000 --- a/pkg/widgets/newsettings/settings.go +++ /dev/null @@ -1,24 +0,0 @@ -package settings - -import "fyne.io/fyne/v2" - -type SettingsWidget interface { -} - -type SettingsDefinition struct { - Name string - Description string - Type string -} - -type Settings struct { - app fyne.App - CAN fyne.Widget -} - -func NewSettings(app fyne.App) *Settings { - w := &Settings{ - app: app, - } - return w -} diff --git a/pkg/widgets/numericentry/numericentry.go b/pkg/widgets/numericentry/numericentry.go index 3e78d7e7..ddb879a8 100644 --- a/pkg/widgets/numericentry/numericentry.go +++ b/pkg/widgets/numericentry/numericentry.go @@ -11,9 +11,10 @@ type Widget struct { widget.Entry } -func New() *Widget { +func New(text string) *Widget { entry := &Widget{} entry.ExtendBaseWidget(entry) + entry.SetText(text) return entry } diff --git a/pkg/widgets/plotter/bresenham.go b/pkg/widgets/plotter/bresenham.go index 3404b207..5b17e51f 100644 --- a/pkg/widgets/plotter/bresenham.go +++ b/pkg/widgets/plotter/bresenham.go @@ -4,6 +4,8 @@ import ( "image" "image/color" "math" + + "github.com/roffe/txlogger/pkg/common" ) // BresenhamThick draws a line of given thickness directly into img.Pix, @@ -44,8 +46,8 @@ func BresenhamThick(img *image.RGBA, x1, y1, x2, y2 int, thickness int, col colo } func bresenhamCore(pix []uint8, stride, w, h, x1, y1, x2, y2 int, col color.RGBA) { - dx := abs(x2 - x1) - dy := abs(y2 - y1) + dx := common.Abs(x2 - x1) + dy := common.Abs(y2 - y1) steep := dy > dx if steep { @@ -57,8 +59,8 @@ func bresenhamCore(pix []uint8, stride, w, h, x1, y1, x2, y2 int, col color.RGBA y1, y2 = y2, y1 } - dx = abs(x2 - x1) - dy = abs(y2 - y1) + dx = common.Abs(x2 - x1) + dy = common.Abs(y2 - y1) err := dx / 2 y := y1 ystep := 1 @@ -91,6 +93,31 @@ func bresenhamCore(pix []uint8, stride, w, h, x1, y1, x2, y2 int, col color.RGBA } } +// fillVRun draws a vertical run of pixels in column x from y0 to y1 (inclusive, +// in either order), clipped to the image, with the same max-blend as +// bresenhamCore. +func fillVRun(pix []uint8, stride, w, h, x, y0, y1 int, col color.RGBA) { + if uint(x) >= uint(w) { + return + } + if y0 > y1 { + y0, y1 = y1, y0 + } + if y1 < 0 || y0 >= h { + return + } + y0 = max(y0, 0) + y1 = min(y1, h-1) + i := y0*stride + x*4 + for y := y0; y <= y1; y++ { + pix[i+0] = max(pix[i+0], col.R) + pix[i+1] = max(pix[i+1], col.G) + pix[i+2] = max(pix[i+2], col.B) + pix[i+3] = max(pix[i+3], col.A) + i += stride + } +} + func fillCircle(pix []uint8, stride, w, h, centerX, centerY, radius int, col color.RGBA) { rr := radius * radius for y := -radius; y <= radius; y++ { @@ -110,10 +137,3 @@ func fillCircle(pix []uint8, stride, w, h, centerX, centerY, radius int, col col } } } - -func abs(n int) int { - if n < 0 { - return -n - } - return n -} diff --git a/pkg/widgets/plotter/plotter.go b/pkg/widgets/plotter/plotter.go index 975e3a13..bf0ebf4c 100644 --- a/pkg/widgets/plotter/plotter.go +++ b/pkg/widgets/plotter/plotter.go @@ -6,6 +6,8 @@ import ( "image/color" "log" "sort" + "sync" + "sync/atomic" "unicode" "unicode/utf8" @@ -14,6 +16,7 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" ) // var _ fyne.Focusable = (*Plotter)(nil) @@ -23,13 +26,25 @@ var ( _ fyne.Widget = (*Plotter)(nil) ) -type PlotterControl interface { - Seek(int) -} +// plotBackend selects how the plot is drawn. The GPU shader is the default; +// TXLOGGER_PLOT_RENDERER=image selects the CPU rasterizer, which is also the +// automatic fallback when the log does not fit the shader's texture layout. +type PlotBackend int + +const ( + PlotBackendImage PlotBackend = iota + PlotBackendShader +) type Plotter struct { widget.BaseWidget + backend PlotBackend + shader *canvas.Shader + // plotObj is the canvas object showing the plot: shader on the GPU + // backend, canvasImage on the image backend. + plotObj fyne.CanvasObject + cursor *canvas.Line canvasImage *canvas.Image split *container.Split @@ -60,6 +75,12 @@ type Plotter struct { hilightLine int + // seekPos is the latest position requested via Seek, which is called from + // the playback goroutine; the pending UI refresh reads it back out. + seekMu sync.Mutex + seekPos int + refreshPending atomic.Bool + OnDragged func(event *fyne.DragEvent) OnTapped func(event *fyne.PointEvent) } @@ -84,6 +105,12 @@ func WithOrder(order []string) PlotterOpt { } } +func WithRenderer(renderer PlotBackend) PlotterOpt { + return func(p *Plotter) { + p.backend = renderer + } +} + func NewPlotter(values map[string][]float64, opts ...PlotterOpt) *Plotter { p := &Plotter{ values: values, @@ -174,24 +201,27 @@ func NewPlotter(values map[string][]float64, opts ...PlotterOpt) *Plotter { p.dataPointsToShow = min(p.dataLength, 250.0) + p.plotObj = p.canvasImage + if p.backend == PlotBackendShader && p.initShader() { + p.plotObj = p.shader + } + leading := container.NewBorder( nil, nil, p.zoom, nil, - container.New(&plotLayout{p: p}, p.canvasImage), + container.New(&plotLayout{p: p}, p.plotObj), ) p.split = container.NewHSplit( leading, container.NewBorder( nil, - container.NewGridWithColumns(1, - widget.NewButton("Toggle visible", func() { - for _, ts := range p.legendTexts { - ts.Tapped(&fyne.PointEvent{}) - } - }), - ), + widget.NewButton("Toggle visible", func() { + for _, ts := range p.legendTexts { + ts.Tapped(&fyne.PointEvent{}) + } + }), nil, nil, container.NewVScroll(p.legend), @@ -232,10 +262,38 @@ func lessCaseInsensitive(s, t string) bool { } func (p *Plotter) CreateRenderer() fyne.WidgetRenderer { - return &plotterRenderer{p} + return &plotterRenderer{PL: p} } +// Seek records the new playback position and schedules a redraw on the UI +// goroutine. Calls are coalesced: while a refresh is pending, further Seeks +// only overwrite seekPos and the pending refresh renders the latest position. +// This bounds drawing to the rate the UI goroutine can keep up with instead of +// the log record rate, so a fast log can never back up the event queue. func (p *Plotter) Seek(pos int) { + p.seekMu.Lock() + p.seekPos = pos + p.seekMu.Unlock() + + if !p.refreshPending.CompareAndSwap(false, true) { + return + } + fyne.Do(func() { + // Clear the flag before reading seekPos: a Seek arriving mid-draw then + // queues a new refresh rather than being dropped, so the final + // position is always rendered. + p.refreshPending.Store(false) + p.seekMu.Lock() + pos := p.seekPos + p.seekMu.Unlock() + p.seekTo(pos) + }) +} + +// seekTo applies a seek on the UI goroutine: it recomputes the view window, +// updates the legend values, redraws the plot and refreshes the changed +// canvas objects. +func (p *Plotter) seekTo(pos int) { halfDataPointsToShow := int(float64(p.dataPointsToShow) * .5) offsetPosition := pos - halfDataPointsToShow if pos <= p.dataLength-halfDataPointsToShow { @@ -246,10 +304,7 @@ func (p *Plotter) Seek(pos int) { } p.cursorPos = pos - // Update legend values, collecting the ones that actually changed so we - // can refresh them together in a single fyne.Do below. valueIndex := min(p.dataLength, p.cursorPos) - var changed []*TappableText for i, v := range p.valueOrder { obj := p.legendTexts[i] newValue := fmt.Sprintf("%.4g", p.values[v][valueIndex]) @@ -258,21 +313,21 @@ func (p *Plotter) Seek(pos int) { continue } obj.value.Text = newValue - changed = append(changed, obj) + obj.Refresh() } + if p.backend == PlotBackendShader { + // The view window moved; the GPU re-renders from two uniforms. + p.updateShaderView() + p.layoutCursor() + p.cursor.Refresh() + p.shader.Refresh() + return + } p.drawImage() p.layoutCursor() - - // Collapse all refreshes for this frame into one dispatch onto the main - // goroutine instead of one per changed legend item plus cursor plus image. - fyne.Do(func() { - for _, obj := range changed { - obj.Refresh() - } - p.cursor.Refresh() - p.canvasImage.Refresh() - }) + p.cursor.Refresh() + p.canvasImage.Refresh() } // drawImage renders the enabled time series into the (reused) plot buffer and @@ -312,6 +367,19 @@ func (p *Plotter) drawImage() { } func (p *Plotter) refreshImage(goroutine bool) { + if p.backend == PlotBackendShader { + // Legend toggles/recolors, hover and zoom all funnel through here; + // the metadata texture is 4xN so rebuilding it unconditionally is + // cheap, and the painter uploads it only because it is a new image. + p.updateShaderMeta() + p.updateShaderView() + if goroutine { + fyne.Do(p.shader.Refresh) + } else { + p.shader.Refresh() + } + return + } p.drawImage() if goroutine { fyne.Do(p.canvasImage.Refresh) @@ -327,6 +395,35 @@ type TimeSeries struct { valueRange float64 Color color.RGBA Enabled bool + // Auto reports that the series has no known display range and should be + // auto-ranged from its data by the caller (used by the live plotter). + Auto bool +} + +// defaultRange returns the fixed display range for the well-known symbols. ok is +// false for symbols without a preset range; the caller derives one from the data. +func defaultRange(name string) (min, max float64, ok bool) { + switch name { + case "Out.X_AccPedal", "Out.X_AccPos": + return 0, 100, true + case "ActualIn.T_Engine", "ActualIn.T_AirInlet": + return -20, 120, true + case "m_Request", "MAF.m_AirInlet", "AirMassMast.m_Request", "MAF.m_AirFromp_AirInlet": + return 0, 2200, true + case "ActualIn.p_AirInlet", "In.p_AirInlet", "ActualIn.p_AirBefThrottle", "In.p_AirBefThrottle": + return -1.0, 3.0, true + case "DisplProt.LambdaScanner", "Lambda.ADScanner", "LambdaScan.LambdaScanner", "LambdaScan.LambdaScanner2", "Lambda.External": + return 0.5, 1.5, true + case "IgnProt.fi_Offset": + return -30, 10, true + case "Lambda.LambdaInt": + return -25, 25, true + case "ECMStat.p_Diff": + return -1, 2, true + case "P_medel", "Max_tryck", "Regl_tryck": + return -1, 3, true + } + return 0, 0, false } func NewTimeSeries(name string, values map[string][]float64) *TimeSeries { @@ -342,39 +439,10 @@ func NewTimeSeries(name string, values map[string][]float64) *TimeSeries { return ts } - switch name { - case "Out.X_AccPedal", "Out.X_AccPos": - ts.Min = 0 - ts.Max = 100 - case "ActualIn.T_Engine", "ActualIn.T_AirInlet": - ts.Min = -20 - ts.Max = 120 - case "m_Request", "MAF.m_AirInlet", "AirMassMast.m_Request", "MAF.m_AirFromp_AirInlet": - ts.Min = 0 - ts.Max = 2200 - case "ActualIn.p_AirInlet", "In.p_AirInlet", "ActualIn.p_AirBefThrottle", "In.p_AirBefThrottle": - ts.Min = -1.0 - ts.Max = 3.0 - case "DisplProt.LambdaScanner", "Lambda.ADScanner", "LambdaScan.LambdaScanner", "LambdaScan.LambdaScanner2": - ts.Min = 0.5 - ts.Max = 1.5 - case "IgnProt.fi_Offset": - ts.Min = -30 - ts.Max = 10 - case "Lambda.LambdaInt": - ts.Min = -25 - ts.Max = 25 - case "ECMStat.p_Diff": - ts.Min = -1 - ts.Max = 2 - case "Lambda.External": - ts.Min = 0.5 - ts.Max = 1.5 - case "P_medel", "Max_tryck", "Regl_tryck": - ts.Min = -1 - ts.Max = 3 - default: - ts.Min, ts.Max = findMinMaxFloat64(data) + if min, max, known := defaultRange(name); known { + ts.Min, ts.Max = min, max + } else { + ts.Min, ts.Max = common.FindMinMaxFloat64(data) } ts.valueRange = ts.Max - ts.Min @@ -382,6 +450,32 @@ func NewTimeSeries(name string, values map[string][]float64) *TimeSeries { return ts } +// NewSeries builds a series with no data yet, for live plotting. Well-known +// symbols get their fixed display range; the rest are flagged Auto so the +// caller can range them from the live data via SetRange. +func NewSeries(name string) *TimeSeries { + ts := &TimeSeries{ + Name: name, + Color: colors.GetColor(name), + Enabled: true, + } + if min, max, known := defaultRange(name); known { + ts.SetRange(min, max) + } else { + ts.Auto = true + ts.SetRange(0, 1) + } + return ts +} + +// SetRange updates the display range used by PlotImage. valueRange is kept in +// sync so callers outside this package can re-range a series (e.g. auto-ranging +// a live signal each refresh). +func (ts *TimeSeries) SetRange(min, max float64) { + ts.Min, ts.Max = min, max + ts.valueRange = max - min +} + func (ts *TimeSeries) PlotImage(img *image.RGBA, values map[string][]float64, start, numPoints, thickness int) { dl := len(values[ts.Name]) - 1 startN, endN := min(max(start, 0), dl), min(start+numPoints, dl) @@ -395,10 +489,16 @@ func (ts *TimeSeries) PlotImage(img *image.RGBA, values map[string][]float64, st heightFactor := float64(hh) / ts.valueRange widthFactor := float64(w) / float64(dataLen) - // start at 1 since we need to draw a line from the previous point data := values[ts.Name][startN:endN] + + if dataLen > w { + ts.plotImageDecimated(img, data, thickness) + return + } + dle := dataLen - 1 + // start at 1 since we need to draw a line from the previous point for x := 1; x < dataLen; x++ { fx := float64(x) x0 := int(((fx - 1) * widthFactor)) @@ -412,12 +512,56 @@ func (ts *TimeSeries) PlotImage(img *image.RGBA, values map[string][]float64, st } } +// plotImageDecimated renders the series when there are more visible points +// than pixel columns. Connecting consecutive points with Bresenham lines would +// overdraw each column once per point landing on it; instead each column gets +// a single vertical run spanning the min/max of its points, capping the work +// at O(width) regardless of how far out the view is zoomed. +func (ts *TimeSeries) plotImageDecimated(img *image.RGBA, data []float64, thickness int) { + pix := img.Pix + stride := img.Stride + s := img.Bounds().Size() + w := s.X + h := s.Y + hh := h - 1 + heightFactor := float64(hh) / ts.valueRange + dataLen := len(data) + + halfThick := 0 + if thickness > 1 { + halfThick = thickness / 2 + } + + lo := 0 + for x := 0; x < w; x++ { + hi := (x + 1) * dataLen / w + // Re-scan the previous column's last point so adjacent runs always + // overlap vertically and the plot stays gap-free. + scanFrom := max(lo-1, 0) + minV, maxV := data[scanFrom], data[scanFrom] + for _, v := range data[scanFrom+1 : hi] { + if v < minV { + minV = v + } + if v > maxV { + maxV = v + } + } + y0 := int(float64(hh) - (maxV-ts.Min)*heightFactor) + y1 := int(float64(hh) - (minV-ts.Min)*heightFactor) + for t := -halfThick; t <= halfThick; t++ { + fillVRun(pix, stride, w, h, x+t, y0-halfThick, y1+halfThick, ts.Color) + } + lo = hi + } +} + // layoutCursor recomputes the cursor line position for the current view. It // does not trigger a Refresh. func (p *Plotter) layoutCursor() { var x float32 halfDataPointsToShow := int(float64(p.dataPointsToShow) * .5) - plotSize := p.canvasImage.Size() + plotSize := p.plotObj.Size() if p.cursorPos >= p.dataLength-halfDataPointsToShow { // Handle cursor position near the end of data @@ -430,8 +574,8 @@ func (p *Plotter) layoutCursor() { // Account for zoom slider width and ensure cursor stays within plot bounds xOffset := p.zoom.Size().Width + x - xOffset = min32(xOffset, plotSize.Width+p.zoom.Size().Width) - xOffset = max32(xOffset, p.zoom.Size().Width) + xOffset = min(xOffset, plotSize.Width+p.zoom.Size().Width) + xOffset = max(xOffset, p.zoom.Size().Width) p.cursor.Position1 = fyne.NewPos(xOffset, 0) p.cursor.Position2 = fyne.NewPos(xOffset+1, plotSize.Height) @@ -446,18 +590,3 @@ func (p *Plotter) updateCursor(goroutine bool) { p.cursor.Refresh() } } - -// Helper functions -func min32(a, b float32) float32 { - if a < b { - return a - } - return b -} - -func max32(a, b float32) float32 { - if a > b { - return a - } - return b -} diff --git a/pkg/widgets/plotter/plotter_bench_test.go b/pkg/widgets/plotter/plotter_bench_test.go new file mode 100644 index 00000000..9c974aa3 --- /dev/null +++ b/pkg/widgets/plotter/plotter_bench_test.go @@ -0,0 +1,63 @@ +package plotter + +import ( + "image" + "image/color" + "math/rand" + "testing" +) + +func benchValues(numSeries, numPoints int) map[string][]float64 { + r := rand.New(rand.NewSource(1)) + values := make(map[string][]float64, numSeries) + for i := 0; i < numSeries; i++ { + name := string(rune('A'+i)) + "series" + data := make([]float64, numPoints) + v := 0.0 + for j := range data { + v += r.Float64()*2 - 1 + data[j] = v + } + values[name] = data + } + return values +} + +func benchPlot(b *testing.B, w, h, numSeries, pointsShown int) { + values := benchValues(numSeries, pointsShown+10) + img := image.NewRGBA(image.Rect(0, 0, w, h)) + series := make([]*TimeSeries, 0, numSeries) + for name := range values { + series = append(series, NewTimeSeries(name, values)) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + clear(img.Pix) + for _, ts := range series { + ts.PlotImage(img, values, 0, pointsShown, 1) + } + } +} + +// Default zoom: 250 points visible +func BenchmarkPlot_1080p_10series_250pts(b *testing.B) { benchPlot(b, 1920, 1080, 10, 250) } +func BenchmarkPlot_1080p_30series_250pts(b *testing.B) { benchPlot(b, 1920, 1080, 30, 250) } + +// Zoomed out: 10000 points visible (zoom slider at max = 25*400) +func BenchmarkPlot_1080p_10series_10kpts(b *testing.B) { benchPlot(b, 1920, 1080, 10, 10000) } +func BenchmarkPlot_1080p_30series_10kpts(b *testing.B) { benchPlot(b, 1920, 1080, 30, 10000) } + +func BenchmarkClearOnly_1080p(b *testing.B) { + img := image.NewRGBA(image.Rect(0, 0, 1920, 1080)) + for i := 0; i < b.N; i++ { + clear(img.Pix) + } +} + +func BenchmarkBresenhamSingleLine(b *testing.B) { + img := image.NewRGBA(image.Rect(0, 0, 1920, 1080)) + col := color.RGBA{255, 0, 0, 255} + for i := 0; i < b.N; i++ { + BresenhamThick(img, 0, 0, 1919, 1079, 1, col) + } +} diff --git a/pkg/widgets/plotter/plotter_mouse.go b/pkg/widgets/plotter/plotter_mouse.go index 89562091..6ab4c5d3 100644 --- a/pkg/widgets/plotter/plotter_mouse.go +++ b/pkg/widgets/plotter/plotter_mouse.go @@ -58,7 +58,7 @@ func (p *Plotter) onZoom(value float64) { if p.plotStartPos < 0 { p.plotStartPos = 0 } - p.widthFactor = p.canvasImage.Size().Width / float32(p.dataPointsToShow) + p.widthFactor = p.plotObj.Size().Width / float32(p.dataPointsToShow) p.updateCursor(false) p.refreshImage(false) } diff --git a/pkg/widgets/plotter/plotter_render.go b/pkg/widgets/plotter/plotter_render.go index d70a70e5..825932c4 100644 --- a/pkg/widgets/plotter/plotter_render.go +++ b/pkg/widgets/plotter/plotter_render.go @@ -5,20 +5,21 @@ import ( ) type plotterRenderer struct { - *Plotter + PL *Plotter + objects []fyne.CanvasObject } func (p *plotterRenderer) MinSize() fyne.Size { - return p.split.MinSize() + return p.PL.split.MinSize() } func (p *plotterRenderer) Layout(size fyne.Size) { - if p.size == size { + if p.PL.size == size { return } - p.size = size + p.PL.size = size - p.split.Resize(size) + p.PL.split.Resize(size) } func (p *plotterRenderer) Refresh() { @@ -28,10 +29,14 @@ func (p *plotterRenderer) Destroy() { } func (p *plotterRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{p.split, - p.overlayText, - p.cursor, + if p.objects == nil { + p.objects = []fyne.CanvasObject{ + p.PL.split, + p.PL.overlayText, + p.PL.cursor, + } } + return p.objects } type plotLayout struct { @@ -47,7 +52,7 @@ func (t *plotLayout) Layout(_ []fyne.CanvasObject, plotSize fyne.Size) { t.p.overlayText.Move(fyne.NewPos(t.p.zoom.Size().Width, 20)) - t.p.canvasImage.Resize(plotSize) // Calculate new plot dimensions + t.p.plotObj.Resize(plotSize) // Calculate new plot dimensions t.p.plotResolution = fyne.NewSize(plotSize.Width*t.p.plotResolutionFactor, plotSize.Height*t.p.plotResolutionFactor) // Update width factor based on the new size t.p.widthFactor = plotSize.Width / float32(t.p.dataPointsToShow) diff --git a/pkg/widgets/plotter/plotter_shader.go b/pkg/widgets/plotter/plotter_shader.go new file mode 100644 index 00000000..5b828140 --- /dev/null +++ b/pkg/widgets/plotter/plotter_shader.go @@ -0,0 +1,359 @@ +package plotter + +import ( + "fmt" + "image" + "image/color" + "log" + "sync/atomic" + + "fyne.io/fyne/v2/canvas" +) + +// GPU renderer: the whole plot is drawn by a single canvas.Shader. The log is +// immutable once loaded, so every sample is uploaded to the GPU exactly once +// (16-bit packed, one texel per sample) together with a small min/max +// decimation level; after that a playback seek, zoom or drag only updates a +// couple of float uniforms and the fragment shader re-renders the view. The +// CPU never rasterizes a plot frame again - compare drawImage, which redraws +// and re-uploads the full plot image on every coalesced Seek. +// +// Per pixel the shader walks the enabled series; when zoomed in it computes +// the anti-aliased distance to the polyline segments crossing the pixel's +// column, when zoomed out it min/maxes the samples covering the column (via +// the 16:1 min/max texture when even that is too many fetches) and draws the +// vertical run, mirroring plotImageDecimated. Series are combined with the +// same order-independent max-blend as bresenhamCore. +// +// Sample encoding: values are normalized to the series' display range +// [Min, Max] with one full range of headroom on each side - texel 0..1 spans +// [Min-range, Max+range] - so overshooting samples keep their slope off +// screen like the Bresenham clipping does instead of flattening at the plot +// edge. 16 bits give the on-screen third ~21800 steps, far below a pixel. + +// plotShaderSeq makes each widget's Shader.Name unique: the painter caches +// the compiled program and its textures per name, so two plotters sharing a +// name would evict each other's data textures every frame. +var plotShaderSeq atomic.Int64 + +const ( + // plotTexW caps the data-texture width; sample index s of series i lives + // at texel (s mod plotTexW, i*rowsPerSeries + s/plotTexW). + plotTexW = 4096 + // plotTexMaxH bounds texture heights to what every desktop GPU handles. + plotTexMaxH = 4096 + // plotMaxSeries matches MAX_SERIES in the shader source. + plotMaxSeries = 64 + // mmGroup is the min/max decimation factor, matching the shader's + // group-16 lookups. + mmGroup = 16 +) + +const plotShaderPreludeGL = "#version 110\n" + +const plotShaderPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const plotShaderBody = ` +#define MAX_SERIES 64 +#define MAX_SEG 4 +#define MAX_RAW 16 +#define MAX_GROUPS 24 + +uniform vec2 frame_size; +uniform vec4 rect_coords; + +uniform sampler2D data_tex; // one texel per sample, 16-bit value in RG +uniform sampler2D mm_tex; // min/max per 16 samples: RG=min, BA=max +uniform sampler2D meta_tex; // per series: x0 color, x1 enabled, x2 length + +uniform float series_count; +uniform float highlight; // hovered series index, -1 for none +uniform float plot_start; // first visible sample +uniform float points_shown; // visible sample count +uniform float tex_w; // texel columns in data_tex and mm_tex +uniform float rows_raw; // data_tex rows per series +uniform float rows_mm; // mm_tex rows per series +uniform float data_h; // data_tex height in rows +uniform float mm_h; // mm_tex height in rows +uniform float meta_h; // meta_tex height = series count +uniform float size_w; // widget size, logical px +uniform float size_h; + +const float BIG = 100000.0; + +float decode16(float hi, float lo) { + return (hi * 65280.0 + lo * 255.0) / 65535.0; +} + +vec4 meta_at(float x, float si) { + return texture2D(meta_tex, vec2((x + 0.5) / 4.0, (si + 0.5) / meta_h)); +} + +// normalized sample value; idx is clamped to the series +float sample_val(float si, float idx, float len) { + idx = clamp(idx, 0.0, len - 1.0); + float row = floor(idx / tex_w); + float colm = idx - row * tex_w; + vec2 uv = vec2((colm + 0.5) / tex_w, (si * rows_raw + row + 0.5) / data_h); + vec4 t = texture2D(data_tex, uv); + return decode16(t.r, t.g); +} + +// normalized (min, max) of sample group gidx +vec2 sample_mm(float si, float gidx, float glen) { + gidx = clamp(gidx, 0.0, glen - 1.0); + float row = floor(gidx / tex_w); + float colm = gidx - row * tex_w; + vec2 uv = vec2((colm + 0.5) / tex_w, (si * rows_mm + row + 0.5) / mm_h); + vec4 t = texture2D(mm_tex, uv); + return vec2(decode16(t.r, t.g), decode16(t.b, t.a)); +} + +// device y of a normalized value: texel range 0..1 spans one display range +// of headroom on each side of [Min, Max] +float val_y(float v, float h_dev) { + float frac = v * 3.0 - 1.0; + return (1.0 - frac) * (h_dev - 1.0); +} + +float seg_dist(vec2 p, vec2 a, vec2 b) { + vec2 e = b - a; + float ee = dot(e, e); + float h = ee > 0.000001 ? clamp(dot(p - a, e) / ee, 0.0, 1.0) : 0.0; + return distance(p, a + e * h); +} + +void main() { + float pix_scale = (rect_coords.y - rect_coords.x) / max(size_w, 1.0); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + float w_dev = rect_coords.y - rect_coords.x; + float h_dev = rect_coords.w - rect_coords.z; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > w_dev || p_dev.y > h_dev) { + discard; + } + + float ppd = points_shown / max(w_dev, 1.0); // samples per device px + float spos = plot_start + p_dev.x / w_dev * points_shown; // sample at this px + float aa = 0.6; + + vec3 acc = vec3(0.0); + float acc_a = 0.0; + + for (int i = 0; i < MAX_SERIES; i++) { + if (i >= int(series_count + 0.5)) { + break; + } + float si = float(i); + if (meta_at(1.0, si).r < 0.5) { + continue; // disabled via the legend + } + vec4 m2 = meta_at(2.0, si); + float len = m2.r * 16711680.0 + m2.g * 65280.0 + m2.b * 255.0; + if (len < 2.0) { + continue; + } + // hovered series renders at 4 logical px like PlotImage thickness 4 + float half_w = (abs(si - highlight) < 0.5 ? 2.0 : 0.5) * pix_scale; + + float mask = 0.0; + if (ppd <= 1.5) { + // zoomed in: true polyline, distance to the segments around + // this pixel's column + float i0 = floor(spos); + float dmin = BIG; + for (int k = -MAX_SEG; k < MAX_SEG; k++) { + float j = i0 + float(k); + float x0 = (j - plot_start) / points_shown * w_dev; + float x1 = (j + 1.0 - plot_start) / points_shown * w_dev; + float y0 = val_y(sample_val(si, j, len), h_dev); + float y1 = val_y(sample_val(si, j + 1.0, len), h_dev); + dmin = min(dmin, seg_dist(p_dev, vec2(x0, y0), vec2(x1, y1))); + } + mask = 1.0 - smoothstep(half_w - aa, half_w + aa, dmin); + } else { + // zoomed out: vertical min/max run per column, like + // plotImageDecimated (including the one-sample overlap into the + // previous column that keeps runs connected) + float s_a = spos - ppd * 0.5 - 1.0; + float s_b = spos + ppd * 0.5; + float lo = BIG; + float hi = -BIG; + if (ppd <= 14.0) { + for (int k = 0; k < MAX_RAW; k++) { + float idx = s_a + float(k); + if (idx > s_b) { + break; + } + float v = sample_val(si, idx, len); + lo = min(lo, v); + hi = max(hi, v); + } + } else { + float g0 = floor(s_a / 16.0); + float glen = ceil(len / 16.0); + for (int k = 0; k < MAX_GROUPS; k++) { + float g = g0 + float(k); + if (g * 16.0 > s_b) { + break; + } + vec2 mm = sample_mm(si, g, glen); + lo = min(lo, mm.x); + hi = max(hi, mm.y); + } + } + float d = max(val_y(hi, h_dev) - p_dev.y, p_dev.y - val_y(lo, h_dev)); + mask = 1.0 - smoothstep(half_w - aa, half_w + aa, d); + } + + // max blend, same as bresenhamCore, so overlap is order independent + vec4 col = meta_at(0.0, si); + acc = max(acc, col.rgb * mask * col.a); + acc_a = max(acc_a, mask * col.a); + } + + if (acc_a < 0.004) { + discard; + } + gl_FragColor = vec4(acc / acc_a, acc_a); +} +` + +// initShader builds the shader object and uploads the immutable log data. +// It reports false when the data does not fit the GPU layout (no series, too +// many series, or a log too long for the texture budget); the caller then +// stays on the image backend. +func (p *Plotter) initShader() bool { + log.Println("Init plotter shaders") + + if len(p.ts) == 0 || len(p.ts) > plotMaxSeries { + return false + } + + maxLen := 0 + for _, ts := range p.ts { + if ts == nil { + return false + } + maxLen = max(maxLen, len(p.values[ts.Name])) + } + if maxLen < 2 { + return false + } + + texW := min(maxLen, plotTexW) + rowsRaw := (maxLen + texW - 1) / texW + groups := (maxLen + mmGroup - 1) / mmGroup + rowsMM := (groups + texW - 1) / texW + if len(p.ts)*rowsRaw > plotTexMaxH || len(p.ts)*rowsMM > plotTexMaxH { + return false + } + + p.shader = canvas.NewShader( + fmt.Sprintf("plotter-%d", plotShaderSeq.Add(1)), + []byte(plotShaderPreludeGL+plotShaderBody), + []byte(plotShaderPreludeES+plotShaderBody), + ) + p.shader.Textures = make(map[string]image.Image, 3) + p.shader.Uniforms = make(map[string]float32, 16) + + data := image.NewRGBA(image.Rect(0, 0, texW, len(p.ts)*rowsRaw)) + mm := image.NewRGBA(image.Rect(0, 0, texW, len(p.ts)*rowsMM)) + for i, ts := range p.ts { + encodeSeries(data, mm, texW, i*rowsRaw, i*rowsMM, ts, p.values[ts.Name]) + } + p.shader.Textures["data_tex"] = data + p.shader.Textures["mm_tex"] = mm + + u := p.shader.Uniforms + u["series_count"] = float32(len(p.ts)) + u["tex_w"] = float32(texW) + u["rows_raw"] = float32(rowsRaw) + u["rows_mm"] = float32(rowsMM) + u["data_h"] = float32(len(p.ts) * rowsRaw) + u["mm_h"] = float32(len(p.ts) * rowsMM) + u["meta_h"] = float32(len(p.ts)) + + p.updateShaderMeta() + p.updateShaderView() + return true +} + +// encodeValue maps a sample to the 16-bit texel value: 0..1 spans +// [Min-range, Max+range] so overshoot keeps its slope (clamped a full plot +// height off screen). +func encodeValue(ts *TimeSeries, v float64) uint16 { + r := ts.valueRange + if r <= 0 { + r = 1 + } + norm := ((v-ts.Min)/r + 1) / 3 + norm = min(1, max(0, norm)) + return uint16(norm*65535 + 0.5) +} + +// encodeSeries packs one series' samples (and its 16:1 min/max groups) into +// the texture rows starting at rawRow/mmRow. +func encodeSeries(data, mm *image.RGBA, texW, rawRow, mmRow int, ts *TimeSeries, values []float64) { + for s, v := range values { + q := encodeValue(ts, v) + data.SetRGBA(s%texW, rawRow+s/texW, color.RGBA{R: uint8(q >> 8), G: uint8(q), A: 0xff}) + } + for g := 0; g*mmGroup < len(values); g++ { + end := min((g+1)*mmGroup, len(values)) + lo, hi := values[g*mmGroup], values[g*mmGroup] + for _, v := range values[g*mmGroup+1 : end] { + lo = min(lo, v) + hi = max(hi, v) + } + ql, qh := encodeValue(ts, lo), encodeValue(ts, hi) + mm.SetRGBA(g%texW, mmRow+g/texW, color.RGBA{ + R: uint8(ql >> 8), G: uint8(ql), + B: uint8(qh >> 8), A: uint8(qh), + }) + } +} + +// updateShaderMeta rebuilds the per-series metadata texture (color, enabled, +// length). Called when the legend toggles or recolors a series; a fresh image +// is allocated because the painter re-uploads only when the map entry points +// at a new image. +func (p *Plotter) updateShaderMeta() { + if p.shader == nil { + return + } + meta := image.NewRGBA(image.Rect(0, 0, 4, len(p.ts))) + for i, ts := range p.ts { + meta.SetRGBA(0, i, ts.Color) + var enabled uint8 + if ts.Enabled { + enabled = 0xff + } + meta.SetRGBA(1, i, color.RGBA{R: enabled, A: 0xff}) + n := len(p.values[ts.Name]) + meta.SetRGBA(2, i, color.RGBA{R: uint8(n >> 16), G: uint8(n >> 8), B: uint8(n), A: 0xff}) + } + p.shader.Textures["meta_tex"] = meta +} + +// updateShaderView pushes the view window and hover state; this is the whole +// per-frame CPU cost of a playback seek on the shader backend. +func (p *Plotter) updateShaderView() { + if p.shader == nil { + return + } + size := p.plotObj.Size() + u := p.shader.Uniforms + u["plot_start"] = float32(p.plotStartPos) + u["points_shown"] = float32(p.dataPointsToShow) + u["highlight"] = float32(p.hilightLine) + u["size_w"] = size.Width + u["size_h"] = size.Height +} diff --git a/pkg/widgets/plotter/plotter_shader_test.go b/pkg/widgets/plotter/plotter_shader_test.go new file mode 100644 index 00000000..649ec92a --- /dev/null +++ b/pkg/widgets/plotter/plotter_shader_test.go @@ -0,0 +1,175 @@ +package plotter + +import ( + "image" + "math" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func shaderPlotter(t testing.TB, numSeries, numPoints int) *Plotter { + t.Helper() + p := NewPlotter(benchValues(numSeries, numPoints)) + if p.backend != PlotBackendShader { + t.Fatal("shader backend not selected") + } + return p +} + +// decode16 mirrors the shader's texel decode. +func decode16(hi, lo uint8) float64 { + return (float64(hi)*256 + float64(lo)) / 65535 +} + +// Every sample must decode from the data texture to its display-normalized +// value: texel 0..1 spans [Min-range, Max+range]. +func TestPlotShaderDataEncoding(t *testing.T) { + p := shaderPlotter(t, 3, 5000) + + tex := p.shader.Textures["data_tex"].(*image.RGBA) + texW := int(p.shader.Uniforms["tex_w"]) + rowsRaw := int(p.shader.Uniforms["rows_raw"]) + if texW != 4096 || rowsRaw != 2 { + t.Fatalf("layout texW=%d rowsRaw=%d, want 4096/2", texW, rowsRaw) + } + + for i, ts := range p.ts { + data := p.values[ts.Name] + for s, v := range data { + c := tex.RGBAAt(s%texW, i*rowsRaw+s/texW) + got := decode16(c.R, c.G) + want := ((v-ts.Min)/ts.valueRange + 1) / 3 + if math.Abs(got-want) > 1.0/65535 { + t.Fatalf("series %d sample %d: %v, want %v", i, s, got, want) + } + } + } +} + +// The min/max texture must hold the exact extremes of each 16-sample group. +func TestPlotShaderMinMax(t *testing.T) { + p := shaderPlotter(t, 2, 5000) + + mm := p.shader.Textures["mm_tex"].(*image.RGBA) + texW := int(p.shader.Uniforms["tex_w"]) + rowsMM := int(p.shader.Uniforms["rows_mm"]) + + for i, ts := range p.ts { + data := p.values[ts.Name] + for g := 0; g*mmGroup < len(data); g++ { + end := min((g+1)*mmGroup, len(data)) + lo, hi := data[g*mmGroup], data[g*mmGroup] + for _, v := range data[g*mmGroup+1 : end] { + lo = min(lo, v) + hi = max(hi, v) + } + c := mm.RGBAAt(g%texW, i*rowsMM+g/texW) + wantLo := ((lo-ts.Min)/ts.valueRange + 1) / 3 + wantHi := ((hi-ts.Min)/ts.valueRange + 1) / 3 + if math.Abs(decode16(c.R, c.G)-wantLo) > 1.0/65535 || math.Abs(decode16(c.B, c.A)-wantHi) > 1.0/65535 { + t.Fatalf("series %d group %d: (%v,%v), want (%v,%v)", + i, g, decode16(c.R, c.G), decode16(c.B, c.A), wantLo, wantHi) + } + } + } +} + +// Out-of-range samples must clamp a full display range off screen, not at +// the plot edge, so overshooting lines keep their slope like the Bresenham +// clipping does. +func TestPlotShaderEncodeHeadroom(t *testing.T) { + ts := &TimeSeries{Min: 0, Max: 10, valueRange: 10} + for _, tc := range []struct { + v float64 + want float64 + }{ + {0, 1.0 / 3}, // Min -> bottom of display band + {10, 2.0 / 3}, // Max -> top of display band + {-10, 0}, // one range below -> texel floor + {20, 1}, // one range above -> texel ceil + {-100, 0}, {99, 1}, // far overshoot clamps + } { + got := float64(encodeValue(ts, tc.v)) / 65535 + if math.Abs(got-tc.want) > 1.0/65535 { + t.Fatalf("encode(%v) = %v, want %v", tc.v, got, tc.want) + } + } +} + +// The metadata texture carries color, enabled flag and series length, and a +// legend toggle must produce a fresh image holding the new state. +func TestPlotShaderMeta(t *testing.T) { + p := shaderPlotter(t, 2, 100) + + meta := p.shader.Textures["meta_tex"].(*image.RGBA) + for i, ts := range p.ts { + if c := meta.RGBAAt(0, i); c != ts.Color { + t.Fatalf("series %d color %v, want %v", i, c, ts.Color) + } + if c := meta.RGBAAt(1, i); c.R != 0xff { + t.Fatalf("series %d not flagged enabled", i) + } + n := len(p.values[ts.Name]) + c := meta.RGBAAt(2, i) + if got := int(c.R)<<16 | int(c.G)<<8 | int(c.B); got != n { + t.Fatalf("series %d length %d, want %d", i, got, n) + } + } + + p.ts[1].Enabled = false + p.updateShaderMeta() + meta2 := p.shader.Textures["meta_tex"].(*image.RGBA) + if meta2 == meta { + t.Fatal("meta texture not replaced; painter would not re-upload") + } + if c := meta2.RGBAAt(1, 1); c.R != 0 { + t.Fatal("disabled series still flagged enabled") + } +} + +// Logs that exceed the texture budget must fall back to the image backend. +func TestPlotShaderFallback(t *testing.T) { + p := NewPlotter(benchValues(2, plotTexW*plotTexMaxH/2+1)) + if p.backend != PlotBackendImage { + t.Fatal("oversized log did not fall back to the image backend") + } + if p.plotObj != p.canvasImage { + t.Fatal("fallback must keep drawing through canvasImage") + } +} + +// Both shader variants must at least compile; glslangValidator checks them +// against the GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestPlotShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": plotShaderPreludeGL + plotShaderBody, + "es.frag": plotShaderPreludeES + plotShaderBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} + +// CPU-side cost of a playback seek on the shader backend; compare against +// the BenchmarkPlot_* figures for the image backend (which additionally +// re-uploads the whole plot texture every frame). +func BenchmarkUpdateShaderView(b *testing.B) { + p := shaderPlotter(b, 30, 10000) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + p.plotStartPos = i % 1000 + p.updateShaderView() + } +} diff --git a/pkg/widgets/plotter/text.go b/pkg/widgets/plotter/text.go index eec1207e..3029587b 100644 --- a/pkg/widgets/plotter/text.go +++ b/pkg/widgets/plotter/text.go @@ -74,6 +74,17 @@ func (tt *TappableText) Refresh() { tt.text.Refresh() } +// Value returns the currently displayed value text. +func (tt *TappableText) Value() string { + return tt.value.Text +} + +// SetValue updates the displayed value text and refreshes it. +func (tt *TappableText) SetValue(s string) { + tt.value.Text = s + tt.value.Refresh() +} + func (tt *TappableText) MouseIn(e *desktop.MouseEvent) { tt.onHover(true) } diff --git a/pkg/widgets/rescaler/rescaler.go b/pkg/widgets/rescaler/rescaler.go new file mode 100644 index 00000000..525c1281 --- /dev/null +++ b/pkg/widgets/rescaler/rescaler.go @@ -0,0 +1,186 @@ +// Package rescaler resamples a 2D map onto new axis support points while +// preserving the underlying surface — the local reimplementation of the +// Trionic Map Scaler (gray-plant-037f86003.3.azurestaticapps.net), which did +// the same thing through a server-side API and required pasting values by hand. +// Here the values are read straight from the loaded binary instead. +package rescaler + +import ( + "fmt" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/roffe/txlogger/pkg/interpolate" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" +) + +// Config describes one map to rescale. Axes and data are in engineering units +// (correction factor already applied). ZData is row-major: index = y*len(X)+x, +// matching MapViewer and interpolate.Interpolate64. +type Config struct { + Name string + XLabel, YLabel, ZLabel string + + XData, YData, ZData []float64 + + XPrecision, YPrecision, ZPrecision int + + // Apply writes the rescaled result back (e.g. to the binary symbols + disk). + // newX/newY have the same length as the originals; newZ keeps the same dims. + Apply func(newX, newY, newZ []float64) error +} + +// Rescale resamples z (defined on oldX × oldY, row-major row=Y col=X) onto the +// newX × newY grid with clamped bilinear interpolation. Points outside the old +// axis range clamp to the nearest edge — no extrapolation. newX/newY may hold +// different values than the originals but interpolate.Interpolate64 wants the +// same Z layout, so callers keep the breakpoint counts unchanged. +func Rescale(oldX, oldY, z, newX, newY []float64) []float64 { + out := make([]float64, len(newX)*len(newY)) + for yi, yv := range newY { + for xi, xv := range newX { + _, _, v, _ := interpolate.Interpolate64(oldX, oldY, z, xv, yv) + out[yi*len(newX)+xi] = v + } + } + return out +} + +// Rescaler is the editable axis + live preview widget. +type Rescaler struct { + widget.BaseWidget + cfg *Config + + xEntry, yEntry *widget.Entry + preview *fyne.Container + status *widget.Label + + newX, newY, newZ []float64 +} + +func New(cfg *Config) *Rescaler { + r := &Rescaler{ + cfg: cfg, + preview: container.NewStack(), + status: widget.NewLabel(""), + } + r.ExtendBaseWidget(r) + + r.xEntry = widget.NewEntry() + r.xEntry.SetText(floatsToText(cfg.XData, cfg.XPrecision)) + r.yEntry = widget.NewEntry() + r.yEntry.SetText(floatsToText(cfg.YData, cfg.YPrecision)) + + r.rescale() // identity preview on open + return r +} + +func (r *Rescaler) rescale() { + newX, err := parseAxis(r.xEntry.Text, len(r.cfg.XData)) + if err != nil { + r.status.SetText("X axis: " + err.Error()) + return + } + newY, err := parseAxis(r.yEntry.Text, len(r.cfg.YData)) + if err != nil { + r.status.SetText("Y axis: " + err.Error()) + return + } + + newZ := Rescale(r.cfg.XData, r.cfg.YData, r.cfg.ZData, newX, newY) + r.newX, r.newY, r.newZ = newX, newY, newZ + + mv, err := mapviewer.New(&mapviewer.Config{ + Name: r.cfg.Name, + XData: newX, + YData: newY, + ZData: newZ, + XPrecision: r.cfg.XPrecision, + YPrecision: r.cfg.YPrecision, + ZPrecision: r.cfg.ZPrecision, + XLabel: r.cfg.XLabel, + YLabel: r.cfg.YLabel, + ZLabel: r.cfg.ZLabel, + }) + if err != nil { + r.status.SetText(err.Error()) + return + } + r.preview.Objects = []fyne.CanvasObject{mv} + r.preview.Refresh() + r.status.SetText("Rescaled — review the preview, then Apply & Save") +} + +func (r *Rescaler) apply() { + if r.cfg.Apply == nil || r.newZ == nil { + return + } + dialog.ShowConfirm("Apply & Save", + fmt.Sprintf("Overwrite %s and its axes in the binary and save to disk?", r.cfg.Name), + func(ok bool) { + if !ok { + return + } + if err := r.cfg.Apply(r.newX, r.newY, r.newZ); err != nil { + r.status.SetText("Apply failed: " + err.Error()) + return + } + r.status.SetText("Applied and saved") + }, fyne.CurrentApp().Driver().AllWindows()[0]) +} + +func (r *Rescaler) CreateRenderer() fyne.WidgetRenderer { + rescaleBtn := widget.NewButtonWithIcon("Rescale", theme.ViewRefreshIcon(), r.rescale) + rescaleBtn.Importance = widget.HighImportance + applyBtn := widget.NewButtonWithIcon("Apply & Save", theme.DocumentSaveIcon(), r.apply) + + form := container.NewVBox( + widget.NewLabel(fmt.Sprintf("New X axis (%s) — %d points:", r.cfg.XLabel, len(r.cfg.XData))), + r.xEntry, + widget.NewLabel(fmt.Sprintf("New Y axis (%s) — %d points:", r.cfg.YLabel, len(r.cfg.YData))), + r.yEntry, + container.NewHBox(rescaleBtn, applyBtn), + r.status, + ) + + content := container.NewBorder(form, nil, nil, nil, r.preview) + return widget.NewSimpleRenderer(content) +} + +func floatsToText(vals []float64, prec int) string { + parts := make([]string, len(vals)) + for i, v := range vals { + parts[i] = strconv.FormatFloat(v, 'f', prec, 64) + } + return strings.Join(parts, ", ") +} + +// parseAxis parses comma/space/newline separated numbers, requiring exactly want +// strictly-ascending values (interpolate.Interpolate64 binary-searches the axis, +// and SetData rejects a changed table length). +func parseAxis(text string, want int) ([]float64, error) { + fields := strings.FieldsFunc(text, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + if len(fields) != want { + return nil, fmt.Errorf("need %d values, got %d", want, len(fields)) + } + out := make([]float64, len(fields)) + for i, f := range fields { + v, err := strconv.ParseFloat(f, 64) + if err != nil { + return nil, fmt.Errorf("%q is not a number", f) + } + if i > 0 && v <= out[i-1] { + return nil, fmt.Errorf("values must strictly ascend (%g after %g)", v, out[i-1]) + } + out[i] = v + } + return out, nil +} diff --git a/pkg/widgets/rescaler/rescaler_test.go b/pkg/widgets/rescaler/rescaler_test.go new file mode 100644 index 00000000..7e9cdc59 --- /dev/null +++ b/pkg/widgets/rescaler/rescaler_test.go @@ -0,0 +1,39 @@ +package rescaler + +import "testing" + +func eq(a, b []float64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestRescale(t *testing.T) { + // Surface z(x,y) = x + y on a 2x2 grid, row-major row=Y col=X. + oldX := []float64{0, 10} + oldY := []float64{0, 10} + z := []float64{0, 10, 10, 20} + + // Identity: same axes -> same data. + if got := Rescale(oldX, oldY, z, oldX, oldY); !eq(got, z) { + t.Fatalf("identity rescale changed data: %v", got) + } + + // Add a midpoint X breakpoint: linear surface => exact midpoints. + newX := []float64{0, 5, 10} + want := []float64{0, 5, 10, 10, 15, 20} + if got := Rescale(oldX, oldY, z, newX, oldY); !eq(got, want) { + t.Fatalf("midpoint rescale = %v, want %v", got, want) + } + + // Outside the old range clamps to the edge (no extrapolation). + if got := Rescale(oldX, oldY, z, []float64{-5, 0}, []float64{0, 0})[0]; got != 0 { + t.Fatalf("clamp below min = %v, want 0", got) + } +} diff --git a/pkg/widgets/settings/adapter.go b/pkg/widgets/settings/adapter.go new file mode 100644 index 00000000..4e738ddf --- /dev/null +++ b/pkg/widgets/settings/adapter.go @@ -0,0 +1,113 @@ +package settings + +import ( + "errors" + "strconv" + "strings" + + "github.com/roffe/gocan" + "github.com/roffe/txlogger/pkg/ota" +) + +func (sw *Widget) GetAdapter(ecuType string) (gocan.Adapter, error) { + return sw.GetAdapterWithExtraFilters(ecuType, nil) +} + +func (sw *Widget) GetAdapterWithExtraFilters(ecuType string, filters []uint32) (gocan.Adapter, error) { + baudrate, err := parseBaudrate(prefSpeed.getOr("")) + if err != nil { + return nil, err + } + + adapterName := prefAdapter.get() + if adapterName == "" { + return nil, errors.New("Select CANbus adapter in settings") //lint:ignore ST1005 This is ok + } + + port := prefPort.get() + if ad, found := sw.adapters[adapterName]; found && ad.RequiresSerialPort && port == "" && !ad.SerialPortOptional { + return nil, errors.New("Select port in setings") //lint:ignore ST1005 This is ok + } + + canFilter, canRate := canFilterAndRate(ecuType, adapterName, filters) + + cfg := &gocan.AdapterConfig{ + Port: port, + PortBaudrate: baudrate, + CANRate: canRate, + CANFilter: canFilter, + Debug: prefDebug.get(), + PrintVersion: true, + } + + if strings.HasPrefix(adapterName, "J2534") { + return gocan.NewGWClient(adapterName, cfg) + } + + if adapterName == "txbridge wifi" { + cfg.AdditionalConfig = map[string]string{ + "minversion": ota.MinimumtxbridgeVersion, + } + } + + return gocan.NewAdapter(adapterName, cfg) +} + +// parseBaudrate converts a stored port speed (which may use the "Nmbit" +// shorthand or be empty) into a numeric baudrate. +func parseBaudrate(speed string) (int, error) { + switch speed { + case "1mbit": + speed = "1000000" + case "2mbit": + speed = "2000000" + case "3mbit": + speed = "3000000" + case "": + speed = "1000000" + } + return strconv.Atoi(speed) +} + +// canFilterAndRate returns the CAN acceptance filter and bus rate for the given +// ECU type and adapter. extraFilters are appended for the Trionic 8 family. +func canFilterAndRate(ecuType, adapterName string, extraFilters []uint32) ([]uint32, float64) { + wblOnCAN := prefWblSource.get() == "CAN" + + switch ecuType { + case "T5", "Trionic 5": + return []uint32{0xC}, 615.384 + + case "T7", "Trionic 7": + var filter []uint32 + if isOBDAdapter(adapterName) || strings.HasSuffix(adapterName, "Wifi") { + filter = []uint32{0x238, 0x258, 0x270} + } else { + filter = []uint32{0x1A0, 0x238, 0x258, 0x270, 0x280, 0x3A0, 0x664, 0x665} + } + if wblOnCAN { + filter = append(filter, 0x180) + } + return filter, 500 + + case "T8", "Trionic 8", "Trionic 8 MCP", "Z22SE", "Z22SE MCP": + var filter []uint32 + if isOBDAdapter(adapterName) { + filter = []uint32{0x5E8, 0x7E8} + } else { + filter = []uint32{0x5E8, 0x7E8, 0x664, 0x665} + } + if wblOnCAN { + filter = append(filter, 0x180) + } + return append(filter, extraFilters...), 500 + } + + return nil, 0 +} + +func isOBDAdapter(name string) bool { + return strings.Contains(name, "ELM327") || + strings.Contains(name, "STN") || + strings.Contains(name, "OBDLink") +} diff --git a/pkg/widgets/settings/controls.go b/pkg/widgets/settings/controls.go new file mode 100644 index 00000000..d467c88a --- /dev/null +++ b/pkg/widgets/settings/controls.go @@ -0,0 +1,214 @@ +package settings + +import ( + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/ebus" + "github.com/roffe/txlogger/pkg/wbl/aem" + "github.com/roffe/txlogger/pkg/wbl/ecumaster" + "github.com/roffe/txlogger/pkg/wbl/innovate" + "github.com/roffe/txlogger/pkg/wbl/plx" + "github.com/roffe/txlogger/pkg/wbl/stag" + "github.com/roffe/txlogger/pkg/wbl/zeitronix" +) + +var ( + logFormats = []string{"CSV", "BPL" /*"TXL"*/} + portSpeeds = []string{"9600", "19200", "38400", "57600", "115200", "230400", "460800", "500000", "921600", "1mbit", "2mbit", "3mbit"} +) + +// --- generic control helpers ----------------------------------------------- + +// checkBox builds a checkbox bound to a boolean preference. +func checkBox(label string, p boolPref) *widget.Check { + return widget.NewCheck(label, p.set) +} + +// indexSelect builds a select whose chosen option index is stored in an +// integer preference (the option order defines the stored value). +func indexSelect(options []string, p intPref) *widget.Select { + sel := widget.NewSelect(options, nil) + sel.OnChanged = func(string) { + p.set(sel.SelectedIndex()) + } + return sel +} + +// setVisible shows or hides a group of canvas objects. +func setVisible(show bool, objs ...fyne.CanvasObject) { + for _, o := range objs { + if show { + o.Show() + } else { + o.Hide() + } + } +} + +// --- wideband source table ------------------------------------------------- + +// wblSource describes a selectable wideband source and the UI it enables. +type wblSource struct { + name string + image string // image resource key, "" for none + portSelect bool // show the WBL port selector + adScanner bool // show the AD scanner controls +} + +var wblSources = []wblSource{ + {name: "None"}, + {name: "ECU", image: "t7", adScanner: true}, // T7 image used as a placeholder + {name: aem.ProductString, image: "uego", portSelect: true}, + {name: "CombiAdapter", image: "combi", adScanner: true}, + {name: ecumaster.ProductString, image: "lambdatocan"}, + {name: innovate.ProductString, image: "mtx-l", portSelect: true}, + {name: plx.ProductString, image: "plx", portSelect: true}, + {name: stag.ProductString, image: "stagafr", portSelect: true}, + {name: zeitronix.ProductString, image: "zeitronix", portSelect: true}, +} + +func (sw *Widget) newWBLSelector() *fyne.Container { + names := make([]string, len(wblSources)) + byName := make(map[string]wblSource, len(wblSources)) + for i, s := range wblSources { + names[i] = s.name + byName[s.name] = s + } + + sw.wblSource = widget.NewSelect(names, func(s string) { + prefWblSource.set(s) + prefWidebandSymbolName.set(sw.GetWidebandSymbolName()) + + src := byName[s] + if src.image != "" { + if img := newImageFromResource(src.image); img != nil { + sw.wblImage.Resource = img.Resource + sw.wblImage.SetMinSize(img.MinSize()) + sw.wblImage.Refresh() + } + } + setVisible(src.portSelect, sw.wblPortLabel, sw.wblPortSelect, sw.wblPortRefreshButton) + setVisible(src.adScanner, sw.wblADscanner, sw.wblADScannerSymbol) + }) + + return container.NewBorder(nil, nil, widget.NewLabel("Source"), nil, sw.wblSource) +} + +// --- individual controls --------------------------------------------------- + +func (sw *Widget) newFreqSlider() *widget.Slider { + slider := widget.NewSlider(5, 300) + slider.Step = 5 + slider.OnChanged = func(f float64) { + sw.freqValue.SetText(strconv.FormatFloat(f, 'f', 0, 64)) + } + slider.OnChangeEnded = func(f float64) { + prefFreq.set(int(f)) + } + return slider +} + +func (sw *Widget) newLogFormat() *widget.Select { + return widget.NewSelect(logFormats, prefLogFormat.set) +} + +func (sw *Widget) newADscannerCheck() *widget.Check { + return widget.NewCheck("use AD Scanner (don't forget to add symbol)", func(b bool) { + setVisible(b, sw.wblADScannerSymbol) + prefUseADScanner.set(b) + }) +} + +func (sw *Widget) newColorBlindMode() *widget.Select { + return widget.NewSelect(colors.SupportedColorBlindModes[:], func(s string) { + prefColorBlindMode.set(s) + ebus.Publish(ebus.TOPIC_COLORBLINDMODE, float64(sw.colorBlindMode.SelectedIndex())) + }) +} + +func (sw *Widget) newAdapterSelector() *widget.Select { + return widget.NewSelect(nil, func(s string) { + info, found := sw.adapters[s] + if !found { + return + } + prefAdapter.set(s) + if info.RequiresSerialPort { + sw.portSelector.Enable() + sw.speedSelector.Enable() + return + } + sw.portDescription.SetText("") + sw.portSelector.Disable() + sw.speedSelector.Disable() + }) +} + +func (sw *Widget) newPortSelector() *widget.SelectEntry { + sel := widget.NewSelectEntry(sw.ListPorts()) + sel.OnChanged = func(s string) { + prefPort.set(s) + if itm, ok := portCache[s]; ok { + sw.portDescription.SetText(itm.SerialNumber) + } else { + sw.portDescription.SetText("") + } + } + return sel +} + +func (sw *Widget) newSpeedSelector() *widget.Select { + return widget.NewSelect(portSpeeds, prefSpeed.set) +} + +func (sw *Widget) newPortRefreshButton() *widget.Button { + return widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { + sw.portSelector.SetOptions(sw.ListPorts()) + sw.portSelector.Refresh() + }) +} + +// --- preference hydration -------------------------------------------------- + +// loadPreferences pushes persisted values into the controls once every widget +// has been constructed. +func (sw *Widget) loadPreferences() { + sw.freqSlider.SetValue(float64(prefFreq.get())) + sw.autoLoad.SetChecked(prefAutoLoad.get()) + sw.autoSave.SetChecked(prefAutoSave.get()) + sw.cursorFollowCrosshair.SetChecked(prefCursorFollowCrosshair.get()) + sw.livePreview.SetChecked(prefLivePreview.get()) + sw.meshView.SetChecked(prefMeshView.get()) + sw.realtimeBars.SetChecked(prefRealtimeBars.get()) + sw.logFormat.SetSelected(prefLogFormat.get()) + + logPath, err := common.GetLogPath() + if err != nil { + fyne.LogError("Could not get log path", err) + } + sw.logPath.SetText(prefLogPath.getOr(logPath)) + + sw.wblSource.SetSelected(prefWblSource.get()) + sw.wblADscanner.SetChecked(prefUseADScanner.get()) + setVisible(sw.wblADscanner.Checked && sw.wblSource.Selected == "ECU", sw.wblADScannerSymbol) + + sw.useMPH.SetChecked(prefUseMPH.get()) + sw.swapRPMandSpeed.SetChecked(prefSwapRPMandSpeed.get()) + sw.wblPortSelect.SetSelected(prefWBLPort.get()) + sw.colorBlindMode.SetSelected(prefColorBlindMode.get()) + + sw.adapterSelector.SetSelected(prefAdapter.get()) + sw.portSelector.SetText(prefPort.get()) + sw.speedSelector.SetSelected(prefSpeed.get()) + sw.debugCheckbox.SetChecked(prefDebug.get()) + + // Graphics + sw.plotRendererSelect.SetSelectedIndex(prefPlotterRenderer.get()) + sw.meshRendererSelect.SetSelectedIndex(prefMeshRenderer.get()) +} diff --git a/pkg/widgets/settings/getters.go b/pkg/widgets/settings/getters.go new file mode 100644 index 00000000..c73667ad --- /dev/null +++ b/pkg/widgets/settings/getters.go @@ -0,0 +1,106 @@ +package settings + +import ( + "log" + + "github.com/roffe/txlogger/pkg/colors" + "github.com/roffe/txlogger/pkg/common" + "github.com/roffe/txlogger/pkg/datalogger" + "github.com/roffe/txlogger/pkg/wbl/aem" + "github.com/roffe/txlogger/pkg/wbl/ecumaster" + "github.com/roffe/txlogger/pkg/wbl/innovate" + "github.com/roffe/txlogger/pkg/wbl/plx" + "github.com/roffe/txlogger/pkg/wbl/stag" + "github.com/roffe/txlogger/pkg/wbl/zeitronix" + "github.com/roffe/txlogger/pkg/widgets/meshgrid" + "github.com/roffe/txlogger/pkg/widgets/plotter" +) + +// General +func (sw *Widget) GetFreq() int { return prefFreq.get() } +func (sw *Widget) GetAutoSave() bool { return prefAutoSave.get() } +func (sw *Widget) GetAutoLoad() bool { return prefAutoLoad.get() } +func (sw *Widget) GetLivePreview() bool { return prefLivePreview.get() } +func (sw *Widget) GetRealtimeBars() bool { return prefRealtimeBars.get() } +func (sw *Widget) GetMeshView() bool { return prefMeshView.get() } +func (sw *Widget) GetCursorFollowCrosshair() bool { return prefCursorFollowCrosshair.get() } + +func (sw *Widget) GetColorBlindMode() colors.ColorBlindMode { + return colors.StringToColorBlindMode(prefColorBlindMode.get()) +} + +// Graphics +func (sw *Widget) GetPlotterRenderer() plotter.PlotBackend { + return plotter.PlotBackend(prefPlotterRenderer.get()) +} + +func (sw *Widget) GetMeshRenderer() meshgrid.RenderBackend { + return meshgrid.RenderBackend(prefMeshRenderer.get()) +} + +// Logging +func (sw *Widget) GetLogFormat() string { return prefLogFormat.get() } + +func (sw *Widget) GetLogPath() string { + if p := prefLogPath.get(); p != "" { + return p + } + p, err := common.GetLogPath() + if err != nil { + log.Println("GetLogPath: ", err) + } + return p +} + +// Dashboard +func (sw *Widget) GetUseMPH() bool { return prefUseMPH.get() } +func (sw *Widget) GetSwapRPMandSpeed() bool { return prefSwapRPMandSpeed.get() } + +// Wideband +func (sw *Widget) GetADScannerSymbolName() string { return prefWBLADScannerSymbol.get() } +func (sw *Widget) GetWidebandName() string { return prefWblSource.get() } +func (sw *Widget) GetWidebandPort() string { return prefWBLPort.get() } +func (sw *Widget) GetWBLSupportPoints() []int { return prefWBLSupportPoints.get() } +func (sw *Widget) GetWBLLambdaValues() []float64 { return prefWBLLambdaValues.get() } + +func (sw *Widget) GetUseADScanner() bool { + if prefWblSource.get() != "ECU" { + return false + } + return prefUseADScanner.get() +} + +// GetWidebandSymbolName resolves the symbol to log for the selected wideband +// source, taking the connected ECU and AD scanner mode into account. +func (sw *Widget) GetWidebandSymbolName() string { + switch sw.GetWidebandName() { + case "ECU": + useADScanner := prefUseADScanner.get() + switch sw.cfg.SelectedEcuFunc() { + case "T5": + return datalogger.LAMBDAADSCANNER + case "T7": + if useADScanner { + return datalogger.LAMBDAADSCANNER + } + return "DisplProt.LambdaScanner" + case "T8": + if useADScanner { + return datalogger.LAMBDAADSCANNER + } + return "LambdaScan.LambdaScanner" + default: + return "None" + } + case aem.ProductString, + "CombiAdapter", + ecumaster.ProductString, + innovate.ProductString, + plx.ProductString, + stag.ProductString, + zeitronix.ProductString: + return datalogger.EXTERNALWBLSYM + default: + return "None" + } +} diff --git a/pkg/widgets/settings/images.go b/pkg/widgets/settings/images.go new file mode 100644 index 00000000..2ed62f2d --- /dev/null +++ b/pkg/widgets/settings/images.go @@ -0,0 +1,39 @@ +package settings + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "github.com/roffe/txlogger/pkg/assets" +) + +// imageSpec describes a bundled wideband product image and its display size. +type imageSpec struct { + content []byte + w, h float32 +} + +var imageSpecs = map[string]imageSpec{ + "mtx-l": {assets.MtxL, 224, 224}, + "lc-2": {assets.Lc2, 400, 224}, + "uego": {assets.Uego, 315, 224}, + "lambdatocan": {assets.LambdaToCan, 481, 224}, + "t7": {assets.T7, 320, 224}, + "plx": {assets.PLX, 470, 224}, + "combi": {assets.CombiV2, 360, 245}, + "zeitronix": {assets.ZeitronixZT2, 252, 252}, + "stagafr": {assets.STAGAfr, 252, 252}, +} + +// newImageFromResource builds a contained, fast-scaling image for the named +// product. Returns nil if the name is unknown. +func newImageFromResource(name string) *canvas.Image { + spec, ok := imageSpecs[name] + if !ok { + return nil + } + img := canvas.NewImageFromResource(fyne.NewStaticResource(name, spec.content)) + img.SetMinSize(fyne.NewSize(spec.w, spec.h)) + img.FillMode = canvas.ImageFillContain + img.ScaleMode = canvas.ImageScaleFastest + return img +} diff --git a/pkg/widgets/settings/prefs.go b/pkg/widgets/settings/prefs.go new file mode 100644 index 00000000..5b996fb9 --- /dev/null +++ b/pkg/widgets/settings/prefs.go @@ -0,0 +1,110 @@ +package settings + +import "fyne.io/fyne/v2" + +// prefs returns the application preference store. Every setting in this package +// is read and written through the typed descriptors below so that storage keys +// and default values live in exactly one place. +func prefs() fyne.Preferences { + return fyne.CurrentApp().Preferences() +} + +// Exported preference keys read directly by other packages (flash settings). +const ( + PrefsNvdm = "nvdm" + PrefsBoot = "boot" +) + +// --- typed preference descriptors ------------------------------------------ +// +// Each descriptor bundles a storage key with its default value and exposes +// get/set helpers, so adding a new setting is a single declaration. + +type boolPref struct { + key string + def bool +} + +func (p boolPref) get() bool { return prefs().BoolWithFallback(p.key, p.def) } +func (p boolPref) set(v bool) { prefs().SetBool(p.key, v) } + +type stringPref struct { + key string + def string +} + +func (p stringPref) get() string { return prefs().StringWithFallback(p.key, p.def) } +func (p stringPref) set(v string) { prefs().SetString(p.key, v) } + +// getOr reads the value but uses the supplied fallback instead of the +// descriptor's default (used when the default must be computed at runtime). +func (p stringPref) getOr(fallback string) string { + return prefs().StringWithFallback(p.key, fallback) +} + +type intPref struct { + key string + def int +} + +func (p intPref) get() int { return prefs().IntWithFallback(p.key, p.def) } +func (p intPref) set(v int) { prefs().SetInt(p.key, v) } + +type intListPref struct { + key string + def []int +} + +func (p intListPref) get() []int { return prefs().IntListWithFallback(p.key, p.def) } +func (p intListPref) set(v []int) { prefs().SetIntList(p.key, v) } + +type floatListPref struct { + key string + def []float64 +} + +func (p floatListPref) get() []float64 { return prefs().FloatListWithFallback(p.key, p.def) } +func (p floatListPref) set(v []float64) { prefs().SetFloatList(p.key, v) } + +// --- preference declarations ----------------------------------------------- + +var ( + // General + prefFreq = intPref{"freq", 25} + prefAutoLoad = boolPref{"autoUpdateLoadEcu", true} + prefAutoSave = boolPref{"autoUpdateSaveEcu", false} + prefCursorFollowCrosshair = boolPref{"cursorFollowCrosshair", false} + prefLivePreview = boolPref{"livePreview", true} + prefMeshView = boolPref{"liveMeshView", true} + prefRealtimeBars = boolPref{"realtimeBars", true} + prefColorBlindMode = stringPref{"colorBlindMode", "Normal"} + + // Graphics + prefPlotterRenderer = intPref{"plotterRenderer", 0} + prefMeshRenderer = intPref{"meshRenderer", 2} + + // Logging + prefLogFormat = stringPref{"logFormat", "CSV"} + prefLogPath = stringPref{"logPath", ""} + + // Dashboard + prefUseMPH = boolPref{"useMPH", false} + prefSwapRPMandSpeed = boolPref{"swapRPMandSpeed", false} + + // Wideband + prefWblSource = stringPref{"wblSource", "None"} + prefWidebandSymbolName = stringPref{"widebandSymbolName", ""} + prefWBLPort = stringPref{"wblPort", ""} + prefUseADScanner = boolPref{"useADScanner", false} + prefWBLADScannerSymbol = stringPref{"wblADScannerSymbol", ""} + prefWBLSupportPoints = intListPref{"wblSupportPoints", []int{0, 1024}} + prefWBLLambdaValues = floatListPref{"wblLambdaValues", []float64{0.5, 1.5}} + prefLastADScannerPreset = stringPref{"lastADScannerPreset", ""} + prefLastADScannerECU = stringPref{"lastADScannerECU", "T7"} + + // CAN + prefAdapter = stringPref{"adapter", ""} + prefPort = stringPref{"port", ""} + prefSpeed = stringPref{"speed", "115200"} + prefDebug = boolPref{"debug", false} +) diff --git a/pkg/widgets/settings/settings.go b/pkg/widgets/settings/settings.go index 81417649..c3572cd8 100644 --- a/pkg/widgets/settings/settings.go +++ b/pkg/widgets/settings/settings.go @@ -1,88 +1,33 @@ package settings import ( - "errors" "fmt" - "log" "os" "slices" "sort" - "strconv" "strings" "sync" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/gocan" "github.com/roffe/gocan/proto" - "github.com/roffe/txlogger/pkg/colors" - "github.com/roffe/txlogger/pkg/common" - "github.com/roffe/txlogger/pkg/datalogger" - "github.com/roffe/txlogger/pkg/ota" - "github.com/roffe/txlogger/pkg/wbl/aem" - "github.com/roffe/txlogger/pkg/wbl/ecumaster" - "github.com/roffe/txlogger/pkg/wbl/innovate" - "github.com/roffe/txlogger/pkg/wbl/plx" - "github.com/roffe/txlogger/pkg/wbl/stag" - "github.com/roffe/txlogger/pkg/wbl/zeitronix" "github.com/roffe/txlogger/pkg/widgets/txconfigurator" "go.bug.st/serial/enumerator" ) -const ( - prefsFreq = "freq" - prefsAutoUpdateLoadEcu = "autoUpdateLoadEcu" - prefsAutoUpdateSaveEcu = "autoUpdateSaveEcu" - prefsLivePreview = "livePreview" - prefsMeshView = "liveMeshView" - prefsRealtimeBars = "realtimeBars" - prefsLogFormat = "logFormat" - prefsLogPath = "logPath" - prefsWblSource = "wblSource" - prefsWidebandSymbolName = "widebandSymbolName" - prefsUseMPH = "useMPH" - prefsSwapRPMandSpeed = "swapRPMandSpeed" - prefsCursorFollowCrosshair = "cursorFollowCrosshair" - prefsWBLPort = "wblPort" - - prefsLastADScannerPreset = "lastADScannerPreset" - prefsWBLSupportPoints = "wblSupportPoints" - prefsWBLLambdaValues = "wblLambdaValues" - prefsLastADScannerECU = "lastADScannerECU" - prefsWBLADScannerSymbol = "wblADScannerSymbol" - - prefsUseADScanner = "useADScanner" - prefsColorBlindMode = "colorBlindMode" - - // CAN - prefsAdapter = "adapter" - prefsPort = "port" - prefsSpeed = "speed" - prefsDebug = "debug" - - // Flash - PrefsNvdm = "nvdm" - PrefsBoot = "boot" -) - -var portSpeeds = []string{"9600", "19200", "38400", "57600", "115200", "230400", "460800", "500000", "921600", "1mbit", "2mbit", "3mbit"} - -type SettingsWidgetInterface interface { - Get(key string) (string, error) - Widget() fyne.Widget -} - -type SetText interface { - SetText(string) -} - +// Config carries callbacks the settings widget needs from its host. type Config struct { Logger func(string) SelectedEcuFunc func() string } +// Widget is the settings panel. All persisted state lives in the application +// preferences (see prefs.go); the fields below are just the UI controls bound +// to those preferences. type Widget struct { widget.BaseWidget @@ -90,7 +35,7 @@ type Widget struct { workDir *widget.Label - // CANSettings *cansettings.Widget + // General freqSlider *widget.Slider freqValue *widget.Label autoSave *widget.Check @@ -104,20 +49,22 @@ type Widget struct { useMPH *widget.Check swapRPMandSpeed *widget.Check colorBlindMode *widget.Select - // can settings + + // Graphics + plotRendererSelect *widget.Select + meshRendererSelect *widget.Select + + // CAN debugCheckbox *widget.Check adapterSelector *widget.Select refreshBtn *widget.Button - portSelector *widget.Select + portSelector *widget.SelectEntry portDescription *widget.Label speedSelector *widget.Select + adapters map[string]*gocan.AdapterInfo - adapters map[string]*gocan.AdapterInfo - - // WBL Specific - - wbleditor *WBLEditor - + // Wideband + wbleditor *WBLEditor wblADscanner *widget.Check wblADScannerSymbol *widget.Select wblSelectContainer *fyne.Container @@ -125,22 +72,7 @@ type Widget struct { wblPortLabel *widget.Label wblPortSelect *widget.Select wblPortRefreshButton *widget.Button - - images struct { - wblImage *canvas.Image - - /* - mtxl *canvas.Image - lc2 *canvas.Image - uego *canvas.Image - lambdatocan *canvas.Image - t7 *canvas.Image - plx *canvas.Image - combi *canvas.Image - zeitronix *canvas.Image - stagafr *canvas.Image - */ - } + wblImage *canvas.Image mu sync.Mutex } @@ -149,14 +81,13 @@ func New(cfg *Config) *Widget { sw := &Widget{ cfg: cfg, adapters: make(map[string]*gocan.AdapterInfo), + wblImage: newImageFromResource("t7"), } for _, adapter := range gocan.ListAdapters() { sw.adapters[adapter.Name] = &adapter } - sw.images.wblImage = newImageFromResource("t7") - sw.ExtendBaseWidget(sw) return sw } @@ -164,87 +95,83 @@ func New(cfg *Config) *Widget { func (sw *Widget) CreateRenderer() fyne.WidgetRenderer { sw.workDir = widget.NewLabel("") sw.workDir.Selectable = true - wd, err := os.Getwd() - if err != nil { + if wd, err := os.Getwd(); err != nil { sw.workDir.SetText(fmt.Sprintf("Error getting working directory: %v", err)) } else { sw.workDir.SetText(wd) } + // General sw.freqSlider = sw.newFreqSlider() sw.freqValue = widget.NewLabel("") - sw.autoLoad = sw.newAutoUpdateLoad() - sw.autoSave = sw.newAutoUpdateSave() - sw.cursorFollowCrosshair = sw.newCursorFollowCrosshair() - sw.livePreview = sw.newLivePreview() - sw.meshView = sw.newMeshView() - sw.realtimeBars = sw.newRealtimeBars() + sw.autoLoad = checkBox("Load maps from ECU when connected", prefAutoLoad) + sw.autoSave = checkBox("Save changes automaticly if connected to ECU (requires open bin)", prefAutoSave) + sw.cursorFollowCrosshair = checkBox("Cursor follows crosshair in MapViewer (one hand mapping)", prefCursorFollowCrosshair) + sw.livePreview = checkBox("Live preview values in symbollist (uncheck if you have a slow pc)", prefLivePreview) + sw.meshView = checkBox("3D Mesh on map viewing", prefMeshView) + sw.realtimeBars = checkBox("Bars on live preview values (uncheck if you have a slow pc)", prefRealtimeBars) sw.logFormat = sw.newLogFormat() sw.logPath = widget.NewLabel("") sw.logPath.Truncation = fyne.TextTruncateEllipsis - sw.useMPH = sw.newUserMPH() - sw.swapRPMandSpeed = sw.newSwapRPMandSpeed() + sw.useMPH = checkBox("Use mph instead of km/h", prefUseMPH) + sw.swapRPMandSpeed = checkBox("Swap RPM and speed gauge position", prefSwapRPMandSpeed) sw.colorBlindMode = sw.newColorBlindMode() sw.wblSelectContainer = sw.newWBLSelector() + // Graphics + sw.plotRendererSelect = indexSelect([]string{"Software", "Shader"}, prefPlotterRenderer) + sw.meshRendererSelect = indexSelect([]string{"Shader", "Polygons", "Software"}, prefMeshRenderer) + // CAN sw.adapterSelector = sw.newAdapterSelector() sw.portSelector = sw.newPortSelector() sw.portDescription = widget.NewLabel("") sw.portDescription.Importance = widget.LowImportance sw.speedSelector = sw.newSpeedSelector() - sw.debugCheckbox = sw.newDebugCheckbox() + sw.debugCheckbox = checkBox("Debug", prefDebug) sw.refreshBtn = sw.newPortRefreshButton() - names := make([]string, 0, len(sw.adapters)) - for name := range sw.adapters { - names = append(names, name) - } - slices.SortFunc(names, func(i, j string) int { - return strings.Compare(strings.ToLower(i), strings.ToLower(j)) - }) - sw.adapterSelector.SetOptions(names) - if ad := fyne.CurrentApp().Preferences().String(prefsAdapter); ad != "" { + sw.adapterSelector.SetOptions(sw.sortedAdapterNames()) + if ad := prefAdapter.get(); ad != "" { sw.adapterSelector.SetSelected(ad) } tabs := container.NewAppTabs() tabs.Append(sw.generalTab()) + tabs.Append(sw.graphicsTab()) tabs.Append(sw.canTab()) tabs.Append(sw.loggingTab()) - tabs.Append(sw.dashboardTab()) tabs.Append(sw.wblTab()) tabs.Append(sw.adScannerTab()) - tabs.Append(container.NewTabItem("txbridge", txconfigurator.NewConfigurator())) - for _, adapter := range gocan.ListAdapters() { - sw.adapters[adapter.Name] = &adapter - } + tabs.Append(container.NewTabItemWithIcon("txbridge", theme.DownloadIcon(), txconfigurator.NewConfigurator(prefPort.get))) sw.loadPreferences() return widget.NewSimpleRenderer(tabs) } -// Public API +func (sw *Widget) sortedAdapterNames() []string { + names := make([]string, 0, len(sw.adapters)) + for name := range sw.adapters { + names = append(names, name) + } + slices.SortFunc(names, func(i, j string) int { + return strings.Compare(strings.ToLower(i), strings.ToLower(j)) + }) + return names +} + +// --- public API ------------------------------------------------------------ var portCache = make(map[string]*enumerator.PortDetails) func (sw *Widget) ListPorts() []string { - var portsList []string ports, err := enumerator.GetDetailedPortsList() - if err != nil { - // m.output(err.Error()) - return []string{} - } - if len(ports) == 0 { - // m.output("No serial ports found!") + if err != nil || len(ports) == 0 { return []string{} } + portsList := make([]string, 0, len(ports)) for _, port := range ports { - // m.output(fmt.Sprintf("Found port: %s", port.Name)) - // if port.IsUSB { - // m.output(fmt.Sprintf(" USB ID %s:%s", port.VID, port.PID)) - // m.output(fmt.Sprintf(" USB serial %s", port.SerialNumber)) portsList = append(portsList, port.Name) portCache[port.Name] = port } @@ -259,7 +186,7 @@ func (sw *Widget) AddAdapters(adapters []*proto.AdapterInfo) { sw.mu.Lock() defer sw.mu.Unlock() for _, adapter := range adapters { - adapter := &gocan.AdapterInfo{ + info := &gocan.AdapterInfo{ Name: adapter.GetName(), Description: adapter.GetDescription(), Capabilities: gocan.AdapterCapabilities{ @@ -269,255 +196,35 @@ func (sw *Widget) AddAdapters(adapters []*proto.AdapterInfo) { }, RequiresSerialPort: adapter.GetRequireSerialPort(), } - - if _, found := sw.adapters[adapter.Name]; found { + if _, found := sw.adapters[info.Name]; found { continue } - sw.adapters[adapter.Name] = adapter + sw.adapters[info.Name] = info } } -func (c *Widget) Disable() { - c.adapterSelector.Disable() - c.portSelector.Disable() - c.speedSelector.Disable() - c.debugCheckbox.Disable() - c.refreshBtn.Disable() +func (sw *Widget) Disable() { + sw.adapterSelector.Disable() + sw.portSelector.Disable() + sw.speedSelector.Disable() + sw.debugCheckbox.Disable() + sw.refreshBtn.Disable() } -func (c *Widget) Enable() { - c.adapterSelector.Enable() - c.portSelector.Enable() - c.speedSelector.Enable() - c.debugCheckbox.Enable() - c.refreshBtn.Enable() +func (sw *Widget) Enable() { + sw.adapterSelector.Enable() + sw.portSelector.Enable() + sw.speedSelector.Enable() + sw.debugCheckbox.Enable() + sw.refreshBtn.Enable() - if info, found := c.adapters[c.adapterSelector.Selected]; found { + if info, found := sw.adapters[sw.adapterSelector.Selected]; found { if info.RequiresSerialPort { - c.portSelector.Enable() - c.speedSelector.Enable() - } else { - c.portSelector.Disable() - c.speedSelector.Disable() - } - } -} - -func (cs *Widget) GetAdapter(ecuType string) (gocan.Adapter, error) { - return cs.GetAdapterWithExtraFilters(ecuType, []uint32{}) -} - -func (cs *Widget) GetAdapterWithExtraFilters(ecuType string, filters []uint32) (gocan.Adapter, error) { - debug := fyne.CurrentApp().Preferences().Bool(prefsDebug) - port := fyne.CurrentApp().Preferences().String(prefsPort) - - baudstring := fyne.CurrentApp().Preferences().String(prefsSpeed) - switch baudstring { - case "1mbit": - baudstring = "1000000" - case "2mbit": - baudstring = "2000000" - case "3mbit": - baudstring = "3000000" - } - - if baudstring == "" { - baudstring = "1000000" - } - - baudrate, err := strconv.Atoi(baudstring) - if err != nil { - return nil, err - } - adapterName := fyne.CurrentApp().Preferences().String(prefsAdapter) - - if adapterName == "" { - return nil, errors.New("Select CANbus adapter in settings") //lint:ignore ST1005 This is ok - } - - if ad, found := cs.adapters[adapterName]; found { - if ad.RequiresSerialPort { - if port == "" { - return nil, errors.New("Select port in setings") //lint:ignore ST1005 This is ok - } - if baudstring == "" { - return nil, errors.New("Select port speed in settings") //lint:ignore ST1005 This is ok - } - } - } - - var canFilter []uint32 - var canRate float64 - - switch ecuType { - case "T5", "Trionic 5": - canFilter = []uint32{0xC} - canRate = 615.384 - case "T7", "Trionic 7": - if strings.Contains(adapterName, "ELM327") || strings.Contains(adapterName, "STN") || strings.Contains(adapterName, "OBDLink") || strings.HasSuffix(adapterName, "Wifi") { - canFilter = []uint32{0x238, 0x258, 0x270} - } else { - canFilter = []uint32{0x1A0, 0x238, 0x258, 0x270, 0x280, 0x3A0, 0x664, 0x665} - } - if fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") == "CAN" { - canFilter = append(canFilter, 0x180) - } - canRate = 500 - case "T8", "Trionic 8", "Trionic 8 MCP", "Z22SE", "Z22SE MCP": - if strings.Contains(adapterName, "ELM327") || strings.Contains(adapterName, "STN") || strings.Contains(adapterName, "OBDLink") { - canFilter = []uint32{0x5E8, 0x7E8} + sw.portSelector.Enable() + sw.speedSelector.Enable() } else { - canFilter = []uint32{0x5E8, 0x7E8, 0x664, 0x665} - } - if fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") == "CAN" { - canFilter = append(canFilter, 0x180) - } - canFilter = append(canFilter, filters...) - - canRate = 500 - } - - cfg := &gocan.AdapterConfig{ - Port: port, - PortBaudrate: baudrate, - CANRate: canRate, - CANFilter: canFilter, - Debug: debug, - PrintVersion: true, - } - - if strings.HasPrefix(adapterName, "J2534") { // || strings.HasPrefix(adapterName, "CANlib") { - return gocan.NewGWClient(adapterName, cfg) - } - - if adapterName == "txbridge wifi" { - //ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - //defer cancel() - //addr, err := mdns.Query(ctx, "txbridge.local") - //if err != nil { - // cs.cfg.Logger(fmt.Sprintf("Failed to resolve txbridge address via mDNS: %v", err)) - //} else { - cfg.AdditionalConfig = map[string]string{ - "address": fmt.Sprintf("%s:%d", "192.168.4.1", 1337), - "minversion": ota.MinimumtxbridgeVersion, - } - //} - } - return gocan.NewAdapter(adapterName, cfg) -} - -func (sw *Widget) GetADScannerSymbolName() string { - return fyne.CurrentApp().Preferences().String(prefsWBLADScannerSymbol) -} - -func (sw *Widget) GetWidebandName() string { - return fyne.CurrentApp().Preferences().StringWithFallback(prefsWblSource, "None") -} - -func (sw *Widget) GetWidebandSymbolName() string { - useADScanner := fyne.CurrentApp().Preferences().Bool(prefsUseADScanner) - switch sw.GetWidebandName() { - case "ECU": - switch sw.cfg.SelectedEcuFunc() { - case "T5": - return datalogger.LAMBDAADSCANNER // Lambda.ADScanner - case "T7": - if useADScanner { - return datalogger.LAMBDAADSCANNER // Lambda.ADScanner - } - return "DisplProt.LambdaScanner" - case "T8": - if useADScanner { - return datalogger.LAMBDAADSCANNER // Lambda.ADScanner - } - return "LambdaScan.LambdaScanner" - default: - return "None" - } - case aem.ProductString, - "CombiAdapter", - ecumaster.ProductString, - innovate.ProductString, - plx.ProductString, - stag.ProductString, - zeitronix.ProductString: - return datalogger.EXTERNALWBLSYM // Lambda.External - default: - return "None" - } -} - -func (sw *Widget) GetColorBlindMode() colors.ColorBlindMode { - return colors.StringToColorBlindMode(fyne.CurrentApp().Preferences().StringWithFallback(prefsColorBlindMode, "Normal")) -} - -func (sw *Widget) GetWidebandPort() string { - return fyne.CurrentApp().Preferences().String(prefsWBLPort) -} - -func (sw *Widget) GetWBLSupportPoints() []int { - return fyne.CurrentApp().Preferences().IntListWithFallback(prefsWBLSupportPoints, []int{0, 1024}) -} - -func (sw *Widget) GetWBLLambdaValues() []float64 { - return fyne.CurrentApp().Preferences().FloatListWithFallback(prefsWBLLambdaValues, []float64{0.5, 1.5}) -} - -func (sw *Widget) GetUseADScanner() bool { - if fyne.CurrentApp().Preferences().String(prefsWblSource) != "ECU" { - return false - } - return fyne.CurrentApp().Preferences().Bool(prefsUseADScanner) -} - -func (sw *Widget) GetFreq() int { - return int(fyne.CurrentApp().Preferences().IntWithFallback(prefsFreq, 25)) -} - -func (sw *Widget) GetAutoSave() bool { - return fyne.CurrentApp().Preferences().Bool(prefsAutoUpdateSaveEcu) -} - -func (sw *Widget) GetAutoLoad() bool { - return fyne.CurrentApp().Preferences().Bool(prefsAutoUpdateLoadEcu) -} - -func (sw *Widget) GetLivePreview() bool { - return fyne.CurrentApp().Preferences().Bool(prefsLivePreview) -} - -func (sw *Widget) GetRealtimeBars() bool { - return fyne.CurrentApp().Preferences().Bool(prefsRealtimeBars) -} - -func (sw *Widget) GetMeshView() bool { - return fyne.CurrentApp().Preferences().Bool(prefsMeshView) -} - -func (sw *Widget) GetLogFormat() string { - return fyne.CurrentApp().Preferences().StringWithFallback(prefsLogFormat, "CSV") -} - -func (sw *Widget) GetLogPath() string { - p := fyne.CurrentApp().Preferences().String(prefsLogPath) - if p == "" { - var err error - p, err = common.GetLogPath() - if err != nil { - log.Println("GetLogPath: ", err) + sw.portSelector.Disable() + sw.speedSelector.Disable() } } - return p -} - -func (sw *Widget) GetUseMPH() bool { - return fyne.CurrentApp().Preferences().Bool(prefsUseMPH) -} - -func (sw *Widget) GetSwapRPMandSpeed() bool { - return fyne.CurrentApp().Preferences().Bool(prefsSwapRPMandSpeed) -} - -func (sw *Widget) GetCursorFollowCrosshair() bool { - return fyne.CurrentApp().Preferences().Bool(prefsCursorFollowCrosshair) } diff --git a/pkg/widgets/settings/settings_internal.go b/pkg/widgets/settings/settings_internal.go deleted file mode 100644 index 09c2fd2a..00000000 --- a/pkg/widgets/settings/settings_internal.go +++ /dev/null @@ -1,350 +0,0 @@ -package settings - -import ( - "strconv" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" - "github.com/roffe/gocan" - "github.com/roffe/txlogger/pkg/assets" - "github.com/roffe/txlogger/pkg/colors" - "github.com/roffe/txlogger/pkg/common" - "github.com/roffe/txlogger/pkg/ebus" - "github.com/roffe/txlogger/pkg/wbl/aem" - "github.com/roffe/txlogger/pkg/wbl/ecumaster" - "github.com/roffe/txlogger/pkg/wbl/innovate" - "github.com/roffe/txlogger/pkg/wbl/plx" - "github.com/roffe/txlogger/pkg/wbl/stag" - "github.com/roffe/txlogger/pkg/wbl/zeitronix" -) - -func newImageFromResource(name string) *canvas.Image { - var img *canvas.Image - switch name { - case "mtx-l": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.MtxL)) - img.SetMinSize(fyne.NewSize(224, 224)) - case "lc-2": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.Lc2)) - img.SetMinSize(fyne.NewSize(400, 224)) - case "uego": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.Uego)) - img.SetMinSize(fyne.NewSize(315, 224)) - case "lambdatocan": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.LambdaToCan)) - img.SetMinSize(fyne.NewSize(481, 224)) - case "t7": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.T7)) - img.SetMinSize(fyne.NewSize(320, 224)) - case "plx": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.PLX)) - img.SetMinSize(fyne.NewSize(470, 224)) - case "combi": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.CombiV2)) - img.SetMinSize(fyne.NewSize(360, 245)) - case "zeitronix": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.ZeitronixZT2)) - img.SetMinSize(fyne.NewSize(252, 252)) - case "stagafr": - img = canvas.NewImageFromResource(fyne.NewStaticResource(name, assets.STAGAfr)) - img.SetMinSize(fyne.NewSize(252, 252)) - } - img.FillMode = canvas.ImageFillContain - img.ScaleMode = canvas.ImageScaleFastest - - return img -} - -var logFormats = []string{"CSV", "BPL" /*"TXL"*/} - -func (sw *Widget) newLogFormat() *widget.Select { - return widget.NewSelect(logFormats, func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsLogFormat, s) - }) -} - -var wblAdapters = []string{ - "None", - "ECU", - aem.ProductString, - "CombiAdapter", - ecumaster.ProductString, - innovate.ProductString, - plx.ProductString, - stag.ProductString, - zeitronix.ProductString, -} - -func (sw *Widget) newWBLSelector() *fyne.Container { - wblImages := map[string]*canvas.Image{ - "ECU": newImageFromResource("t7"), // Using T7 image for ECU as a placeholder - ecumaster.ProductString: newImageFromResource("lambdatocan"), - innovate.ProductString: newImageFromResource("mtx-l"), // Using MTX-L image for Innovate as a placeholder - aem.ProductString: newImageFromResource("uego"), - plx.ProductString: newImageFromResource("plx"), - "CombiAdapter": newImageFromResource("combi"), - zeitronix.ProductString: newImageFromResource("zeitronix"), - stag.ProductString: newImageFromResource("stagafr"), - } - - sw.wblSource = widget.NewSelect(wblAdapters, func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsWblSource, s) - fyne.CurrentApp().Preferences().SetString(prefsWidebandSymbolName, sw.GetWidebandSymbolName()) - - var adScanner, portSelect bool - - img, found := wblImages[s] - if found && img != nil { - sw.images.wblImage.Resource = img.Resource - sw.images.wblImage.SetMinSize(img.MinSize()) - sw.images.wblImage.Refresh() - } - - switch s { - case "ECU", "CombiAdapter": - adScanner = true - portSelect = false - case aem.ProductString, innovate.ProductString, plx.ProductString, stag.ProductString, zeitronix.ProductString: - portSelect = true - case ecumaster.ProductString: - portSelect = false - default: - portSelect = false - } - - if portSelect { - sw.wblPortLabel.Show() - sw.wblPortSelect.Show() - sw.wblPortRefreshButton.Show() - } else { - sw.wblPortLabel.Hide() - sw.wblPortSelect.Hide() - sw.wblPortRefreshButton.Hide() - } - - if adScanner { - sw.wblADscanner.Show() - sw.wblADScannerSymbol.Show() - } else { - sw.wblADscanner.Hide() - sw.wblADScannerSymbol.Hide() - } - }) - return container.NewBorder( - nil, - nil, - widget.NewLabel("Source"), - nil, - sw.wblSource, - ) -} - -func (sw *Widget) newFreqSlider() *widget.Slider { - slider := widget.NewSlider(5, 300) - slider.Step = 5 - slider.OnChanged = func(f float64) { - sw.freqValue.SetText(strconv.FormatFloat(f, 'f', 0, 64)) - } - slider.OnChangeEnded = func(f float64) { - fyne.CurrentApp().Preferences().SetInt(prefsFreq, int(f)) - } - return slider -} - -func (sw *Widget) newADscannerCheck() *widget.Check { - return widget.NewCheck("use AD Scanner (don't forget to add symbol)", func(b bool) { - if b { - sw.wblADScannerSymbol.Show() - } else { - sw.wblADScannerSymbol.Hide() - } - fyne.CurrentApp().Preferences().SetBool(prefsUseADScanner, b) - }) -} - -func (sw *Widget) newMeshView() *widget.Check { - return widget.NewCheck("3D Mesh on map viewing", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsMeshView, b) - }) -} - -func (sw *Widget) newAutoUpdateLoad() *widget.Check { - return widget.NewCheck("Load maps from ECU when connected", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsAutoUpdateLoadEcu, b) - }) -} - -func (sw *Widget) newAutoUpdateSave() *widget.Check { - return widget.NewCheck("Save changes automaticly if connected to ECU (requires open bin)", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsAutoUpdateSaveEcu, b) - }) -} - -func (sw *Widget) newCursorFollowCrosshair() *widget.Check { - return widget.NewCheck("Cursor follows crosshair in MapViewer (one hand mapping)", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsCursorFollowCrosshair, b) - }) -} - -func (sw *Widget) newLivePreview() *widget.Check { - return widget.NewCheck("Live preview values in symbollist (uncheck if you have a slow pc)", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsLivePreview, b) - }) -} - -func (sw *Widget) newRealtimeBars() *widget.Check { - return widget.NewCheck("Bars on live preview values (uncheck if you have a slow pc)", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsRealtimeBars, b) - }) -} - -func (sw *Widget) newUserMPH() *widget.Check { - return widget.NewCheck("Use mph instead of km/h", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsUseMPH, b) - }) -} - -func (sw *Widget) newSwapRPMandSpeed() *widget.Check { - return widget.NewCheck("Swap RPM and speed gauge position", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsSwapRPMandSpeed, b) - }) -} - -func (sw *Widget) newColorBlindMode() *widget.Select { - return widget.NewSelect(colors.SupportedColorBlindModes[:], func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsColorBlindMode, s) - ebus.Publish(ebus.TOPIC_COLORBLINDMODE, float64(sw.colorBlindMode.SelectedIndex())) - }) -} - -func (sw *Widget) newAdapterSelector() *widget.Select { - return widget.NewSelect(gocan.ListAdapterNames(), func(s string) { - if info, found := sw.adapters[s]; found { - fyne.CurrentApp().Preferences().SetString(prefsAdapter, s) - if info.RequiresSerialPort { - sw.portSelector.Enable() - sw.speedSelector.Enable() - return - } else { - sw.portDescription.SetText("") - } - sw.portSelector.Disable() - sw.speedSelector.Disable() - } - }) -} - -func (sw *Widget) newPortSelector() *widget.Select { - return widget.NewSelect(sw.ListPorts(), func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsPort, s) - itm, ok := portCache[s] - if ok { - /* - var desc string - if itm.Manufacturer != "" { - desc += itm.Manufacturer - - if itm.Product != "" { - if desc != "" { - desc += " " - } - desc += itm.Product - } - if itm.SerialNumber != "" { - if desc != "" { - desc += " " - } - desc += itm.SerialNumber - } - */ - sw.portDescription.SetText(itm.SerialNumber) - } else { - sw.portDescription.SetText("") - } - }) -} - -func (sw *Widget) newSpeedSelector() *widget.Select { - return widget.NewSelect(portSpeeds, func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsSpeed, s) - }) -} - -func (sw *Widget) newDebugCheckbox() *widget.Check { - return widget.NewCheck("Debug", func(b bool) { - fyne.CurrentApp().Preferences().SetBool(prefsDebug, b) - }) -} - -func (sw *Widget) newPortRefreshButton() *widget.Button { - return widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { - sw.portSelector.Options = sw.ListPorts() - sw.portSelector.Refresh() - }) -} - -func (sw *Widget) loadPreferences() { - freq := fyne.CurrentApp().Preferences().IntWithFallback(prefsFreq, 25) - sw.freqSlider.SetValue(float64(freq)) - loadPrefsCheck(sw.autoLoad, prefsAutoUpdateLoadEcu, true) - loadPrefsCheck(sw.autoSave, prefsAutoUpdateSaveEcu, false) - loadPrefsCheck(sw.cursorFollowCrosshair, prefsCursorFollowCrosshair, false) - loadPrefsCheck(sw.livePreview, prefsLivePreview, true) - loadPrefsCheck(sw.meshView, prefsMeshView, true) - loadPrefsCheck(sw.realtimeBars, prefsRealtimeBars, true) - loadPrefsSelect(sw.logFormat, prefsLogFormat, "CSV") - logPath, err := common.GetLogPath() - if err != nil { - fyne.LogError("Could not get log path", err) - } - loadPrefsText(sw.logPath, prefsLogPath, logPath) - loadPrefsText(sw.logPath, prefsLogPath, logPath) - loadPrefsSelect(sw.wblSource, prefsWblSource, "None") - loadPrefsCheck(sw.wblADscanner, prefsUseADScanner, false) - if sw.wblADscanner.Checked && sw.wblSource.Selected == "ECU" { - sw.wblADScannerSymbol.Show() - } else { - sw.wblADScannerSymbol.Hide() - } - - loadPrefsCheck(sw.useMPH, prefsUseMPH, false) - loadPrefsCheck(sw.swapRPMandSpeed, prefsSwapRPMandSpeed, false) - loadPrefsSelect(sw.wblPortSelect, prefsWBLPort, "") - loadPrefsSelect(sw.colorBlindMode, prefsColorBlindMode, "Normal") - - loadPrefsSelect(sw.adapterSelector, prefsAdapter, "") - loadPrefsSelect(sw.portSelector, prefsPort, "") - loadPrefsSelect(sw.speedSelector, prefsSpeed, "115200") - loadPrefsCheck(sw.debugCheckbox, prefsDebug, false) -} - -func loadPrefsSelect(s *widget.Select, prefKey string, fallback string) { - s.SetSelected(fyne.CurrentApp().Preferences().StringWithFallback(prefKey, fallback)) -} - -func loadPrefsCheck(box *widget.Check, prefKey string, fallback bool) { - box.SetChecked(fyne.CurrentApp().Preferences().BoolWithFallback(prefKey, fallback)) -} - -func loadPrefsText(obj SetText, prefKey string, fallback string) { - obj.SetText(fyne.CurrentApp().Preferences().StringWithFallback(prefKey, fallback)) -} - -/* -func positiveFloatValidator(s string) (float64, error) { - s = strings.ReplaceAll(s, ",", ".") - s = strings.TrimSuffix(s, ".") - - val, err := strconv.ParseFloat(s, 64) - if err != nil { - return 0, errors.New("invalid number") - } - if val < 0 { - return 0, errors.New("must be positive") - } - return val, nil -} -*/ diff --git a/pkg/widgets/settings/settings_tabs.go b/pkg/widgets/settings/settings_tabs.go deleted file mode 100644 index bf4ab6ee..00000000 --- a/pkg/widgets/settings/settings_tabs.go +++ /dev/null @@ -1,223 +0,0 @@ -package settings - -import ( - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" - "github.com/roffe/txlogger/pkg/common" - xlayout "github.com/roffe/txlogger/pkg/layout" - "github.com/roffe/txlogger/pkg/widgets" -) - -func (sw *Widget) generalTab() *container.TabItem { - return container.NewTabItem("General", container.NewVBox( - container.NewBorder( - nil, - nil, - widget.NewLabel("WD"), - nil, - sw.workDir, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.InfoIcon()), - nil, - sw.autoLoad, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.WarningIcon()), - nil, - sw.autoSave, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.MoveUpIcon()), - nil, - sw.cursorFollowCrosshair, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.SearchIcon()), - nil, - container.NewVBox( - sw.livePreview, - sw.realtimeBars, - ), - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.ViewFullScreenIcon()), - nil, - sw.meshView, - ), - container.NewBorder( - nil, - nil, - widget.NewLabel("Color blind mode"), - nil, - sw.colorBlindMode, - ), - )) -} - -func (sw *Widget) loggingTab() *container.TabItem { - return container.NewTabItem("Logging", container.NewVBox( - container.NewBorder( - nil, - nil, - widget.NewLabel("Logging rate (Hz)"), - sw.freqValue, - sw.freqSlider, - ), - widget.NewSeparator(), - container.NewBorder( - nil, - nil, - widget.NewLabel("Log format"), - nil, - sw.logFormat, - ), - container.NewBorder( - nil, - container.NewGridWithColumns(2, - widget.NewButtonWithIcon("Reset", theme.ContentClearIcon(), func() { - logPath, err := common.GetLogPath() - if err != nil { - fyne.LogError("Could not get log path", err) - } - sw.logPath.SetText(logPath) - fyne.CurrentApp().Preferences().SetString(prefsLogPath, logPath) - }), - widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() { - cb := func(dir string) { - sw.logPath.SetText(dir) - fyne.CurrentApp().Preferences().SetString(prefsLogPath, dir) - } - widgets.SelectFolder(cb) - }), - ), - widget.NewLabel("Log folder"), - nil, - sw.logPath, - ), - )) -} - -func (sw *Widget) wblTab() *container.TabItem { - sw.wblPortLabel = widget.NewLabel("WBL Port") - sw.wblPortSelect = widget.NewSelect(append([]string{"txbridge", "CAN"}, sw.ListPorts()...), func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsWBLPort, s) - }) - - sw.wblPortRefreshButton = widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { - sw.wblPortSelect.Options = append([]string{"txbridge", "CAN"}, sw.ListPorts()...) - sw.wblPortSelect.Refresh() - }) - - sw.wblADscanner = sw.newADscannerCheck() - - adSymbols := []string{ - "AD_EGR", - "DisplProt.AD_Scanner", - "LambdaScan.AD_Scanner", - "LambdaScan.AD_Scanner2", - } - - sw.wblADScannerSymbol = widget.NewSelect(adSymbols, func(s string) { - fyne.CurrentApp().Preferences().SetString(prefsWBLADScannerSymbol, s) - }) - sw.wblADScannerSymbol.SetSelected(sw.GetADScannerSymbolName()) - - return container.NewTabItem( - "WBL", - container.NewBorder( - container.NewHBox( - layout.NewSpacer(), - sw.images.wblImage, - layout.NewSpacer(), - ), - nil, - nil, - nil, - container.NewVBox( - sw.wblSelectContainer, - sw.wblADscanner, - container.NewBorder( - nil, - nil, - sw.wblPortLabel, - sw.wblPortRefreshButton, - sw.wblPortSelect, - ), - sw.wblADScannerSymbol, - ), - ), - ) -} - -func (sw *Widget) dashboardTab() *container.TabItem { - return container.NewTabItem("Dashboard", container.NewVBox( - widget.NewLabel("Dashboard settings"), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.InfoIcon()), - nil, - sw.swapRPMandSpeed, - ), - container.NewBorder( - nil, - nil, - widget.NewIcon(theme.InfoIcon()), - nil, - sw.useMPH, - ), - )) -} - -func (sw *Widget) adScannerTab() *container.TabItem { - sw.wbleditor = NewWBLEditor(sw.GetWBLSupportPoints(), sw.GetWBLLambdaValues()) - sw.wbleditor.Hide() - return container.NewTabItem("AD Scanner", sw.wbleditor) -} - -func (sw *Widget) canTab() *container.TabItem { - return container.NewTabItem("CAN", container.NewVBox( - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Adapter")), - sw.debugCheckbox, - sw.adapterSelector, - ), - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Port")), - sw.refreshBtn, - sw.portSelector, - ), - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Info")), - nil, - sw.portDescription, - ), - container.NewBorder( - nil, - nil, - xlayout.NewFixedWidth(70, widget.NewLabel("Speed")), - nil, - sw.speedSelector, - ), - )) -} diff --git a/pkg/widgets/settings/tabs.go b/pkg/widgets/settings/tabs.go new file mode 100644 index 00000000..1bf538a8 --- /dev/null +++ b/pkg/widgets/settings/tabs.go @@ -0,0 +1,152 @@ +package settings + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" + xlayout "github.com/roffe/txlogger/pkg/layout" + "github.com/roffe/txlogger/pkg/widgets" +) + +// leftLabeled places a text label to the left of content. +func leftLabeled(label string, content fyne.CanvasObject) *fyne.Container { + return container.NewBorder(nil, nil, widget.NewLabel(label), nil, content) +} + +// leftIcon places an icon to the left of content. +func leftIcon(res fyne.Resource, content fyne.CanvasObject) *fyne.Container { + return container.NewBorder(nil, nil, widget.NewIcon(res), nil, content) +} + +// section groups related controls under a titled card, the main visual +// building block of the settings panel. +func section(title string, rows ...fyne.CanvasObject) *widget.Card { + return widget.NewCard(title, "", container.NewVBox(rows...)) +} + +// tab wraps a stack of cards in a padded page. +func tab(title string, icon fyne.Resource, cards ...fyne.CanvasObject) *container.TabItem { + page := container.NewPadded(container.NewVBox(cards...)) + return container.NewTabItemWithIcon(title, icon, page) +} + +func (sw *Widget) generalTab() *container.TabItem { + return tab("General", theme.SettingsIcon(), + section("ECU connection", + leftIcon(theme.InfoIcon(), sw.autoLoad), + leftIcon(theme.WarningIcon(), sw.autoSave), + ), + section("Map editor & preview", + leftIcon(theme.MoveUpIcon(), sw.cursorFollowCrosshair), + leftIcon(theme.ViewFullScreenIcon(), sw.meshView), + leftIcon(theme.SearchIcon(), container.NewVBox(sw.livePreview, sw.realtimeBars)), + ), + section("Appearance", + leftLabeled("Color blind mode", sw.colorBlindMode), + ), + section("Working directory", + sw.workDir, + ), + ) +} + +func (sw *Widget) graphicsTab() *container.TabItem { + return tab("Graphics", theme.ColorPaletteIcon(), + section("Renderers", + leftLabeled("Plot renderer", sw.plotRendererSelect), + leftLabeled("Mesh renderer", sw.meshRendererSelect), + ), + section("Dashboard", + leftIcon(theme.MoveUpIcon(), sw.swapRPMandSpeed), + leftIcon(theme.InfoIcon(), sw.useMPH), + ), + ) +} + +func (sw *Widget) canTab() *container.TabItem { + fixedLabel := func(text string) fyne.CanvasObject { + return xlayout.NewFixedWidth(70, widget.NewLabel(text)) + } + return tab("CAN", theme.ComputerIcon(), + section("Adapter", + container.NewBorder(nil, nil, fixedLabel("Adapter"), sw.debugCheckbox, sw.adapterSelector), + container.NewBorder(nil, nil, fixedLabel("Port"), sw.refreshBtn, sw.portSelector), + container.NewBorder(nil, nil, fixedLabel("Info"), nil, sw.portDescription), + container.NewBorder(nil, nil, fixedLabel("Speed"), nil, sw.speedSelector), + ), + ) +} + +func (sw *Widget) loggingTab() *container.TabItem { + logFolderButtons := container.NewGridWithColumns(2, + widget.NewButtonWithIcon("Reset", theme.ContentClearIcon(), func() { + logPath, err := common.GetLogPath() + if err != nil { + fyne.LogError("Could not get log path", err) + } + sw.logPath.SetText(logPath) + prefLogPath.set(logPath) + }), + widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() { + widgets.SelectFolder(func(dir string) { + sw.logPath.SetText(dir) + prefLogPath.set(dir) + }) + }), + ) + + return tab("Logging", theme.DocumentSaveIcon(), + section("Capture", + container.NewBorder(nil, nil, widget.NewLabel("Logging rate (Hz)"), sw.freqValue, sw.freqSlider), + leftLabeled("Log format", sw.logFormat), + ), + section("Storage", + container.NewBorder(nil, logFolderButtons, widget.NewLabel("Log folder"), nil, sw.logPath), + ), + ) +} + +func (sw *Widget) wblTab() *container.TabItem { + wblPorts := func() []string { + return append([]string{"txbridge", "CAN"}, sw.ListPorts()...) + } + + sw.wblPortLabel = widget.NewLabel("WBL Port") + sw.wblPortSelect = widget.NewSelect(wblPorts(), prefWBLPort.set) + sw.wblPortRefreshButton = widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { + sw.wblPortSelect.Options = wblPorts() + sw.wblPortSelect.Refresh() + }) + + sw.wblADscanner = sw.newADscannerCheck() + + adSymbols := []string{ + "AD_EGR", + "DisplProt.AD_Scanner", + "LambdaScan.AD_Scanner", + "LambdaScan.AD_Scanner2", + } + sw.wblADScannerSymbol = widget.NewSelect(adSymbols, prefWBLADScannerSymbol.set) + sw.wblADScannerSymbol.SetSelected(sw.GetADScannerSymbolName()) + + image := container.NewHBox(layout.NewSpacer(), sw.wblImage, layout.NewSpacer()) + + settings := section("Wideband source", + sw.wblSelectContainer, + sw.wblADscanner, + container.NewBorder(nil, nil, sw.wblPortLabel, sw.wblPortRefreshButton, sw.wblPortSelect), + sw.wblADScannerSymbol, + ) + + page := container.NewPadded(container.NewVBox(image, settings)) + return container.NewTabItemWithIcon("WBL", theme.MediaRecordIcon(), page) +} + +func (sw *Widget) adScannerTab() *container.TabItem { + sw.wbleditor = NewWBLEditor(sw.GetWBLSupportPoints(), sw.GetWBLLambdaValues()) + sw.wbleditor.Hide() + return container.NewTabItemWithIcon("AD Scanner", theme.SearchIcon(), sw.wbleditor) +} diff --git a/pkg/widgets/settings/wbleditor.go b/pkg/widgets/settings/wbleditor.go index 43672ccf..72daadc8 100644 --- a/pkg/widgets/settings/wbleditor.go +++ b/pkg/widgets/settings/wbleditor.go @@ -3,7 +3,6 @@ package settings import ( "encoding/json" "fmt" - "image/color" "os" "path/filepath" "sort" @@ -11,10 +10,8 @@ import ( "strings" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/common" @@ -35,6 +32,20 @@ var builtInPresets = map[string]adScannerPreset{ }, } +type adScannerPreset struct { + ECU string `json:"ecu"` + Y []int `json:"y"` + Z []float64 `json:"z"` +} + +// adResolutionForECU returns the AD converter resolution for an ECU type. +func adResolutionForECU(ecu string) int { + if ecu == "T5" { + return 255 + } + return 1023 +} + type mapRow struct { y int z float64 @@ -55,7 +66,6 @@ type WBLEditor struct { presetSelect *widget.Select graph *graphView adresolution int - // lastEcu string } func NewWBLEditor(yAxis []int, zValues []float64) *WBLEditor { @@ -65,16 +75,7 @@ func NewWBLEditor(yAxis []int, zValues []float64) *WBLEditor { m.rows = append(m.rows, &mapRow{y: yAxis[i], z: zValues[i]}) } m.ExtendBaseWidget(m) - - switch fyne.CurrentApp().Preferences().String(prefsLastADScannerECU) { - case "T5": - m.adresolution = 255 - case "T7", "T8": - m.adresolution = 1023 - default: - m.adresolution = 1023 - } - + m.adresolution = adResolutionForECU(prefLastADScannerECU.get()) return m } @@ -105,7 +106,7 @@ func (m *WBLEditor) buildRow(r *mapRow) { if err != nil { return } - r.y = clampInt(m.yFromVolt(v), yMin, yMax) + r.y = common.Clamp(m.yFromVolt(v), yMin, yMax) r.ye.Text = strconv.Itoa(r.y) r.ye.Refresh() m.refreshGraph() @@ -119,7 +120,7 @@ func (m *WBLEditor) buildRow(r *mapRow) { if err != nil { return } - r.y = clampInt(v, yMin, yMax) + r.y = common.Clamp(v, yMin, yMax) r.vo.Text = fmt.Sprintf("%.2f", m.voltFromY(r.y)) r.vo.Refresh() m.refreshGraph() @@ -134,7 +135,7 @@ func (m *WBLEditor) buildRow(r *mapRow) { if err != nil { return } - r.z = clampFloat(v, zMin, zMax) + r.z = common.Clamp(v, zMin, zMax) m.refreshGraph() m.save() } @@ -179,6 +180,26 @@ func (m *WBLEditor) removeRow(r *mapRow) { m.refreshGraph() } +// setRows replaces all rows with the supplied y/z pairs, rebuilding their +// widgets and the rows container (used when loading presets at runtime). +func (m *WBLEditor) setRows(yAxis []int, zValues []float64) { + m.rows = nil + n := min(len(yAxis), len(zValues)) + for i := range n { + r := &mapRow{y: yAxis[i], z: zValues[i]} + m.buildRow(r) + m.rows = append(m.rows, r) + } + if m.rowsBox != nil { + m.rowsBox.Objects = nil + for _, r := range m.rows { + m.rowsBox.Add(r.hb) + } + m.rowsBox.Refresh() + } + m.refreshGraph() +} + func (m *WBLEditor) refreshGraph() { if m.graph != nil { m.graph.Refresh() @@ -186,8 +207,8 @@ func (m *WBLEditor) refreshGraph() { } func (m *WBLEditor) save() { - fyne.CurrentApp().Preferences().SetIntList(prefsWBLSupportPoints, m.YAxis()) - fyne.CurrentApp().Preferences().SetFloatList(prefsWBLLambdaValues, m.ZValues()) + prefWBLSupportPoints.set(m.YAxis()) + prefWBLLambdaValues.set(m.ZValues()) } // updateRowEntries writes a row's current y/z to its entries without @@ -240,20 +261,13 @@ func (m *WBLEditor) CreateRenderer() fyne.WidgetRenderer { m.graph = newGraphView(m) m.presetSelect = widget.NewSelect(m.listPresets(), m.loadPreset) - - lastPreset := fyne.CurrentApp().Preferences().String(prefsLastADScannerPreset) - if lastPreset != "" { + if lastPreset := prefLastADScannerPreset.get(); lastPreset != "" { m.presetSelect.Selected = lastPreset } m.ecuSelect = widget.NewSelect([]string{"T5", "T7", "T8"}, func(s string) { - switch s { - case "T5": - m.adresolution = 255 - case "T7", "T8": - m.adresolution = 1023 - } - fyne.CurrentApp().Preferences().SetString(prefsLastADScannerECU, s) + m.adresolution = adResolutionForECU(s) + prefLastADScannerECU.set(s) for _, r := range m.rows { if r.vo != nil { r.vo.Text = fmt.Sprintf("%.2f", m.voltFromY(r.y)) @@ -261,7 +275,7 @@ func (m *WBLEditor) CreateRenderer() fyne.WidgetRenderer { } } }) - m.ecuSelect.Selected = fyne.CurrentApp().Preferences().StringWithFallback(prefsLastADScannerECU, "T7") + m.ecuSelect.Selected = prefLastADScannerECU.get() top := container.NewBorder( nil, @@ -275,11 +289,7 @@ func (m *WBLEditor) CreateRenderer() fyne.WidgetRenderer { m.deletePreset(m.presetSelect.Selected) m.refreshPresets() }), - widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { - presets := m.listPresets() - m.presetSelect.Options = presets - m.presetSelect.Refresh() - }), + widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), m.refreshPresets), ), m.presetSelect, ) @@ -290,10 +300,7 @@ func (m *WBLEditor) CreateRenderer() fyne.WidgetRenderer { bottom, nil, nil, - container.NewHSplit( - left, - m.graph, - ), + container.NewHSplit(left, m.graph), ) return widget.NewSimpleRenderer(view) } @@ -329,12 +336,6 @@ func (m *WBLEditor) listPresets() []string { return presets } -type adScannerPreset struct { - ECU string `json:"ecu"` - Y []int `json:"y"` - Z []float64 `json:"z"` -} - func (m *WBLEditor) savePreset() { name := widget.NewEntry() @@ -351,23 +352,20 @@ func (m *WBLEditor) savePreset() { debug.Log(err.Error()) return } - fname := filepath.Join(layoutPath, name.Text+".json") - var preset adScannerPreset - - preset.ECU = m.ecuSelect.Selected - preset.Y = m.YAxis() - preset.Z = m.ZValues() + preset := adScannerPreset{ + ECU: m.ecuSelect.Selected, + Y: m.YAxis(), + Z: m.ZValues(), + } - f, err := os.Create(fname) + f, err := os.Create(filepath.Join(layoutPath, name.Text+".json")) if err != nil { debug.Log(err.Error()) return } defer f.Close() - encoder := json.NewEncoder(f) - // encoder.SetIndent("", " ") - if err := encoder.Encode(preset); err != nil { + if err := json.NewEncoder(f).Encode(preset); err != nil { debug.Log(err.Error()) return } @@ -386,24 +384,9 @@ func (m *WBLEditor) loadPreset(name string) { debug.Log("loading AD preset: " + name) if preset, ok := builtInPresets[name]; ok { - m.rows = nil - for i := range preset.Y { - r := &mapRow{y: preset.Y[i], z: preset.Z[i]} - m.buildRow(r) - m.rows = append(m.rows, r) - } - if m.rowsBox != nil { - m.rowsBox.Objects = nil - for _, r := range m.rows { - m.rowsBox.Add(r.hb) - } - m.rowsBox.Refresh() - } - m.refreshGraph() - + m.setRows(preset.Y, preset.Z) m.ecuSelect.SetSelected(preset.ECU) - - fyne.CurrentApp().Preferences().SetString(prefsLastADScannerPreset, name) + prefLastADScannerPreset.set(name) m.save() return } @@ -428,21 +411,8 @@ func (m *WBLEditor) loadPreset(name string) { return } - m.rows = nil - for i := range preset.Y { - r := &mapRow{y: preset.Y[i], z: preset.Z[i]} - m.buildRow(r) - m.rows = append(m.rows, r) - } - if m.rowsBox != nil { - m.rowsBox.Objects = nil - for _, r := range m.rows { - m.rowsBox.Add(r.hb) - } - m.rowsBox.Refresh() - } - m.refreshGraph() - fyne.CurrentApp().Preferences().SetString(prefsLastADScannerPreset, name) + m.setRows(preset.Y, preset.Z) + prefLastADScannerPreset.set(name) m.save() } @@ -452,317 +422,8 @@ func (m *WBLEditor) deletePreset(name string) { fyne.LogError("Could not get layout path", err) return } - err = os.Remove(filepath.Join(layoutPath, name+".json")) - if err != nil { + if err := os.Remove(filepath.Join(layoutPath, name+".json")); err != nil { fyne.LogError("Could not delete preset file", err) return } } - -// --- graph view (native fyne primitives) ----------------------------------- - -var ( - bgColor = color.NRGBA{R: 24, G: 24, B: 28, A: 255} - gridColor = color.NRGBA{R: 60, G: 60, B: 68, A: 255} - axisColor = color.NRGBA{R: 140, G: 140, B: 150, A: 255} - lineColor = color.NRGBA{R: 80, G: 200, B: 120, A: 255} - pointColor = color.NRGBA{R: 240, G: 200, B: 60, A: 255} - pointEdge = color.NRGBA{R: 0, G: 0, B: 0, A: 255} -) - -const ( - graphMargin = 16 - pointSize = 12 - minGraph = 280 - - yMin = 0 - yMax = 1023 - zMin = 0.0 - zMax = 1.5 -) - -type graphView struct { - widget.BaseWidget - editor *WBLEditor - r *graphRenderer -} - -func newGraphView(editor *WBLEditor) *graphView { - g := &graphView{editor: editor} - g.ExtendBaseWidget(g) - return g -} - -func (g *graphView) CreateRenderer() fyne.WidgetRenderer { - r := &graphRenderer{g: g} - r.bg = canvas.NewRectangle(bgColor) - g.r = r - r.rebuild() - return r -} - -type graphRenderer struct { - g *graphView - size fyne.Size - - bg *canvas.Rectangle - gridLines []*canvas.Line - axes []*canvas.Line - dataLines []*canvas.Line - points []*draggablePoint - - // cached mapping from last layout (used by drag math) - x0, y0, x1, y1 float32 - minYv, maxYv int - minZv, maxZv float64 -} - -func (r *graphRenderer) rebuild() { - if len(r.gridLines) == 0 { - for range 6 { - l := canvas.NewLine(gridColor) - l.StrokeWidth = 1 - r.gridLines = append(r.gridLines, l) - } - } - if len(r.axes) == 0 { - for range 2 { - l := canvas.NewLine(axisColor) - l.StrokeWidth = 1 - r.axes = append(r.axes, l) - } - } - - rows := r.g.editor.rows - wantLines := 0 - if n := len(rows); n > 1 { - wantLines = n - 1 - } - for len(r.dataLines) < wantLines { - l := canvas.NewLine(lineColor) - l.StrokeWidth = 2 - r.dataLines = append(r.dataLines, l) - } - r.dataLines = r.dataLines[:wantLines] - - for len(r.points) < len(rows) { - p := newDraggablePoint(r.g) - r.points = append(r.points, p) - } - r.points = r.points[:len(rows)] - for i, row := range rows { - r.points[i].row = row - } -} - -func (r *graphRenderer) Layout(size fyne.Size) { - r.size = size - r.bg.Resize(size) - r.layoutGraph() -} - -func (r *graphRenderer) layoutGraph() { - w := r.size.Width - h := r.size.Height - if w <= 0 || h <= 0 { - return - } - - r.x0 = float32(graphMargin) - r.y0 = float32(graphMargin) - r.x1 = w - graphMargin - r.y1 = h - graphMargin - - for i := range 3 { - gy := r.y0 + (r.y1-r.y0)*float32(i+1)/4 - gh := r.gridLines[i] - gh.Position1 = fyne.NewPos(r.x0, gy) - gh.Position2 = fyne.NewPos(r.x1, gy) - gh.Refresh() - - gx := r.x0 + (r.x1-r.x0)*float32(i+1)/4 - gv := r.gridLines[3+i] - gv.Position1 = fyne.NewPos(gx, r.y0) - gv.Position2 = fyne.NewPos(gx, r.y1) - gv.Refresh() - } - - r.axes[0].Position1 = fyne.NewPos(r.x0, r.y1) - r.axes[0].Position2 = fyne.NewPos(r.x1, r.y1) - r.axes[0].Refresh() - r.axes[1].Position1 = fyne.NewPos(r.x0, r.y0) - r.axes[1].Position2 = fyne.NewPos(r.x0, r.y1) - r.axes[1].Refresh() - - rows := r.g.editor.rows - if len(rows) == 0 { - r.minYv, r.maxYv = yMin, yMax - r.minZv, r.maxZv = zMin, zMax - return - } - - minY, maxY := rows[0].y, rows[0].y - minZ, maxZ := rows[0].z, rows[0].z - for _, row := range rows { - if row.y < minY { - minY = row.y - } - if row.y > maxY { - maxY = row.y - } - if row.z < minZ { - minZ = row.z - } - if row.z > maxZ { - maxZ = row.z - } - } - // Enforce a minimum visible span so dragging stays responsive when - // points are clustered and so the graph doesn't degenerate to a point. - const minYSpan, minZSpan = 50, 0.2 - if maxY-minY < minYSpan { - mid := (maxY + minY) / 2 - minY = clampInt(mid-minYSpan/2, yMin, yMax-minYSpan) - maxY = minY + minYSpan - } - if maxZ-minZ < minZSpan { - mid := (maxZ + minZ) / 2 - minZ = clampFloat(mid-minZSpan/2, zMin, zMax-minZSpan) - maxZ = minZ + minZSpan - } - r.minYv, r.maxYv = minY, maxY - r.minZv, r.maxZv = minZ, maxZ - - type pt struct{ x, y float32 } - pts := make([]pt, len(rows)) - for i, row := range rows { - fx := float32(row.y-minY) / float32(maxY-minY) - fy := float32((row.z - minZ) / (maxZ - minZ)) - pts[i].x = r.x0 + fx*(r.x1-r.x0) - pts[i].y = r.y1 - fy*(r.y1-r.y0) - } - - for i := 1; i < len(pts); i++ { - l := r.dataLines[i-1] - l.Position1 = fyne.NewPos(pts[i-1].x, pts[i-1].y) - l.Position2 = fyne.NewPos(pts[i].x, pts[i].y) - l.Refresh() - } - - half := float32(pointSize) / 2 - for i, p := range pts { - dp := r.points[i] - dp.Resize(fyne.NewSize(pointSize, pointSize)) - dp.Move(fyne.NewPos(p.x-half, p.y-half)) - dp.Refresh() - } -} - -func (r *graphRenderer) Refresh() { - r.bg.FillColor = bgColor - r.bg.Refresh() - r.rebuild() - r.layoutGraph() - canvas.Refresh(r.g) -} - -func (r *graphRenderer) Objects() []fyne.CanvasObject { - objs := make([]fyne.CanvasObject, 0, 1+len(r.gridLines)+len(r.axes)+len(r.dataLines)+len(r.points)) - objs = append(objs, r.bg) - for _, l := range r.gridLines { - objs = append(objs, l) - } - for _, l := range r.axes { - objs = append(objs, l) - } - for _, l := range r.dataLines { - objs = append(objs, l) - } - for _, p := range r.points { - objs = append(objs, p) - } - return objs -} - -func (r *graphRenderer) MinSize() fyne.Size { - return fyne.NewSize(minGraph, minGraph) -} - -func (r *graphRenderer) Destroy() {} - -// --- draggable point ------------------------------------------------------- - -type draggablePoint struct { - widget.BaseWidget - g *graphView - row *mapRow - accY float64 // fractional accumulator for integer y axis -} - -func newDraggablePoint(g *graphView) *draggablePoint { - p := &draggablePoint{g: g} - p.ExtendBaseWidget(p) - return p -} - -func (p *draggablePoint) CreateRenderer() fyne.WidgetRenderer { - rect := canvas.NewRectangle(pointColor) - rect.StrokeColor = pointEdge - rect.StrokeWidth = 1 - return widget.NewSimpleRenderer(rect) -} - -func (p *draggablePoint) Cursor() desktop.Cursor { - return desktop.PointerCursor -} - -func (p *draggablePoint) Dragged(e *fyne.DragEvent) { - r := p.g.r - if r == nil || p.row == nil { - return - } - w := r.x1 - r.x0 - h := r.y1 - r.y0 - if w <= 0 || h <= 0 { - return - } - - // pixel delta → value delta using last layout's data range - dY := float64(e.Dragged.DX) / float64(w) * float64(r.maxYv-r.minYv) - dZ := -float64(e.Dragged.DY) / float64(h) * (r.maxZv - r.minZv) - - p.accY += dY - step := int(p.accY) - p.accY -= float64(step) - - p.row.y = clampInt(p.row.y+step, yMin, yMax) - p.row.z = clampFloat(p.row.z+dZ, zMin, zMax) - - p.g.editor.updateRowEntries(p.row) - p.g.Refresh() -} - -func (p *draggablePoint) DragEnd() { - p.accY = 0 - p.g.editor.save() -} - -func clampInt(v, lo, hi int) int { - if v < lo { - return lo - } - if v > hi { - return hi - } - return v -} - -func clampFloat(v, lo, hi float64) float64 { - if v < lo { - return lo - } - if v > hi { - return hi - } - return v -} diff --git a/pkg/widgets/settings/wblgraph.go b/pkg/widgets/settings/wblgraph.go new file mode 100644 index 00000000..ba2f4640 --- /dev/null +++ b/pkg/widgets/settings/wblgraph.go @@ -0,0 +1,307 @@ +package settings + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/widget" + "github.com/roffe/txlogger/pkg/common" +) + +// --- graph view (native fyne primitives) ----------------------------------- + +var ( + bgColor = color.NRGBA{R: 24, G: 24, B: 28, A: 255} + gridColor = color.NRGBA{R: 60, G: 60, B: 68, A: 255} + axisColor = color.NRGBA{R: 140, G: 140, B: 150, A: 255} + lineColor = color.NRGBA{R: 80, G: 200, B: 120, A: 255} + pointColor = color.NRGBA{R: 240, G: 200, B: 60, A: 255} + pointEdge = color.NRGBA{R: 0, G: 0, B: 0, A: 255} +) + +const ( + graphMargin = 16 + pointSize = 12 + minGraphWidth = 360 + minGraphHeight = 280 + + yMin = 0 + yMax = 1023 + zMin = 0.0 + zMax = 1.5 +) + +type graphView struct { + widget.BaseWidget + editor *WBLEditor + r *graphRenderer +} + +func newGraphView(editor *WBLEditor) *graphView { + g := &graphView{editor: editor} + g.ExtendBaseWidget(g) + return g +} + +func (g *graphView) CreateRenderer() fyne.WidgetRenderer { + r := &graphRenderer{g: g} + r.bg = canvas.NewRectangle(bgColor) + g.r = r + r.rebuild() + return r +} + +type graphRenderer struct { + g *graphView + size fyne.Size + + bg *canvas.Rectangle + gridLines []*canvas.Line + axes []*canvas.Line + dataLines []*canvas.Line + points []*draggablePoint + + // cached mapping from last layout (used by drag math) + x0, y0, x1, y1 float32 + minYv, maxYv int + minZv, maxZv float64 + + objects []fyne.CanvasObject +} + +func (r *graphRenderer) rebuild() { + if len(r.gridLines) == 0 { + for range 6 { + l := canvas.NewLine(gridColor) + l.StrokeWidth = 1 + r.gridLines = append(r.gridLines, l) + } + } + if len(r.axes) == 0 { + for range 2 { + l := canvas.NewLine(axisColor) + l.StrokeWidth = 1 + r.axes = append(r.axes, l) + } + } + + rows := r.g.editor.rows + wantLines := 0 + if n := len(rows); n > 1 { + wantLines = n - 1 + } + for len(r.dataLines) < wantLines { + l := canvas.NewLine(lineColor) + l.StrokeWidth = 2 + r.dataLines = append(r.dataLines, l) + } + r.dataLines = r.dataLines[:wantLines] + + for len(r.points) < len(rows) { + p := newDraggablePoint(r.g) + r.points = append(r.points, p) + } + r.points = r.points[:len(rows)] + for i, row := range rows { + r.points[i].row = row + } + + // dataLines/points may have changed length; force Objects() to rebuild. + r.objects = nil +} + +func (r *graphRenderer) Layout(size fyne.Size) { + r.size = size + r.bg.Resize(size) + r.layoutGraph() +} + +func (r *graphRenderer) layoutGraph() { + w := r.size.Width + h := r.size.Height + if w <= 0 || h <= 0 { + return + } + + r.x0 = float32(graphMargin) + r.y0 = float32(graphMargin) + r.x1 = w - graphMargin + r.y1 = h - graphMargin + + for i := range 3 { + gy := r.y0 + (r.y1-r.y0)*float32(i+1)/4 + gh := r.gridLines[i] + gh.Position1 = fyne.NewPos(r.x0, gy) + gh.Position2 = fyne.NewPos(r.x1, gy) + gh.Refresh() + + gx := r.x0 + (r.x1-r.x0)*float32(i+1)/4 + gv := r.gridLines[3+i] + gv.Position1 = fyne.NewPos(gx, r.y0) + gv.Position2 = fyne.NewPos(gx, r.y1) + gv.Refresh() + } + + r.axes[0].Position1 = fyne.NewPos(r.x0, r.y1) + r.axes[0].Position2 = fyne.NewPos(r.x1, r.y1) + r.axes[0].Refresh() + r.axes[1].Position1 = fyne.NewPos(r.x0, r.y0) + r.axes[1].Position2 = fyne.NewPos(r.x0, r.y1) + r.axes[1].Refresh() + + rows := r.g.editor.rows + if len(rows) == 0 { + r.minYv, r.maxYv = yMin, yMax + r.minZv, r.maxZv = zMin, zMax + return + } + + minY, maxY := rows[0].y, rows[0].y + minZ, maxZ := rows[0].z, rows[0].z + for _, row := range rows { + if row.y < minY { + minY = row.y + } + if row.y > maxY { + maxY = row.y + } + if row.z < minZ { + minZ = row.z + } + if row.z > maxZ { + maxZ = row.z + } + } + // Enforce a minimum visible span so dragging stays responsive when + // points are clustered and so the graph doesn't degenerate to a point. + const minYSpan, minZSpan = 50, 0.2 + if maxY-minY < minYSpan { + mid := (maxY + minY) / 2 + minY = common.Clamp(mid-minYSpan/2, yMin, yMax-minYSpan) + maxY = minY + minYSpan + } + if maxZ-minZ < minZSpan { + mid := (maxZ + minZ) / 2 + minZ = common.Clamp(mid-minZSpan/2, zMin, zMax-minZSpan) + maxZ = minZ + minZSpan + } + r.minYv, r.maxYv = minY, maxY + r.minZv, r.maxZv = minZ, maxZ + + type pt struct{ x, y float32 } + pts := make([]pt, len(rows)) + for i, row := range rows { + fx := float32(row.y-minY) / float32(maxY-minY) + fy := float32((row.z - minZ) / (maxZ - minZ)) + pts[i].x = r.x0 + fx*(r.x1-r.x0) + pts[i].y = r.y1 - fy*(r.y1-r.y0) + } + + for i := 1; i < len(pts); i++ { + l := r.dataLines[i-1] + l.Position1 = fyne.NewPos(pts[i-1].x, pts[i-1].y) + l.Position2 = fyne.NewPos(pts[i].x, pts[i].y) + l.Refresh() + } + + half := float32(pointSize) / 2 + for i, p := range pts { + dp := r.points[i] + dp.Resize(fyne.NewSize(pointSize, pointSize)) + dp.Move(fyne.NewPos(p.x-half, p.y-half)) + dp.Refresh() + } +} + +func (r *graphRenderer) Refresh() { + r.bg.FillColor = bgColor + r.bg.Refresh() + r.rebuild() + r.layoutGraph() + canvas.Refresh(r.g) +} + +func (r *graphRenderer) Objects() []fyne.CanvasObject { + if r.objects == nil { + r.objects = make([]fyne.CanvasObject, 0, 1+len(r.gridLines)+len(r.axes)+len(r.dataLines)+len(r.points)) + r.objects = append(r.objects, r.bg) + for _, l := range r.gridLines { + r.objects = append(r.objects, l) + } + for _, l := range r.axes { + r.objects = append(r.objects, l) + } + for _, l := range r.dataLines { + r.objects = append(r.objects, l) + } + for _, p := range r.points { + r.objects = append(r.objects, p) + } + } + return r.objects +} + +func (r *graphRenderer) MinSize() fyne.Size { + return fyne.NewSize(minGraphWidth, minGraphHeight) +} + +func (r *graphRenderer) Destroy() {} + +// --- draggable point ------------------------------------------------------- + +type draggablePoint struct { + widget.BaseWidget + g *graphView + row *mapRow + accY float64 // fractional accumulator for integer y axis +} + +func newDraggablePoint(g *graphView) *draggablePoint { + p := &draggablePoint{g: g} + p.ExtendBaseWidget(p) + return p +} + +func (p *draggablePoint) CreateRenderer() fyne.WidgetRenderer { + rect := canvas.NewRectangle(pointColor) + rect.StrokeColor = pointEdge + rect.StrokeWidth = 1 + return widget.NewSimpleRenderer(rect) +} + +func (p *draggablePoint) Cursor() desktop.Cursor { + return desktop.PointerCursor +} + +func (p *draggablePoint) Dragged(e *fyne.DragEvent) { + r := p.g.r + if r == nil || p.row == nil { + return + } + w := r.x1 - r.x0 + h := r.y1 - r.y0 + if w <= 0 || h <= 0 { + return + } + + // pixel delta → value delta using last layout's data range + dY := float64(e.Dragged.DX) / float64(w) * float64(r.maxYv-r.minYv) + dZ := -float64(e.Dragged.DY) / float64(h) * (r.maxZv - r.minZv) + + p.accY += dY + step := int(p.accY) + p.accY -= float64(step) + + p.row.y = common.Clamp(p.row.y+step, yMin, yMax) + p.row.z = common.Clamp(p.row.z+dZ, zMin, zMax) + + p.g.editor.updateRowEntries(p.row) + p.g.Refresh() +} + +func (p *draggablePoint) DragEnd() { + p.accY = 0 + p.g.editor.save() +} diff --git a/pkg/widgets/symbolbrowser/symbolbrowser.go b/pkg/widgets/symbolbrowser/symbolbrowser.go new file mode 100644 index 00000000..33c4d97b --- /dev/null +++ b/pkg/widgets/symbolbrowser/symbolbrowser.go @@ -0,0 +1,232 @@ +package symbolbrowser + +import ( + "fmt" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + xlayout "fyne.io/x/fyne/layout" + symbol "github.com/roffe/ecusymbol" +) + +var _ fyne.Widget = (*Widget)(nil) + +// Column proportions, shared by the header and every row so they line up. +// Name, Address, SRAM offset, Length, Type, Action(Open). +var columnSizes = []float64{0.34, 0.15, 0.15, 0.09, 0.15, 0.12} + +// Widget lists every symbol in the loaded binary together with its address, +// sram offset, length and type, and offers a button to open each one as a map. +type Widget struct { + widget.BaseWidget + + getFW func() symbol.SymbolCollection + getECU func() symbol.ECUType + openMap func(symbol.ECUType, string, string) + err func(error) + + search *widget.Entry + countLbl *widget.Label + list *widget.List + + all []*symbol.Symbol + filtered []*symbol.Symbol +} + +func New(getFW func() symbol.SymbolCollection, getECU func() symbol.ECUType, openMap func(symbol.ECUType, string, string), err func(error)) *Widget { + w := &Widget{ + getFW: getFW, + getECU: getECU, + openMap: openMap, + err: err, + } + w.loadSymbols() + w.ExtendBaseWidget(w) + return w +} + +// loadSymbols pulls the current symbol collection from the main window. +func (w *Widget) loadSymbols() { + fw := w.getFW() + if fw == nil { + w.all = nil + w.filtered = nil + return + } + w.all = fw.Symbols() + w.filtered = w.all +} + +func (w *Widget) applyFilter(q string) { + q = strings.ToLower(strings.TrimSpace(q)) + if q == "" { + w.filtered = w.all + } else { + out := make([]*symbol.Symbol, 0, len(w.all)) + for _, s := range w.all { + if strings.Contains(strings.ToLower(s.Name), q) { + out = append(out, s) + } + } + w.filtered = out + } + if w.countLbl != nil { + w.countLbl.SetText(w.countText()) + } + if w.list != nil { + w.list.Refresh() + w.list.ScrollToTop() + } +} + +func (w *Widget) countText() string { + if len(w.filtered) == len(w.all) { + return fmt.Sprintf("%d symbols", len(w.all)) + } + return fmt.Sprintf("%d / %d symbols", len(w.filtered), len(w.all)) +} + +func (w *Widget) openSymbol(sym *symbol.Symbol) { + if w.openMap == nil || sym == nil { + return + } + w.openMap(w.getECU(), "", sym.Name) +} + +func (w *Widget) header() fyne.CanvasObject { + mk := func(s string) *widget.Label { + return widget.NewLabelWithStyle(s, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + } + return container.New( + xlayout.NewHPortion(columnSizes), + mk("Name"), + mk("Address"), + mk("SRAM offset"), + mk("Length"), + mk("Type"), + mk("Action"), + ) +} + +func (w *Widget) CreateRenderer() fyne.WidgetRenderer { + w.search = widget.NewEntry() + w.search.SetPlaceHolder("Filter by name...") + w.search.OnChanged = w.applyFilter + + w.countLbl = widget.NewLabel(w.countText()) + + refresh := widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { + w.loadSymbols() + w.applyFilter(w.search.Text) + }) + + w.list = widget.NewList( + func() int { return len(w.filtered) }, + func() fyne.CanvasObject { return newSymbolRow(w.openSymbol) }, + func(i widget.ListItemID, o fyne.CanvasObject) { + if i < 0 || i >= len(w.filtered) { + return + } + o.(*symbolRow).set(w.filtered[i]) + }, + ) + + top := container.NewBorder(nil, nil, nil, container.NewHBox(w.countLbl, refresh), w.search) + + return widget.NewSimpleRenderer(container.NewBorder( + container.NewVBox(top, w.header()), + nil, + nil, + nil, + w.list, + )) +} + +// typeString renders the Trionic type bitfield as readable flags. +func typeString(t uint8) string { + var parts []string + if t&symbol.SIGNED != 0 { + parts = append(parts, "signed") + } + if t&symbol.KONST != 0 { + parts = append(parts, "const") + } + if t&symbol.CHAR != 0 { + parts = append(parts, "char") + } + if t&symbol.LONG != 0 { + parts = append(parts, "long") + } + if t&symbol.BITFIELD != 0 { + parts = append(parts, "bitfield") + } + if t&symbol.STRUCT != 0 { + parts = append(parts, "struct") + } + if len(parts) == 0 { + return fmt.Sprintf("0x%02X", t) + } + return strings.Join(parts, "|") +} + +var _ fyne.Widget = (*symbolRow)(nil) + +type symbolRow struct { + widget.BaseWidget + + name *widget.Label + addr *widget.Label + sram *widget.Label + length *widget.Label + typ *widget.Label + open *widget.Button + + sym *symbol.Symbol + onOpen func(*symbol.Symbol) +} + +func newSymbolRow(onOpen func(*symbol.Symbol)) *symbolRow { + r := &symbolRow{ + name: widget.NewLabel(""), + addr: widget.NewLabel(""), + sram: widget.NewLabel(""), + length: widget.NewLabel(""), + typ: widget.NewLabel(""), + onOpen: onOpen, + } + r.name.Truncation = fyne.TextTruncateEllipsis + r.name.Selectable = true + r.typ.Truncation = fyne.TextTruncateEllipsis + r.open = widget.NewButtonWithIcon("Open", theme.GridIcon(), func() { + if r.sym != nil && r.onOpen != nil { + r.onOpen(r.sym) + } + }) + r.ExtendBaseWidget(r) + return r +} + +func (r *symbolRow) set(sym *symbol.Symbol) { + r.sym = sym + r.name.SetText(sym.Name) + r.addr.SetText(fmt.Sprintf("$%06X", sym.Address)) + r.sram.SetText(fmt.Sprintf("$%06X", sym.SramOffset)) + r.length.SetText(strconv.Itoa(int(sym.Length))) + r.typ.SetText(typeString(sym.Type)) +} + +func (r *symbolRow) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(container.New( + xlayout.NewHPortion(columnSizes), + r.name, + r.addr, + r.sram, + r.length, + r.typ, + r.open, + )) +} diff --git a/pkg/widgets/symbolcompare/symbolcompare.go b/pkg/widgets/symbolcompare/symbolcompare.go new file mode 100644 index 00000000..a71680cc --- /dev/null +++ b/pkg/widgets/symbolcompare/symbolcompare.go @@ -0,0 +1,63 @@ +package symbolcompare + +import ( + "fmt" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" +) + +// Config drives the compare list. Diffs is the (already sorted) list of symbol +// names that differ between the two binaries. OnShowSideBySide opens the actual +// map view, which lives in the windows package so it can reuse MapViewer. +type Config struct { + Diffs []string + OnShowSideBySide func(name string) +} + +// New returns a list of differing symbols. Double-click a row to compare it side +// by side. +func New(cfg *Config) fyne.CanvasObject { + list := widget.NewList( + func() int { return len(cfg.Diffs) }, + func() fyne.CanvasObject { return newCompareRow(cfg) }, + func(i widget.ListItemID, o fyne.CanvasObject) { + o.(*compareRow).setName(cfg.Diffs[i]) + }, + ) + + header := widget.NewLabel(fmt.Sprintf("%d symbols differ", len(cfg.Diffs))) + return container.NewBorder(header, nil, nil, nil, list) +} + +// compareRow is a list row that reacts to double-tap. It does not implement +// Tapped, so single taps still propagate to the List for selection +// highlighting. +type compareRow struct { + widget.BaseWidget + label *widget.Label + name string + cfg *Config +} + +func newCompareRow(cfg *Config) *compareRow { + r := &compareRow{label: widget.NewLabel(""), cfg: cfg} + r.ExtendBaseWidget(r) + return r +} + +func (r *compareRow) setName(name string) { + r.name = name + r.label.SetText(name) +} + +func (r *compareRow) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(r.label) +} + +func (r *compareRow) DoubleTapped(_ *fyne.PointEvent) { + if r.name != "" && r.cfg.OnShowSideBySide != nil { + r.cfg.OnShowSideBySide(r.name) + } +} diff --git a/pkg/widgets/symbollist/symbollist.go b/pkg/widgets/symbollist/symbollist.go index 590c9020..071358ec 100644 --- a/pkg/widgets/symbollist/symbollist.go +++ b/pkg/widgets/symbollist/symbollist.go @@ -2,9 +2,9 @@ package symbollist import ( "image/color" - "sort" + "math" + "slices" "strconv" - "strings" "sync" "fyne.io/fyne/v2" @@ -65,22 +65,50 @@ func (s *Widget) SetColorBlindMode(mode colors.ColorBlindMode) { } func (s *Widget) UpdateBars(enabled bool) { + s.mu.Lock() + defer s.mu.Unlock() s.updateBars = enabled } func (s *Widget) Names() []string { - names := make([]string, len(s.cfg.Symbols)+1) - for i, s := range s.cfg.Symbols { - names[i] = s.Name + s.mu.Lock() + defer s.mu.Unlock() + names := make([]string, 0, len(s.cfg.Symbols)+1) + hasWBL := false + for _, sym := range s.cfg.Symbols { + if sym.Name == datalogger.EXTERNALWBLSYM { + hasWBL = true + } + names = append(names, sym.Name) } - names[len(names)-1] = datalogger.EXTERNALWBLSYM - sort.Slice(names, func(i, j int) bool { - return strings.ToLower(names[i]) < strings.ToLower(names[j]) - }) + if !hasWBL { + names = append(names, datalogger.EXTERNALWBLSYM) + } + slices.SortFunc(names, compareFold) return names } +// compareFold orders ASCII strings case-insensitively without the per-comparison +// allocations of strings.ToLower. +func compareFold(a, b string) int { + for i := 0; i < len(a) && i < len(b); i++ { + ca, cb := a[i], b[i] + if 'A' <= ca && ca <= 'Z' { + ca += 'a' - 'A' + } + if 'A' <= cb && cb <= 'Z' { + cb += 'a' - 'A' + } + if ca != cb { + return int(ca) - int(cb) + } + } + return len(a) - len(b) +} + func (s *Widget) SetValue(name string, value float64) { + s.mu.Lock() + defer s.mu.Unlock() val, found := s.entryMap[name] if found { if value == val.value { @@ -93,22 +121,28 @@ func (s *Widget) SetValue(name string, value float64) { val.max = value } if s.updateBars { - val.valueBarFactor = float32((value - val.min) / (val.max - val.min)) + if span := val.max - val.min; span > 0 { + val.valueBarFactor = float32((value - val.min) / span) + } else { + val.valueBarFactor = 0 + } col := colors.GetColorInterpolation(val.min, val.max, value, s.cfg.ColorBlindMode) col.A = barAlpha val.valueBar.FillColor = col totalWidth := val.symbolName.Size().Width val.valueBar.Resize(fyne.Size{Width: val.valueBarFactor * totalWidth, Height: 26}) } - textValue := strconv.FormatFloat(value, 'f', val.prec, 64) - if textValue != val.lastText { - val.lastText = textValue - val.symbolValue.SetText(textValue) + val.buf = strconv.AppendFloat(val.buf[:0], value, 'f', val.prec, 64) + if string(val.buf) != val.lastText { + val.lastText = string(val.buf) + val.symbolValue.SetText(val.lastText) } } } func (s *Widget) Disable() { + s.mu.Lock() + defer s.mu.Unlock() for _, e := range s.entries { // e.symbolCorrectionfactor.Disable() e.deleteBTN.Disable() @@ -116,6 +150,8 @@ func (s *Widget) Disable() { } func (s *Widget) Enable() { + s.mu.Lock() + defer s.mu.Unlock() for _, e := range s.entries { // e.symbolCorrectionfactor.Enable() e.deleteBTN.Enable() @@ -126,6 +162,7 @@ func (s *Widget) Add(symbols ...*symbol.Symbol) { s.mu.Lock() defer s.mu.Unlock() + added := false for _, sym := range symbols { if _, found := s.entryMap[sym.Name]; found { continue @@ -156,13 +193,26 @@ func (s *Widget) Add(symbols ...*symbol.Symbol) { entry := s.newSymbolWidgetEntry(sym, deleteFunc) s.cfg.Symbols = append(s.cfg.Symbols, sym) s.entries = append(s.entries, entry) - s.container.Add(entry) + // append directly instead of container.Add to avoid a layout+refresh per entry + s.container.Objects = append(s.container.Objects, entry) s.entryMap[sym.Name] = entry + added = true + } + if added { + s.container.Refresh() } } func (s *Widget) Clear() { + s.mu.Lock() + defer s.mu.Unlock() for _, e := range s.entries { + // NaN sentinel so the next sample always renders, even if it equals + // the last value seen before the clear + e.value = math.NaN() + e.min = 0 + e.max = 0 + e.valueBarFactor = 0 e.lastText = "---" e.symbolValue.SetText("---") } @@ -241,6 +291,9 @@ func (s *Widget) newSymbolWidgetEntry(sym *symbol.Symbol, deleteFunc func(*Symbo symbol: sym, prec: symbol.GetPrecision(sym.Correctionfactor), deleteFunc: deleteFunc, + // NaN compares unequal to everything, so the first sample always + // renders — including an initial value of exactly 0 + value: math.NaN(), } sw.ExtendBaseWidget(sw) sw.symbolName = widget.NewLabel(sw.symbol.Name) @@ -306,6 +359,7 @@ type SymbolWidgetEntry struct { min, max float64 prec int lastText string + buf []byte // scratch for AppendFloat, avoids an allocation per update oldSize fyne.Size @@ -332,12 +386,16 @@ func (sw *SymbolWidgetEntry) SetCorrectionFactor(f float64) { */ func (sw *SymbolWidgetEntry) CreateRenderer() fyne.WidgetRenderer { - return &symbolWidgetEntryRenderer{sw} + return &symbolWidgetEntryRenderer{ + e: sw, + objects: []fyne.CanvasObject{sw.container}, + } // return widget.NewSimpleRenderer(sw.container) } type symbolWidgetEntryRenderer struct { - e *SymbolWidgetEntry + e *SymbolWidgetEntry + objects []fyne.CanvasObject } func (s *symbolWidgetEntryRenderer) Destroy() { @@ -357,17 +415,13 @@ func (s *symbolWidgetEntryRenderer) MinSize() fyne.Size { } func (s *symbolWidgetEntryRenderer) Refresh() { - s.e.symbolName.Refresh() - s.e.symbolValue.Refresh() - // s.e.symbolNumber.Refresh() - // s.e.symbolCorrectionfactor.Refresh() col := colors.GetColorInterpolation(s.e.min, s.e.max, s.e.value, s.e.w.cfg.ColorBlindMode) col.A = barAlpha s.e.valueBar.FillColor = col s.e.valueBar.StrokeColor = col - s.e.valueBar.Refresh() + s.e.container.Refresh() } func (s *symbolWidgetEntryRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{s.e.container} + return s.objects } diff --git a/pkg/widgets/secrettext/secrettext.go b/pkg/widgets/tappabletext/tappabletext.go similarity index 50% rename from pkg/widgets/secrettext/secrettext.go rename to pkg/widgets/tappabletext/tappabletext.go index 78e13041..b6a4c923 100644 --- a/pkg/widgets/secrettext/secrettext.go +++ b/pkg/widgets/tappabletext/tappabletext.go @@ -1,19 +1,15 @@ -package secrettext +package tappabletext import ( "bytes" - "sync" - "time" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/widget" "github.com/hajimehoshi/go-mp3" "github.com/roffe/txlogger/pkg/assets" "github.com/roffe/txlogger/pkg/sound" + "github.com/roffe/txlogger/pkg/widgets/tunnel" ) var _ fyne.Tappable = (*SecretText)(nil) @@ -21,30 +17,26 @@ var _ fyne.Tappable = (*SecretText)(nil) type SecretText struct { *widget.Label tappedTimes int - SecretFunc func() - initOnce sync.Once + Taps int + Func func() } -func New(text string) *SecretText { +func New(text string, taps int, fn func()) *SecretText { label := widget.NewLabel(text) return &SecretText{ Label: label, + Taps: taps, + Func: fn, } } func (s *SecretText) Tapped(*fyne.PointEvent) { s.tappedTimes++ // log.Println("tapped", s.tappedTimes) - if s.tappedTimes >= 10 { - - t := fyne.NewStaticResource("taz.png", assets.Taz) - cv := canvas.NewImageFromResource(t) - cv.ScaleMode = canvas.ImageScaleFastest - cv.SetMinSize(fyne.NewSize(0, 0)) + if s.tappedTimes >= s.Taps { + s.tappedTimes = 0 fileBytesReader := bytes.NewReader(assets.Korvring) - - // Decode file decodedMp3, err := mp3.NewDecoder(fileBytesReader) if err != nil { panic("mp3.NewDecoder failed: " + err.Error()) @@ -54,25 +46,36 @@ func (s *SecretText) Tapped(*fyne.PointEvent) { player.Play() - s.tappedTimes = 0 - if f := s.SecretFunc; f != nil { + if f := s.Func; f != nil { f() } - cont := container.NewStack(cv) - d := dialog.NewCustom("You found the secret", "Leif", cont, fyne.CurrentApp().Driver().AllWindows()[0]) + t := tunnel.New() + t.SetCredits([]string{ + "SAAB", + "MattiasC", + "Dilemma", + "J.K Nilsson", + "Manick", + "Artursson", + "Schottis", + "Chriva", + "Myrtilos", + "Mackan", + "Kalej", + "Bojer", + "TrionicTuning", + "o2o Crew", + }) + + d := dialog.NewCustom("You found the secret", "Leif", t, fyne.CurrentApp().Driver().AllWindows()[0]) d.SetOnClosed(func() { player.Pause() }) d.Show() - an := canvas.NewSizeAnimation(fyne.NewSize(0, 0), fyne.NewSize(370, 386), time.Second, func(size fyne.Size) { - cv.Resize(size) - }) - - an.Start() } } -func (s *SecretText) Cursor() desktop.Cursor { - return desktop.CrosshairCursor -} +//func (s *SecretText) Cursor() desktop.Cursor { +// return desktop.CrosshairCursor +//} diff --git a/pkg/widgets/tunnel/logo.png b/pkg/widgets/tunnel/logo.png new file mode 100644 index 00000000..31f60b00 Binary files /dev/null and b/pkg/widgets/tunnel/logo.png differ diff --git a/pkg/widgets/tunnel/tunnel.go b/pkg/widgets/tunnel/tunnel.go new file mode 100644 index 00000000..62ba9a00 --- /dev/null +++ b/pkg/widgets/tunnel/tunnel.go @@ -0,0 +1,104 @@ +// Package tunnel renders an endless psychedelic 3D tunnel ride entirely on the +// GPU with a single canvas.Shader, modelled on the meshgrid shader backend. +// The viewer rides a retrowave wiremesh vortex like a rollercoaster; all motion +// comes from the "time" uniform that canvas.NewShaderAnimation advances, so the +// CPU does no per-frame work. +package tunnel + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/widget" +) + +var _ fyne.Widget = (*Tunnel)(nil) + +// defaultSize is the requested 500x500 viewport; the widget keeps it as its +// minimum and fills whatever a container hands it, staying circular via the +// short-axis normalisation in the shader. +const defaultSize = 700 + +type Tunnel struct { + widget.BaseWidget + + shader *canvas.Shader + anim *fyne.Animation + + running bool +} + +// New builds a tunnel widget. The animation starts automatically once the +// widget is shown (CreateRenderer) and stops when it is removed (Destroy). +func New() *Tunnel { + t := &Tunnel{} + t.ExtendBaseWidget(t) + t.initShader() + t.anim = canvas.NewShaderAnimation(t.shader) + return t +} + +// Start begins (or resumes) the ride. Safe to call repeatedly. +func (t *Tunnel) Start() { + if t.running || t.anim == nil { + return + } + t.running = true + t.anim.Start() +} + +// Stop freezes the tunnel on its current frame. +func (t *Tunnel) Stop() { + if !t.running || t.anim == nil { + return + } + t.running = false + t.anim.Stop() +} + +// SetSpeed scales how fast the viewer rides into the tunnel. +func (t *Tunnel) SetSpeed(speed float32) { + if t.shader == nil { + return + } + t.shader.Uniforms["speed"] = speed + t.shader.Refresh() +} + +// SetLogoScale sets the bouncing logo's half-size in short-axis units (the +// short axis spans -1..1), e.g. 0.30 makes the logo ~30% of half the viewport. +func (t *Tunnel) SetLogoScale(scale float32) { + if t.shader == nil { + return + } + t.shader.Uniforms["logo_scale"] = scale + t.shader.Refresh() +} + +func (t *Tunnel) CreateRenderer() fyne.WidgetRenderer { + t.Start() + return &tunnelRenderer{t: t} +} + +type tunnelRenderer struct { + t *Tunnel +} + +func (r *tunnelRenderer) Layout(size fyne.Size) { + r.t.shader.Resize(size) +} + +func (r *tunnelRenderer) MinSize() fyne.Size { + return fyne.NewSize(defaultSize, defaultSize) +} + +func (r *tunnelRenderer) Refresh() { + r.t.shader.Refresh() +} + +func (r *tunnelRenderer) Destroy() { + r.t.Stop() +} + +func (r *tunnelRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.t.shader} +} diff --git a/pkg/widgets/tunnel/tunnel_crawl.go b/pkg/widgets/tunnel/tunnel_crawl.go new file mode 100644 index 00000000..0035fdde --- /dev/null +++ b/pkg/widgets/tunnel/tunnel_crawl.go @@ -0,0 +1,94 @@ +package tunnel + +import ( + "image" + "image/color" + "log" + + "fyne.io/fyne/v2/theme" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +// Credits-strip texture layout. Each line gets a fixed-height band; the shader +// samples the strip with a perspective crawl and loops it with fract. +const ( + crawlTexWidth = 640 + crawlLineH = 56 + crawlFontSize = 40 +) + +// SetCredits sets the lines shown as a Star Wars-style crawl that rises from +// the bottom into the tunnel centre and loops forever. An empty string renders +// as a blank line, useful for spacing and section breaks - including a blank +// first/last line, which hides the loop seam. Passing an empty slice removes +// the crawl. +func (t *Tunnel) SetCredits(lines []string) { + if t.shader == nil { + return + } + img := renderCrawl(lines) + if img == nil { + delete(t.shader.Textures, "crawl_tex") + t.shader.Uniforms["crawl_lines"] = 0 + t.shader.Refresh() + return + } + if t.shader.Textures == nil { + t.shader.Textures = make(map[string]image.Image, 2) + } + t.shader.Textures["crawl_tex"] = img + t.shader.Uniforms["crawl_lines"] = float32(len(lines)) + t.shader.Refresh() +} + +// renderCrawl rasterises the credit lines into a tall, centred, transparent +// strip: band i holds line i, with band 0 (image row 0, texture t = 0) the +// first line. The painter uploads image rows verbatim, so the first line sits +// at the far end of the crawl. Returns nil for an empty list. +func renderCrawl(lines []string) *image.RGBA { + if len(lines) == 0 { + return nil + } + face, err := crawlFace() + if err != nil { + log.Printf("tunnel: crawl font: %v", err) + return nil + } + defer face.Close() + + img := image.NewRGBA(image.Rect(0, 0, crawlTexWidth, len(lines)*crawlLineH)) + + m := face.Metrics() + textH := (m.Ascent + m.Descent).Ceil() + topPad := (crawlLineH - textH) / 2 + + d := &font.Drawer{Dst: img, Src: image.NewUniform(color.White), Face: face} + for i, line := range lines { + if line == "" { + continue // blank spacer line + } + adv := font.MeasureString(face, line) + d.Dot = fixed.Point26_6{ + X: (fixed.I(crawlTexWidth) - adv) / 2, // centre horizontally + Y: fixed.I(i*crawlLineH+topPad) + m.Ascent, + } + d.DrawString(line) + } + return img +} + +// crawlFace builds a font face from the app's bundled text font so the crawl +// matches the rest of the UI. +func crawlFace() (font.Face, error) { + ft, err := opentype.Parse(theme.DefaultTextFont().Content()) + if err != nil { + return nil, err + } + return opentype.NewFace(ft, &opentype.FaceOptions{ + Size: crawlFontSize, + DPI: 72, + Hinting: font.HintingFull, + }) +} diff --git a/pkg/widgets/tunnel/tunnel_shader.go b/pkg/widgets/tunnel/tunnel_shader.go new file mode 100644 index 00000000..0a8818a4 --- /dev/null +++ b/pkg/widgets/tunnel/tunnel_shader.go @@ -0,0 +1,254 @@ +package tunnel + +import ( + "bytes" + _ "embed" + "fmt" + "image" + "image/draw" + _ "image/png" + "log" + "sync/atomic" + + "fyne.io/fyne/v2/canvas" +) + +//go:embed logo.png +var logoPNG []byte + +// The whole effect is a single procedural fragment shader: no geometry, no +// textures, no per-frame CPU work beyond the "time" uniform that +// canvas.NewShaderAnimation advances. Each pixel reconstructs an infinite +// tube in polar coordinates - angle around the tube, 1/radius into it - and +// draws a glowing retrowave wireframe of rings and ribs that streams toward +// the viewer. A swaying vanishing point plus a depth-dependent twist give the +// rollercoaster ride; hue cycling over depth and time keeps it psychedelic. + +// tunnelSeq makes each widget's Shader.Name unique so the painter caches one +// compiled program per live widget instead of evicting a shared one. +var tunnelSeq atomic.Int64 + +const tunnelPreludeGL = "#version 110\n" + +const tunnelPreludeES = `#version 100 +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +` + +const tunnelBody = ` +#define PI 3.14159265359 + +uniform vec2 frame_size; // output frame size, device px +uniform vec4 rect_coords; // this object's bounds (x1, x2, y1, y2), device px +uniform float time; // elapsed animation seconds + +// optional tuning knobs (default sensibly if left at 0 by the Go side) +uniform float speed; // forward ride speed +uniform float ring_freq; // rings per depth unit +uniform float rib_freq; // longitudinal ribs around the tube +uniform float logo_scale; // logo billboard half-size, short-axis units + +uniform float crawl_lines; // number of credit lines (0 = no crawl) +uniform float crawl_persp; // floor depth at the bottom edge +uniform float crawl_width; // text block half-width in world units +uniform float crawl_zlines; // credit lines spanned per unit of depth +uniform float crawl_speed; // scroll rate, lines per second + +uniform sampler2D logo_tex; // txlogger logo, premultiplied RGBA +uniform sampler2D crawl_tex; // credits strip, line 0 at the top (t = 0) + +vec3 hsv2rgb(vec3 c) { + vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0); + return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); +} + +// glowing line at the nearest multiple of 1.0 of x, half width hw +float grid_line(float x, float hw) { + float f = fract(x); + float d = min(f, 1.0 - f); + return smoothstep(hw, 0.0, d); +} + +// triangle wave in [-1, 1] with period 1, for DVD-screensaver bouncing +float tri(float x) { + return abs(fract(x) - 0.5) * 4.0 - 1.0; +} + +void main() { + vec2 size = vec2(rect_coords.y - rect_coords.x, rect_coords.w - rect_coords.z); + vec2 p_dev = vec2(gl_FragCoord.x, frame_size.y - gl_FragCoord.y) - rect_coords.xz; + + // the painter expands the quad slightly for edge softness; stay inside + if (p_dev.x < 0.0 || p_dev.y < 0.0 || p_dev.x > size.x || p_dev.y > size.y) { + discard; + } + + float spd = speed > 0.0 ? speed : 1.1; + float rings = ring_freq > 0.0 ? ring_freq : 5.0; + float ribs = rib_freq > 0.0 ? rib_freq : 16.0; + + // centered, aspect-correct coordinates, roughly [-1, 1] on the short axis. + // sc stays pristine for the logo billboard; uv is warped into the tunnel. + vec2 sc = (p_dev - 0.5 * size) / min(size.x, size.y) * 2.0; + vec2 uv = sc; + + // rollercoaster: drift the vanishing point along a couple of detuned + // sinusoids so the track appears to bank and weave. vp is the hole's + // position in screen (sc) space; the bank below rotates around it, so the + // tunnel mouth always sits at sc == vp. The crawl reuses vp as the far + // point its text converges into. + vec2 vp = vec2(sin(time * 0.7) * 0.34 + sin(time * 1.31) * 0.11, + cos(time * 0.53) * 0.30 + cos(time * 1.79) * 0.09); + uv -= vp; + + // bank the whole frame into the curves + float bank = sin(time * 0.61) * 0.55; + float cb = cos(bank), sb = sin(bank); + uv = mat2(cb, -sb, sb, cb) * uv; + + float r = length(uv); + float a = atan(uv.y, uv.x); + + // tube coordinates: depth grows toward the centre, ride forward over time, + // and add a spiral twist with depth for the psytrance vortex + float depth = 1.0 / max(r, 0.0008); + float v = depth + time * spd; + float u = a / PI + depth * 0.18 + time * 0.04; + + // wireframe: cyan rings across the tube, magenta ribs along it. The line + // width shrinks with depth so far geometry naturally thins toward the + // vanishing point. + float lw = clamp(0.02 + r * 0.10, 0.02, 0.22); + float ring = grid_line(v * rings, lw); + float rib = grid_line(u * ribs, lw * 0.9); + + float hue = fract(0.58 + v * 0.025 + u * 0.05 + time * 0.05); + vec3 ringCol = hsv2rgb(vec3(hue, 0.85, 1.0)); + vec3 ribCol = hsv2rgb(vec3(fract(hue + 0.45), 0.9, 1.0)); + + vec3 col = ring * ringCol * 1.3 + rib * ribCol * 1.0; + + // fade the grid out right at the centre so it doesn't smear into a blob + col *= smoothstep(0.0, 0.10, r); + + // pulsing neon "light at the end of the tunnel" + float pulse = 0.55 + 0.45 * sin(time * 2.3); + col += vec3(0.75, 0.30, 1.0) * smoothstep(0.45, 0.0, r) * pulse; + + // dark retrowave haze between the lines so the tube reads as a surface + vec3 haze = mix(vec3(0.03, 0.0, 0.07), vec3(0.12, 0.01, 0.20), + 0.5 + 0.5 * sin(v * 0.5 + time * 0.5)); + col += haze * (1.0 - clamp(ring + rib, 0.0, 1.0)) * smoothstep(0.0, 0.12, r); + + // vignette toward the outer edge + col *= 1.0 - 0.40 * smoothstep(0.7, 1.5, r); + + // --- Star Wars credits crawl: flows up the floor into the moving hole --- + // A floor plane recedes from the bottom edge (near) toward the tunnel + // mouth (far), but its far end now converges on the drifting hole vp + // instead of the screen centre. yh is depth below the moving horizon + // (the floor lives where sc.y > vp.y); z = persp/yh is depth along it. + // The lane centre slides from x = 0 at the bottom to vp.x at the horizon, + // so the text stays anchored where it emerges yet leans and sways toward + // the wandering hole. s narrows the text with depth, and lf scrolls in + // line units that wrap with fract (a blank first/last line hides the loop + // seam) while staying < 0 on the first pass so the strip rises in from the + // bottom. + float yh = sc.y - vp.y; + if (crawl_lines > 0.5 && yh > 0.045) { + float cw = crawl_width > 0.0 ? crawl_width : 0.9; + float cz = crawl_zlines > 0.0 ? crawl_zlines : 3.0; + float cp = crawl_persp > 0.0 ? crawl_persp : 0.45; + float cs = crawl_speed > 0.0 ? crawl_speed : 0.8; + + float z = cp / yh; + // g runs 0 at the bottom anchor to 1 at the horizon. The lane centre + // first leans on a straight line from x = 0 to the hole (vp.x)... + float g = clamp((1.0 - sc.y) / (1.0 - vp.y), 0.0, 1.0); + float centerX = vp.x * g; + // ...then bows sideways so the path curves into the mouth along with + // the tunnel. g*(1-g) pins both ends (bottom anchor and hole) while the + // shared bank phase, plus a depth twist, make deeper sections lead the + // curve the same way the rings bank and spiral. + centerX += 0.9 * g * (1.0 - g) * sin(bank * 2.0 - g * 2.5); + float s = (sc.x - centerX) * z / cw + 0.5; + float lf = (time * cs - z * cz) / crawl_lines; + if (s >= 0.0 && s <= 1.0 && lf >= 0.0) { + float t = fract(lf); + float a = texture2D(crawl_tex, vec2(s, t)).a; + a *= smoothstep(0.045, 0.18, yh) * smoothstep(1.5, 1.0, sc.y); + vec3 crawlCol = vec3(1.0, 0.85, 0.2); // classic crawl yellow + col = mix(col, crawlCol, a); + col += crawlCol * a * 0.25; // soft neon glow + } + } + + // --- spinning, DVD-bouncing txlogger logo billboard, composited on top --- + // DVD-screensaver bounce: detuned triangle waves keep it inside the frame + vec2 lc = vec2(tri(time * 0.27) * 0.58, tri(time * 0.21) * 0.52); + + // grow and shrink as it rides the tunnel toward and away from the viewer + float depthBob = 0.70 + 0.35 * sin(time * 0.8); + float lscale = (logo_scale > 0.0 ? logo_scale : 0.30) * depthBob; + + // rotation couples to the bounce path: the angle tracks position, so its + // spin rate and direction follow the travel heading and flip at every wall + // hit (lc.x reverses on the side walls, lc.y on the top/bottom). + float ang = lc.x * 10.0 + lc.y * 7.0; + float ca = cos(ang), sa = sin(ang); + vec2 luv = mat2(ca, -sa, sa, ca) * ((sc - lc) / lscale); + if (abs(luv.x) <= 1.0 && abs(luv.y) <= 1.0) { + vec4 logo = texture2D(logo_tex, luv * 0.5 + 0.5); + // source-over with premultiplied alpha, then a pulsing neon rim glow + col = col * (1.0 - logo.a) + logo.rgb; + col += logo.a * (0.4 + 0.4 * sin(time * 2.0)) * 0.25 * vec3(0.6, 0.25, 1.0); + } + + gl_FragColor = vec4(col, 1.0); +} +` + +func (t *Tunnel) initShader() { + t.shader = canvas.NewShader( + fmt.Sprintf("tunnel-%d", tunnelSeq.Add(1)), + []byte(tunnelPreludeGL+tunnelBody), + []byte(tunnelPreludeES+tunnelBody), + ) + t.shader.Uniforms = map[string]float32{ + "time": 0, + "speed": 1.1, + "ring_freq": 5.0, + "rib_freq": 16.0, + "logo_scale": 0.30, + "crawl_lines": 0, // no credits until SetCredits is called + "crawl_persp": 0.45, + "crawl_width": 0.9, + "crawl_zlines": 3.0, + "crawl_speed": 0.8, + } + if logo := loadLogo(); logo != nil { + t.shader.Textures = map[string]image.Image{"logo_tex": logo} + } +} + +// loadLogo decodes the embedded txlogger logo into a premultiplied *image.RGBA, +// the form the GL painter uploads verbatim (image row 0 -> texture t=0, so the +// logo samples upright). Returns nil if decoding fails, in which case the +// shader simply samples an unbound texture and draws no logo. +func loadLogo() image.Image { + img, _, err := image.Decode(bytes.NewReader(logoPNG)) + if err != nil { + log.Printf("tunnel: decoding logo.png: %v", err) + return nil + } + if rgba, ok := img.(*image.RGBA); ok { + return rgba + } + b := img.Bounds() + rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(rgba, rgba.Bounds(), img, b.Min, draw.Src) + return rgba +} diff --git a/pkg/widgets/tunnel/tunnel_test.go b/pkg/widgets/tunnel/tunnel_test.go new file mode 100644 index 00000000..69777120 --- /dev/null +++ b/pkg/widgets/tunnel/tunnel_test.go @@ -0,0 +1,91 @@ +package tunnel + +import ( + "image" + "os" + "os/exec" + "path/filepath" + "testing" + + "fyne.io/fyne/v2/test" +) + +// New must produce a widget whose shader carries the animation-driven "time" +// uniform and tuning knobs, plus a renderer that exposes the shader. +func TestNewTunnel(t *testing.T) { + test.NewTempApp(t) // CreateRenderer starts the animation, which needs a driver + tn := New() + if tn.shader == nil { + t.Fatal("shader not initialised") + } + if _, ok := tn.shader.Uniforms["time"]; !ok { + t.Fatal("time uniform missing") + } + if tn.anim == nil { + t.Fatal("animation not created") + } + if _, ok := tn.shader.Textures["logo_tex"]; !ok { + t.Fatal("embedded logo texture not loaded") + } + + r := tn.CreateRenderer() + objs := r.Objects() + if len(objs) != 1 || objs[0] != tn.shader { + t.Fatalf("renderer objects = %v, want [shader]", objs) + } + if min := r.MinSize(); min.Width != defaultSize || min.Height != defaultSize { + t.Fatalf("MinSize = %v, want %dx%d", min, defaultSize, defaultSize) + } + r.Destroy() +} + +// SetCredits must rasterise the lines into a crawl texture sized one band per +// line (blank lines included) and record the line count for the shader. +func TestSetCredits(t *testing.T) { + test.NewTempApp(t) + tn := New() + + lines := []string{"", "txlogger thanks", "", "Alice", "Bob", ""} + tn.SetCredits(lines) + + img, ok := tn.shader.Textures["crawl_tex"].(*image.RGBA) + if !ok { + t.Fatal("crawl texture not created") + } + if w := img.Bounds().Dy(); w != len(lines)*crawlLineH { + t.Fatalf("crawl height = %d, want %d", w, len(lines)*crawlLineH) + } + if got := tn.shader.Uniforms["crawl_lines"]; got != float32(len(lines)) { + t.Fatalf("crawl_lines = %v, want %d", got, len(lines)) + } + + // An empty slice removes the crawl again. + tn.SetCredits(nil) + if _, ok := tn.shader.Textures["crawl_tex"]; ok { + t.Fatal("crawl texture should be removed for empty credits") + } + if got := tn.shader.Uniforms["crawl_lines"]; got != 0 { + t.Fatalf("crawl_lines = %v, want 0", got) + } +} + +// Both shader variants must compile; glslangValidator checks them against the +// GLSL 1.10 (desktop) and GLSL ES 1.00 specs. +func TestTunnelShaderSourcesCompile(t *testing.T) { + validator, err := exec.LookPath("glslangValidator") + if err != nil { + t.Skip("glslangValidator not installed") + } + for name, src := range map[string]string{ + "desktop.frag": tunnelPreludeGL + tunnelBody, + "es.frag": tunnelPreludeES + tunnelBody, + } { + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(validator, p).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", name, err, out) + } + } +} diff --git a/pkg/widgets/txconfigurator/txconfigurator.go b/pkg/widgets/txconfigurator/txconfigurator.go index c5c07c24..09cf5e2c 100644 --- a/pkg/widgets/txconfigurator/txconfigurator.go +++ b/pkg/widgets/txconfigurator/txconfigurator.go @@ -1,10 +1,8 @@ package txconfigurator import ( - "context" "errors" "fmt" - "log" "time" "fyne.io/fyne/v2" @@ -13,7 +11,6 @@ import ( "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" - "github.com/roffe/txlogger/pkg/mdns" "github.com/roffe/txlogger/pkg/ota" "github.com/roffe/txlogger/pkg/txbridge" ) @@ -23,7 +20,8 @@ var _ desktop.Mouseable = (*ConfiguratorWidget)(nil) type ConfiguratorWidget struct { widget.BaseWidget - client *txbridge.Client + client *txbridge.Client + getPort func() string apSSIDEntry *widget.Entry apPasswordEntry *widget.Entry @@ -40,9 +38,10 @@ type ConfiguratorWidget struct { container *fyne.Container } -func NewConfigurator() *ConfiguratorWidget { +func NewConfigurator(getPort func() string) *ConfiguratorWidget { t := &ConfiguratorWidget{ - client: txbridge.NewClient(), + client: txbridge.NewClient(""), + getPort: getPort, } t.apChannelSelect = widget.NewSelect([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13"}, func(s string) { @@ -66,21 +65,9 @@ func NewConfigurator() *ConfiguratorWidget { t.updateButton.Enable() t.connectButton.Enable() }) - address := "tcp://192.168.4.1:1337" - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - if src, err := mdns.Query(ctx, "txbridge.local"); err != nil { - log.Printf("failed to query mDNS: %v", err) - } else { - if src.IsValid() { - address = fmt.Sprintf("tcp://%s:%d", src.String(), 1337) - } else { - log.Printf("No mDNS response, using address: %s", address) - } - } err := ota.UpdateOTA(ota.Config{ - Port: address, + Port: "tcp://" + txbridge.ResolveAddress(t.getPort()), Logfunc: func(a ...any) { t.statusLabel.SetText(fmt.Sprint(a...)) }, @@ -90,9 +77,8 @@ func NewConfigurator() *ConfiguratorWidget { }) }, }) - if err != nil { - //dialog.ShowError(err, fyne.CurrentApp().Driver().AllWindows()[0]) + // dialog.ShowError(err, fyne.CurrentApp().Driver().AllWindows()[0]) t.statusLabel.SetText("Status: " + err.Error()) return } @@ -167,6 +153,7 @@ func NewConfigurator() *ConfiguratorWidget { } func (t *ConfiguratorWidget) connect() { + t.client.SetAddress(txbridge.ResolveAddress(t.getPort())) err := t.client.Connect() if err != nil { dialog.ShowError(err, fyne.CurrentApp().Driver().AllWindows()[0]) @@ -410,7 +397,6 @@ func (tr *ConfiguratorWidgetRenderer) MinSize() fyne.Size { } func (tr *ConfiguratorWidgetRenderer) Refresh() { - } func (tr *ConfiguratorWidgetRenderer) Objects() []fyne.CanvasObject { diff --git a/pkg/widgets/vbar/vbar.go b/pkg/widgets/vbar/vbar.go index 3df1563c..d2fd3827 100644 --- a/pkg/widgets/vbar/vbar.go +++ b/pkg/widgets/vbar/vbar.go @@ -134,10 +134,6 @@ func (s *VBar) SetValue(value float64) { } } -func (s *VBar) SetValue2(value float64) { - s.SetValue(value) -} - func (s *VBar) Value() float64 { return s.value } @@ -195,7 +191,7 @@ func (s *VBar) CreateRenderer() fyne.WidgetRenderer { s.lines[maxSteps-i] = line } - return &VBarRenderer{s} + return &VBarRenderer{VBar: s} } // getColorForValue returns fill & stroke color for an arbitrary gauge value. @@ -237,6 +233,7 @@ func (s *VBar) getColorForValue(value float64) (fillColor, strokeColor color.RGB type VBarRenderer struct { *VBar + objects []fyne.CanvasObject } func (r *VBarRenderer) MinSize() fyne.Size { @@ -302,10 +299,13 @@ func (r *VBarRenderer) Layout(space fyne.Size) { } func (r *VBarRenderer) Objects() []fyne.CanvasObject { - objs := make([]fyne.CanvasObject, 0, len(r.lines)+4) - for _, line := range r.lines { - objs = append(objs, line) + if r.objects == nil { + objs := make([]fyne.CanvasObject, 0, len(r.lines)+4) + for _, line := range r.lines { + objs = append(objs, line) + } + objs = append(objs, r.bar, r.face, r.titleText, r.displayText) + r.objects = objs } - objs = append(objs, r.bar, r.face, r.titleText, r.displayText) - return objs + return r.objects } diff --git a/pkg/widgets/widget.go b/pkg/widgets/widget.go index 7b3d68bf..32cd4171 100644 --- a/pkg/widgets/widget.go +++ b/pkg/widgets/widget.go @@ -48,6 +48,33 @@ func SelectFile(callback func(r fyne.URIReadCloser), desc string, exts ...string }() } +func SelectFiles(callback func(rc []fyne.URIReadCloser), desc string, exts ...string) { + go func() { + filenames, err := selectFiles(desc, exts...) + if err != nil { + if errors.Is(err, native.ErrCancelled) || err.Error() == "Cancelled" { + return + } + log.Println("Error selecting files:", err) + return + } + readers := make([]fyne.URIReadCloser, 0, len(filenames)) + for _, filename := range filenames { + uri := storage.NewFileURI(filename) + r, err := storage.Reader(uri) + if err != nil { + log.Println("Error reading file:", err) + continue + } + readers = append(readers, r) + } + if len(readers) == 0 { + return + } + fyne.Do(func() { callback(readers) }) + }() +} + func SaveFile(callback func(str string), desc string, ext string) { go func() { //filter := native.FileFilter{Description: desc, Extensions: []string{ext}} diff --git a/pkg/widgets/widget_linux.go b/pkg/widgets/widget_linux.go index 0899b382..c5ac771b 100644 --- a/pkg/widgets/widget_linux.go +++ b/pkg/widgets/widget_linux.go @@ -14,6 +14,14 @@ func selectFile(desc string, exts ...string) (string, error) { return runChild("open_file", "Open "+desc, desc, exts...) } +func selectFiles(desc string, exts ...string) ([]string, error) { + resp, err := runChildResp("open_files", "Open "+desc, desc, exts...) + if err != nil { + return nil, err + } + return resp.Paths, nil +} + func saveFile(desc string, ext string) (string, error) { return runChild("save_file", "Save "+desc, desc, ext) } @@ -23,6 +31,14 @@ func selectFolder() (string, error) { } func runChild(op, title, desc string, exts ...string) (string, error) { + resp, err := runChildResp(op, title, desc, exts...) + if err != nil { + return resp.Path, err + } + return resp.Path, nil +} + +func runChildResp(op, title, desc string, exts ...string) (native.FileResponse, error) { child := exec.Command("/proc/self/exe") // re-exec self child.Env = append(os.Environ(), "FP=1") childIn, _ := child.StdinPipe() @@ -31,7 +47,7 @@ func runChild(op, title, desc string, exts ...string) (string, error) { defer childIn.Close() if err := child.Start(); err != nil { - return "", fmt.Errorf("failed to start child: %w\n", err) + return native.FileResponse{}, fmt.Errorf("failed to start child: %w\n", err) } enc := json.NewEncoder(childIn) @@ -44,7 +60,7 @@ func runChild(op, title, desc string, exts ...string) (string, error) { Exts: exts, } if err := enc.Encode(req); err != nil { - return "", fmt.Errorf("error decoding response: %w", err) + return native.FileResponse{}, fmt.Errorf("error decoding response: %w", err) } var resp native.FileResponse @@ -53,12 +69,12 @@ func runChild(op, title, desc string, exts ...string) (string, error) { waitErr := child.Wait() if decodeErr != nil { - return "", decodeErr + return native.FileResponse{}, decodeErr } if resp.Err != "" { - return resp.Path, errors.New(resp.Err) + return resp, errors.New(resp.Err) } - return resp.Path, waitErr + return resp, waitErr } diff --git a/pkg/widgets/widget_windows.go b/pkg/widgets/widget_windows.go index 82adaac2..af76dd50 100644 --- a/pkg/widgets/widget_windows.go +++ b/pkg/widgets/widget_windows.go @@ -7,6 +7,11 @@ func selectFile(desc string, exts ...string) (string, error) { return native.OpenFileDialog("Open file", filter) } +func selectFiles(desc string, exts ...string) ([]string, error) { + filter := native.FileFilter{Description: desc, Extensions: exts} + return native.OpenFilesDialog("Open files", filter) +} + func saveFile(desc string, ext string) (string, error) { return native.SaveFileDialog("Save "+desc, ext, native.FileFilter{ Description: desc, diff --git a/pkg/windows/closedloopregion_test.go b/pkg/windows/closedloopregion_test.go new file mode 100644 index 00000000..977d130a --- /dev/null +++ b/pkg/windows/closedloopregion_test.go @@ -0,0 +1,33 @@ +package windows + +import "testing" + +func TestLookup1D(t *testing.T) { + // MaxLoadNormTab rpm axis + values from the reference T7 binary. + rpm := []float64{700, 880, 1260, 1640, 2020, 2400, 2780, 3160, 3540, 3920, 4300, 4680, 5060, 5440, 5820, 6200} + max := []float64{650, 650, 650, 650, 650, 650, 660, 660, 660, 650, 570, 510, 450, 330, 330, 330} + + cases := []struct { + x, want float64 + }{ + {700, 650}, // first breakpoint + {6200, 330}, // last breakpoint + {300, 650}, // below range -> clamp low + {9000, 330}, // above range -> clamp high + {5060, 450}, // exact breakpoint mid-table + {4490, 540}, // halfway between 4300(570) and 4680(510) + } + + for _, c := range cases { + if got := lookup1D(rpm, max, c.x); got != c.want { + t.Errorf("lookup1D(%g) = %g, want %g", c.x, got, c.want) + } + } + + // Boundary mapping: at rpm 5060 the closed-loop limit is 450 mg/c, so airmass + // 420 is closed loop and 480 is open loop. + limit := lookup1D(rpm, max, 5060) + if !(420 <= limit) || 480 <= limit { + t.Errorf("closed-loop boundary wrong: limit=%g, want 420<=limit<480", limit) + } +} diff --git a/pkg/windows/help.go b/pkg/windows/help.go index 30ec9712..d555109b 100644 --- a/pkg/windows/help.go +++ b/pkg/windows/help.go @@ -13,6 +13,7 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/roffe/txlogger/pkg/assets" + "github.com/roffe/txlogger/pkg/widgets/tappabletext" ) func Help() *container.AppTabs { @@ -117,7 +118,7 @@ func Help() *container.AppTabs { return tabs } -func About() *fyne.Container { +func (mw *MainWindow) about() *fyne.Container { kvaserLogo := canvas.NewImageFromResource(fyne.NewStaticResource("kvaser_logo.png", assets.KvaserLogoBytes)) kvaserLogo.SetMinSize(fyne.NewSize(190, 117)) kvaserLogo.FillMode = canvas.ImageFillContain @@ -149,6 +150,12 @@ func About() *fyne.Container { thnx := widget.NewLabel("Special thanks to") thnx.TextStyle.Bold = true + versionString := tappabletext.New("Version: "+fyne.CurrentApp().Metadata().Version+" Build: "+strconv.Itoa(fyne.CurrentApp().Metadata().Build), 10, func() { + mw.app.Preferences().SetBool("enable_preview_features1337", true) + mw.previewFeatures = true + mw.SetMainMenu(mw.GetMenu(mw.selects.ecuSelect.Selected)) + }) + return container.NewBorder( nil, nil, @@ -160,7 +167,7 @@ func About() *fyne.Container { nil, container.NewVBox( widget.NewHyperlink("txlogger.com", tx), - widget.NewLabel("Version: "+fyne.CurrentApp().Metadata().Version+" Build: "+strconv.Itoa(fyne.CurrentApp().Metadata().Build)), + versionString, widget.NewLabel("Author: Joakim \"Roffe\" Karlsson"), ), ), diff --git a/pkg/windows/mainWindow.go b/pkg/windows/mainWindow.go index 1d3106d7..aa58a35d 100644 --- a/pkg/windows/mainWindow.go +++ b/pkg/windows/mainWindow.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "time" "fyne.io/fyne/v2" @@ -20,6 +21,7 @@ import ( "fyne.io/fyne/v2/widget" xwidget "fyne.io/x/fyne/widget" symbol "github.com/roffe/ecusymbol" + "github.com/roffe/ecusymbol/as2" "github.com/roffe/gocan" "github.com/roffe/gocan/proto" "github.com/roffe/txlogger/pkg/colors" @@ -33,7 +35,6 @@ import ( "github.com/roffe/txlogger/pkg/widgets/ledicon" "github.com/roffe/txlogger/pkg/widgets/logplayer" "github.com/roffe/txlogger/pkg/widgets/multiwindow" - "github.com/roffe/txlogger/pkg/widgets/secrettext" "github.com/roffe/txlogger/pkg/widgets/settings" "github.com/roffe/txlogger/pkg/widgets/symbollist" "google.golang.org/protobuf/types/known/emptypb" @@ -61,21 +62,24 @@ func (s *SecretText) MouseUp(e *desktop.MouseEvent) { type MainWindow struct { fyne.Window - app fyne.App - menu *MainMenu - outputData binding.StringList - selects *mainWindowSelects - buttons *mainWindowButtons - counters *mainWindowCounters - loggingRunning bool - filename string - symbolList *symbollist.Widget - fw symbol.SymbolCollection + app fyne.App + leadingMenus, trailingMenus []*fyne.Menu + outputData binding.StringList + selects *mainWindowSelects + buttons *mainWindowButtons + counters *mainWindowCounters + loggingRunning bool + filename string + symbolList *symbollist.Widget + + as2 *as2.File + fw symbol.SymbolCollection + dlc datalogger.IClient gwclient proto.GocanClient buttonsDisabled bool settings *settings.Widget - statusText *secrettext.SecretText + statusText *widget.Label wm *multiwindow.MultipleWindows content *fyne.Container startup bool @@ -105,6 +109,7 @@ type mainWindowButtons struct { layoutRefreshBtn *widget.Button symbolListBtn *widget.Button addGaugeBtn *widget.Button + livePlotBtn *widget.Button } type mainWindowCounters struct { @@ -130,12 +135,8 @@ func NewMainWindow(app fyne.App) *MainWindow { gocanGatewayLED: ledicon.New("Gateway"), canLED: ledicon.New("CAN"), - statusText: secrettext.New("Harder, Better, Faster, Stronger"), - previewFeatures: app.Preferences().BoolWithFallback("enable_preview_features", false), - } - - mw.statusText.SecretFunc = func() { - mw.app.Preferences().SetBool("enable_preview_features", true) + statusText: widget.NewLabel("Harder, Better, Faster, Stronger"), + previewFeatures: app.Preferences().BoolWithFallback("enable_preview_features1337", false), } ebus.SubscribeFunc(ebus.TOPIC_COLORBLINDMODE, func(v float64) { @@ -271,13 +272,7 @@ func (mw *MainWindow) render() { footer := container.NewBorder( nil, nil, - container.NewBorder( - nil, - nil, - nil, - mw.buttons.layoutRefreshBtn, - mw.selects.layoutSelect, - ), + nil, container.NewHBox( container.NewHBox( mw.gocanGatewayLED, @@ -286,7 +281,6 @@ func (mw *MainWindow) render() { mw.counters.errorCounterLabel, mw.counters.fpsCounterLabel, ), - widget.NewButtonWithIcon("", theme.ComputerIcon(), mw.openEBUSMonitor), mw.buttons.debugBtn, ), mw.statusText, @@ -314,8 +308,8 @@ func (mw *MainWindow) LoadLogfileCombined(filename string, reader io.ReadCloser, Logplayer: true, UseMPH: mw.settings.GetUseMPH(), SwapRPMandSpeed: mw.settings.GetSwapRPMandSpeed(), - High: 0.5, - Low: 1.5, + High: 1.5, + Low: 0.5, WidebandSymbol: mw.settings.GetWidebandSymbolName(), } @@ -399,6 +393,16 @@ func (mw *MainWindow) LoadLogfileCombined(filename string, reader io.ReadCloser, mw.Log("loaded log file " + filename + " in combined logplayer") } +func (mw *MainWindow) LoadAS2File(filename string) error { + f, err := as2.Load(filename) + if err != nil { + return fmt.Errorf("failed to load AS2 file: %w", err) + } + mw.as2 = f + mw.Log("Loaded AS2 file " + filename) + return nil +} + func (mw *MainWindow) LoadLogfile(filename string, r io.Reader, pos fyne.Position) { // Just filename, used for Window title fp := filepath.Base(filename) @@ -417,8 +421,25 @@ func (mw *MainWindow) LoadLogfile(filename string, r io.Reader, pos fyne.Positio mw.Log("loaded log file " + filename) lp := logplayer.New(&logplayer.Config{ - EBus: ebus.CONTROLLER, - Logfile: logz, + EBus: ebus.CONTROLLER, + Logfile: logz, + PlotterRenderer: mw.settings.GetPlotterRenderer(), + OnExport: func(records []logfile.Record) { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(fp)), ".") + prefix := strings.TrimSuffix(fp, filepath.Ext(fp)) + "-clip" + logPath := mw.settings.GetLogPath() + go func() { + path, err := datalogger.ExportRecords(logPath, prefix, ext, records) + fyne.Do(func() { + if err != nil { + mw.Error(fmt.Errorf("failed to export selection: %w", err)) + return + } + mw.Log(fmt.Sprintf("exported %d samples to %s", len(records), path)) + dialog.ShowInformation("Selection exported", fmt.Sprintf("Saved %d samples to\n%s", len(records), path), mw) + }) + }() + }, }) /* content := container.NewBorder( diff --git a/pkg/windows/mainWindow_buttons.go b/pkg/windows/mainWindow_buttons.go index 5397c1f5..510b9c75 100644 --- a/pkg/windows/mainWindow_buttons.go +++ b/pkg/windows/mainWindow_buttons.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" @@ -15,6 +16,7 @@ import ( "github.com/roffe/txlogger/pkg/datalogger" "github.com/roffe/txlogger/pkg/ebus" "github.com/roffe/txlogger/pkg/widgets/dashboard" + "github.com/roffe/txlogger/pkg/widgets/liveplotter" "github.com/roffe/txlogger/pkg/widgets/msglist" "github.com/roffe/txlogger/pkg/widgets/multiwindow" ) @@ -31,6 +33,33 @@ func (mw *MainWindow) createButtons() { mw.buttons.debugBtn = mw.newDebugBtn() mw.buttons.symbolListBtn = mw.newSymbolListBtn() mw.buttons.addGaugeBtn = mw.newaddGaugeBtn() + mw.buttons.livePlotBtn = mw.newLivePlotBtn() +} + +func (mw *MainWindow) newLivePlotBtn() *widget.Button { + return widget.NewButtonWithIcon("Live plot", theme.MediaSkipNextIcon(), func() { + if w := mw.wm.HasWindow("Live plot"); w != nil { + mw.wm.Raise(w) + return + } + + names := mw.symbolList.Names() + if len(names) == 0 { + mw.Error(fmt.Errorf("no symbols selected to plot")) + return + } + + lp := liveplotter.New(&liveplotter.Config{ + Order: names, + Window: 120 * time.Second, + }) + + lpw := multiwindow.NewInnerWindow("Live plot", lp) + lpw.Icon = theme.MediaSkipNextIcon() + lpw.OnClose = lp.Close + mw.wm.Add(lpw) + lpw.Resize(fyne.NewSize(900, 500)) + }) } func (mw *MainWindow) newaddGaugeBtn() *widget.Button { @@ -312,7 +341,7 @@ func (mw *MainWindow) newDashboardBtn() *widget.Button { mw.Window.SetContent(db) mw.SetFullScreen(true) } else { - mw.SetMainMenu(mw.menu.GetMenu(mw.selects.ecuSelect.Selected)) + mw.SetMainMenu(mw.GetMenu(mw.selects.ecuSelect.Selected)) mw.Window.SetContent(mw.content) dbw.Close() mw.buttons.dashboardBtn.OnTapped() diff --git a/pkg/windows/mainWindow_compare.go b/pkg/windows/mainWindow_compare.go new file mode 100644 index 00000000..259dbcb7 --- /dev/null +++ b/pkg/windows/mainWindow_compare.go @@ -0,0 +1,194 @@ +package windows + +import ( + "bytes" + "errors" + "fmt" + "io" + "path/filepath" + "sort" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + symbol "github.com/roffe/ecusymbol" + "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/mapviewer" + "github.com/roffe/txlogger/pkg/widgets/multiwindow" + "github.com/roffe/txlogger/pkg/widgets/symbolcompare" +) + +// openSymbolCompare lets the user pick a second binary and lists every symbol +// whose data differs from the currently loaded one. +func (mw *MainWindow) openSymbolCompare() { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + if mw.dlc != nil { + mw.Error(errors.New("stop logging before comparing binaries")) + return + } + widgets.SelectFile(func(r fyne.URIReadCloser) { + defer r.Close() + data, err := io.ReadAll(r) + if err != nil { + mw.Error(err) + return + } + filename := filepath.Base(r.URI().Path()) + otherEcu, other, err := symbol.Load(filename, data, mw.Log) + if err != nil { + mw.Error(fmt.Errorf("failed to load %s: %w", filename, err)) + return + } + typ := symbol.ECUTypeFromString(mw.selects.ecuSelect.Selected) + if otherEcu != typ { + mw.Error(fmt.Errorf("ECU type mismatch: current is %s, %s is %s", typ, filename, otherEcu)) + return + } + mw.showSymbolCompare(typ, filename, other) + }, "Binary file", "bin") +} + +func (mw *MainWindow) showSymbolCompare(typ symbol.ECUType, otherName string, other symbol.SymbolCollection) { + var diffs []string + for _, s := range mw.fw.Symbols() { + o := other.GetByName(s.Name) + if o == nil { + continue // ponytail: only symbols present in both; added/removed skipped + } + if !bytes.Equal(s.Bytes(), o.Bytes()) { + diffs = append(diffs, s.Name) + } + } + sort.Strings(diffs) + + if len(diffs) == 0 { + dialog.ShowInformation("No differences", "The two binaries have identical symbol data", mw) + return + } + + cmp := symbolcompare.New(&symbolcompare.Config{ + Diffs: diffs, + OnShowSideBySide: func(name string) { mw.openCompareTabs(typ, otherName, other, name) }, + }) + inner := multiwindow.NewInnerWindow("Compare with "+otherName, cmp) + inner.Icon = theme.SearchReplaceIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(400, 600)) +} + +// openCompareTabs shows the current and other binary's version of a map plus a +// per-cell diff in three tabs. ponytail: native AppTabs, no custom wrapper. +func (mw *MainWindow) openCompareTabs(typ symbol.ECUType, otherName string, other symbol.SymbolCollection, mapName string) { + winName := mapName + " - compare" + if w := mw.wm.HasWindow(winName); w != nil { + mw.wm.Raise(w) + return + } + axis, xData, yData, curZ, xPrec, yPrec, zPrec, err := compareMapData(mw.fw, typ, mapName) + if err != nil { + mw.Error(err) + return + } + _, _, _, othZ, _, _, _, err := compareMapData(other, typ, mapName) + if err != nil { + mw.Error(err) + return + } + if len(curZ) != len(othZ) { + mw.Error(fmt.Errorf("%s has different dimensions in the two binaries (%d vs %d)", mapName, len(curZ), len(othZ))) + return + } + cur, err := mw.readonlyMapViewer(mapName, axis.ZDescription, axis, xData, yData, curZ, xPrec, yPrec, zPrec) + if err != nil { + mw.Error(err) + return + } + oth, err := mw.readonlyMapViewer(mapName, axis.ZDescription, axis, xData, yData, othZ, xPrec, yPrec, zPrec) + if err != nil { + mw.Error(err) + return + } + diff := make([]float64, len(curZ)) + for i := range curZ { + diff[i] = curZ[i] - othZ[i] + } + diffMv, err := mw.readonlyMapViewer(mapName, "Δ "+axis.ZDescription, axis, xData, yData, diff, xPrec, yPrec, zPrec) + if err != nil { + mw.Error(err) + return + } + tabs := container.NewAppTabs( + container.NewTabItem("Diff", diffMv), + container.NewTabItem("Current", cur), + container.NewTabItem(otherName, oth), + ) + inner := multiwindow.NewInnerWindow(winName, tabs) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(900, 700)) +} + +func (mw *MainWindow) readonlyMapViewer(name, zLabel string, axis symbol.Axis, xData, yData, zData []float64, xPrec, yPrec, zPrec int) (*mapviewer.MapViewer, error) { + return mapviewer.New(&mapviewer.Config{ + Name: name, + XData: xData, + YData: yData, + ZData: zData, + XPrecision: xPrec, + YPrecision: yPrec, + ZPrecision: zPrec, + XLabel: axis.XDescription, + YLabel: axis.YDescription, + ZLabel: zLabel, + MeshView: mw.settings.GetMeshView(), + MeshRenderer: mw.settings.GetMeshRenderer(), + Editable: false, + ColorblindMode: mw.settings.GetColorBlindMode(), + }) +} + +// compareMapData resolves a map's axes + data from one collection. A trimmed +// version of newMapViewer's resolution: no as2, no T5 coolant special-case. +func compareMapData(coll symbol.SymbolCollection, typ symbol.ECUType, mapName string) (axis symbol.Axis, xData, yData, zData []float64, xPrec, yPrec, zPrec int, err error) { + axis = symbol.GetInfo(typ, mapName) + + symX := coll.GetByName(axis.X) + if symX == nil && axis.X == "BstKnkCal.fi_offsetXSP" { + symX = coll.GetByName("BstKnkCal.OffsetXSP") + } + symY := coll.GetByName(axis.Y) + symZ := coll.GetByName(axis.Z) + if symZ == nil { + err = fmt.Errorf("symbol %s not found", axis.Z) + return + } + + zData = symZ.Float64s() + zPrec = symbol.GetPrecision(symZ.Correctionfactor) + + if symX != nil { + xData = symX.Float64s() + xPrec = symbol.GetPrecision(symX.Correctionfactor) + } else { + xData = []float64{0} + } + + switch { + case symY != nil: + yData = symY.Float64s() + yPrec = symbol.GetPrecision(symY.Correctionfactor) + case len(xData) <= 1 && len(zData) > 1: + // 1xN column with no Y axis: index it so the viewer can lay it out + yData = make([]float64, len(zData)) + for i := range yData { + yData[i] = float64(i) + } + default: + yData = []float64{0} + } + return +} diff --git a/pkg/windows/mainWindow_internal.go b/pkg/windows/mainWindow_internal.go index a1711202..b6cb9010 100644 --- a/pkg/windows/mainWindow_internal.go +++ b/pkg/windows/mainWindow_internal.go @@ -16,8 +16,6 @@ import ( xwidget "fyne.io/x/fyne/widget" "github.com/roffe/gocan/proto" "github.com/roffe/txlogger/pkg/common" - "github.com/roffe/txlogger/pkg/ebus" - "github.com/roffe/txlogger/pkg/widgets/ebusmonitor" "github.com/roffe/txlogger/pkg/widgets/multiwindow" ) @@ -73,6 +71,10 @@ func (mw *MainWindow) onDropped(p fyne.Position, uris []fyne.URI) { for _, u := range uris { filename := u.Path() switch strings.ToLower(path.Ext(filename)) { + case ".as2": + if err := mw.LoadAS2File(filename); err != nil { + mw.Error(err) + } case ".bin": if err := mw.LoadSymbolsFromFile(filename); err != nil { mw.Error(err) @@ -126,27 +128,6 @@ func listLayouts() []string { return opts } -func (mw *MainWindow) openEBUSMonitor() { - if w := mw.wm.HasWindow("EBUS Monitor"); w != nil { - mw.wm.Raise(w) - return - } - mon := ebusmonitor.New() - eb := multiwindow.NewSystemWindow("EBUS Monitor", mon) - eb.Icon = theme.ComputerIcon() - ebus.SetOnMessage( - func(topic string, data float64) { - fyne.Do(func() { - mon.SetText(topic, data) - }) - }, - ) - eb.OnClose = func() { - ebus.SetOnMessage(nil) - } - mw.wm.Add(eb) -} - func (mw *MainWindow) openSettings() { if w := mw.wm.HasWindow("Settings"); w != nil { mw.wm.Raise(w) diff --git a/pkg/windows/mainWindow_menu.go b/pkg/windows/mainWindow_menu.go index 7e9083f5..d0761522 100644 --- a/pkg/windows/mainWindow_menu.go +++ b/pkg/windows/mainWindow_menu.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "math" + "os" "os/exec" "runtime" "strings" @@ -13,7 +14,9 @@ import ( "time" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" symbol "github.com/roffe/ecusymbol" "github.com/roffe/gocan" "github.com/roffe/txlogger/pkg/colors" @@ -21,158 +24,74 @@ import ( "github.com/roffe/txlogger/pkg/ecu/t8/t8file" "github.com/roffe/txlogger/pkg/update" "github.com/roffe/txlogger/pkg/widgets" + "github.com/roffe/txlogger/pkg/widgets/boosttuner" + "github.com/roffe/txlogger/pkg/widgets/canflasher" "github.com/roffe/txlogger/pkg/widgets/dtcreader" "github.com/roffe/txlogger/pkg/widgets/editparameters" "github.com/roffe/txlogger/pkg/widgets/mapviewer" + "github.com/roffe/txlogger/pkg/widgets/matrixbuilder" "github.com/roffe/txlogger/pkg/widgets/multiwindow" "github.com/roffe/txlogger/pkg/widgets/progressmodal" + "github.com/roffe/txlogger/pkg/widgets/rescaler" + "github.com/roffe/txlogger/pkg/widgets/symbolbrowser" "github.com/roffe/txlogger/pkg/widgets/trionic5/pgmmod" "github.com/roffe/txlogger/pkg/widgets/trionic5/pgmstatus" "github.com/roffe/txlogger/pkg/widgets/trionic7/t7esp" - "github.com/roffe/txlogger/pkg/widgets/trionic7/t7fwinfo" ) func (mw *MainWindow) setupMenu() { - getAdapter := func() (gocan.Adapter, error) { - device, err := mw.settings.GetAdapter(mw.selects.ecuSelect.Selected) - if err != nil { - mw.Error(err) - return nil, err - } - return device, nil - } - getFW := func() symbol.SymbolCollection { return mw.fw } - getECU := func() string { - return mw.selects.ecuSelect.Selected - } - - funcMap := map[string]func(string){ - "DTC Reader": func(str string) { - if w := mw.wm.HasWindow("DTC Reader"); w != nil { - mw.wm.Raise(w) - return + openItem := fyne.NewMenuItemWithIcon("Open", theme.FolderIcon(), nil) + openItem.ChildMenu = fyne.NewMenu("File", + fyne.NewMenuItemWithIcon("Open binary", theme.DocumentIcon(), mw.loadBinary), + fyne.NewMenuItemWithIcon("Open log", theme.DocumentIcon(), func() { + cb := func(r fyne.URIReadCloser) { + defer r.Close() + filename := r.URI().Name() + mw.Log("opening logfile " + filename) + sz := mw.Window.Content().Size() + p := fyne.NewPos(sz.Width/2, sz.Height/2) + mw.LoadLogfile(filename, r, p) } - inner := multiwindow.NewInnerWindow("DTC Reader", dtcreader.New(getFW, getECU, getAdapter, mw.Log, mw.Error)) - inner.Icon = theme.InfoIcon() - mw.wm.Add(inner) - inner.Resize(fyne.Size{Width: 600, Height: 400}) - }, - "Edit Parameters": func(str string) { - if w := mw.wm.HasWindow("Edit Parameters"); w != nil { - mw.wm.Raise(w) - return + widgets.SelectFile(cb, "Log file", "csv", "bpl", "t5l", "t7l", "t8l") + }), + fyne.NewMenuItemWithIcon("Open log in new window", theme.DocumentIcon(), func() { + cb := func(r fyne.URIReadCloser) { + defer r.Close() + filename := r.URI().Path() + mw.LoadLogfileCombined(filename, r, fyne.Position{}, true) } - param := editparameters.NewEditParameters(getAdapter, mw.Error, mw.Log) - inner := multiwindow.NewInnerWindow("Edit Parameters", param) - inner.Icon = theme.InfoIcon() - mw.wm.Add(inner) - }, - "Register EU0D": func(str string) { - if w := mw.wm.HasWindow("Register EU0D"); w != nil { - mw.wm.Raise(w) - return - } - inner := multiwindow.NewInnerWindow("Register EU0D", NewMyrtilosRegistration(mw)) - inner.Icon = theme.InfoIcon() - mw.wm.Add(inner) - }, - "ESP Calibration": func(str string) { - if w := mw.wm.HasWindow("ESP Calibration selection"); w != nil { - mw.wm.Raise(w) - return - } - if t, ok := mw.fw.(*symbol.T7File); ok { - esp := t7esp.New(mw.filename, t) - inner := multiwindow.NewInnerWindow("ESP Calibration selection", esp) - inner.Icon = theme.InfoIcon() - inner.DisableResize = true - mw.wm.Add(inner) - } else { - mw.Error(errors.New("not a T7 file")) - } - }, - "Firmware information": func(str string) { - if w := mw.wm.HasWindow("Firmware info"); w != nil { - mw.wm.Raise(w) - return + widgets.SelectFile(cb, "logfile", "t5l", "t7l", "t8l", "csv", "bpl") + }), + fyne.NewMenuItemWithIcon("Open log folder", theme.FolderIcon(), func() { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("explorer", mw.settings.GetLogPath()) + case "darwin": + cmd = exec.Command("open", mw.settings.GetLogPath()) + default: + cmd = exec.Command("xdg-open", mw.settings.GetLogPath()) } - if t, ok := mw.fw.(*symbol.T7File); ok { - fwinfo := t7fwinfo.New(t) - inner := multiwindow.NewInnerWindow("Firmware info", fwinfo) - inner.Icon = theme.InfoIcon() - mw.wm.Add(inner) - } - }, - "Firmware info edit": func(str string) { - if w := mw.wm.HasWindow("Firmware info edit"); w != nil { - mw.wm.Raise(w) - return - } - tf := new(t8file.T8File) - filename := fyne.CurrentApp().Preferences().String("lastBinFile") - tf.GetInfo(filename) - tf.ShowEditT8Dialog(mw) - }, - "Pgm_mod!": func(str string) { - if w := mw.wm.HasWindow("Pgm_mod!"); w != nil { - mw.wm.Raise(w) - return - } - symZ := mw.fw.GetByName("Pgm_mod!") - pgm := pgmmod.New() - pgm.LoadFunc = func() ([]byte, error) { - if mw.dlc != nil { - log.Printf("Loading Pgm_mod! from ECU $%X", symZ.SramOffset) - data, err := mw.dlc.GetRAM(symZ.SramOffset, uint32(symZ.Length)) - if err != nil { - return nil, err - } - return data, nil - } - log.Printf("Loading Pgm_mod! from Binary $%X", symZ.Address) - return symZ.Bytes(), nil - } - - pgm.SaveFunc = func(data []byte) error { - if len(data) != int(symZ.Length) { - return fmt.Errorf("data length mismatch: got %d, want %d", len(data), symZ.Length) - } - if mw.dlc != nil { - log.Printf("Saving Pgm_mod! to ECU $%X", symZ.SramOffset) - if err := mw.dlc.SetRAM(symZ.SramOffset, data); err != nil { - return err - } - return nil - } - log.Printf("Saving Pgm_mod! to Binary $%X", symZ.Address) - return symZ.SetData(data) - } - - pgm.Set(symZ.Bytes()) - mapWindow := multiwindow.NewInnerWindow("Pgm_mod!", pgm) - mapWindow.Icon = theme.GridIcon() - mw.wm.Add(mapWindow) - }, - "Pgm_status": func(str string) { - if w := mw.wm.HasWindow("Pgm_status"); w != nil { - return + if err := cmd.Start(); err != nil { + mw.Error(err) } - pgs := pgmstatus.New() - cancel := ebus.SubscribeFunc("Pgm_status", pgs.Set) - iw := multiwindow.NewInnerWindow("Pgm_status", pgs) - iw.Icon = theme.InfoIcon() - iw.OnClose = func() { - if cancel != nil { - cancel() + }), + fyne.NewMenuItemWithIcon("Open AS2 file", theme.DocumentIcon(), func() { + cb := func(r fyne.URIReadCloser) { + defer r.Close() + filename := r.URI().Path() + mw.Log("Opening AS2 file " + filename) + if err := mw.LoadAS2File(filename); err != nil { + mw.Error(err) } } - mw.wm.Add(iw) - }, - } + widgets.SelectFile(cb, "AS2 file", "as2") + }), + ) leading := []*fyne.Menu{ fyne.NewMenu("File", @@ -181,53 +100,40 @@ func (mw *MainWindow) setupMenu() { mw.wm.Raise(w) return } - inner := multiwindow.NewInnerWindow("About", About()) + inner := multiwindow.NewInnerWindow("About", mw.about()) inner.Icon = theme.HelpIcon() mw.wm.Add(inner) }), - fyne.NewMenuItemWithIcon("Open binary", theme.DocumentIcon(), mw.loadBinary), - fyne.NewMenuItemWithIcon("Open log", theme.DocumentIcon(), func() { - cb := func(r fyne.URIReadCloser) { - defer r.Close() - filename := r.URI().Name() - mw.Log("opening logfile " + filename) - sz := mw.Window.Content().Size() - p := fyne.NewPos(sz.Width/2, sz.Height/2) - mw.LoadLogfile(filename, r, p) - } - widgets.SelectFile(cb, "Log file", "csv", "t5l", "t7l", "t8l") + openItem, + fyne.NewMenuItemWithIcon("Settings", theme.SettingsIcon(), mw.openSettings), + fyne.NewMenuItemWithIcon("What's new", theme.InfoIcon(), mw.showWhatsNew), + fyne.NewMenuItemWithIcon("Check for updates", theme.ViewRefreshIcon(), func() { + update.UpdateCheck(mw.app, mw.Window) }), - fyne.NewMenuItemWithIcon("Open log in new window", theme.DocumentIcon(), func() { - cb := func(r fyne.URIReadCloser) { - defer r.Close() - filename := r.URI().Path() - mw.LoadLogfileCombined(filename, r, fyne.Position{}, true) + ), + fyne.NewMenu("Tools", + fyne.NewMenuItemWithIcon("Symbol Browser", theme.ListIcon(), func() { + if w := mw.wm.HasWindow("Symbol Browser"); w != nil { + mw.wm.Raise(w) + return } - widgets.SelectFile(cb, "logfile", "t5l", "t7l", "t8l", "csv") - }), - fyne.NewMenuItemWithIcon("Open log folder", theme.FolderIcon(), func() { - var cmd *exec.Cmd - switch runtime.GOOS { - case "windows": - cmd = exec.Command("explorer", mw.settings.GetLogPath()) - case "darwin": - cmd = exec.Command("open", mw.settings.GetLogPath()) - default: - cmd = exec.Command("xdg-open", mw.settings.GetLogPath()) + getECU := func() symbol.ECUType { + return symbol.ECUTypeFromString(mw.selects.ecuSelect.Selected) } - if err := cmd.Start(); err != nil { - mw.Error(err) + openMap := func(typ symbol.ECUType, title, mapName string) { + mw.openMap(typ, title, mapName, "") } + browser := symbolbrowser.New(getFW, getECU, openMap, mw.Error) + inner := multiwindow.NewInnerWindow("Symbol Browser", browser) + inner.Icon = theme.ListIcon() + mw.wm.Add(inner) + inner.Resize(fyne.Size{Width: 760, Height: 520}) }), - fyne.NewMenuItemWithIcon("Settings", theme.SettingsIcon(), func() { - mw.openSettings() - }), - fyne.NewMenuItemWithIcon("What's new", theme.InfoIcon(), func() { - mw.showWhatsNew() - }), - fyne.NewMenuItemWithIcon("Check for updates", theme.ViewRefreshIcon(), func() { - update.UpdateCheck(mw.app, mw.Window) - }), + fyne.NewMenuItemWithIcon("Compare symbols with other binary", theme.SearchReplaceIcon(), mw.openSymbolCompare), + fyne.NewMenuItemWithIcon("Matrix Builder", theme.InfoIcon(), mw.openMatrixBuilder), + //fyne.NewMenuItemWithIcon("Rescale AccPedalMap", theme.GridIcon(), func() { + // mw.openRescaler(symbol.ECU_T8, "TrqMastCal.X_AccPedalMAP") + //}), ), } @@ -248,7 +154,159 @@ func (mw *MainWindow) setupMenu() { ), } - mw.menu = NewMenu(mw, leading, trailing, mw.openMap, funcMap) + if mw.previewFeatures { + leading[len(leading)-1].Items = append( + leading[len(leading)-1].Items, + fyne.NewMenuItemWithIcon("Canflasher", theme.UploadIcon(), func() { + if w := mw.wm.HasWindow("Canflasher"); w != nil { + mw.wm.Raise(w) + return + } + inner := multiwindow.NewInnerWindow("Canflasher", canflasher.New(&canflasher.Config{ + CSW: mw.settings, + })) + inner.Icon = theme.UploadIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(450, 250)) + }), + fyne.NewMenuItemWithIcon("T7 Boost Auto-Tuner", theme.MediaFastForwardIcon(), mw.openBoostTuner), + ) + } + + mw.leadingMenus = leading + mw.trailingMenus = trailing +} + +func (mw *MainWindow) getAdapter() (gocan.Adapter, error) { + device, err := mw.settings.GetAdapter(mw.selects.ecuSelect.Selected) + if err != nil { + mw.Error(err) + return nil, err + } + return device, nil +} + +func (mw *MainWindow) openDTCReader() { + if w := mw.wm.HasWindow("DTC Reader"); w != nil { + mw.wm.Raise(w) + return + } + getFW := func() symbol.SymbolCollection { return mw.fw } + getECU := func() string { return mw.selects.ecuSelect.Selected } + inner := multiwindow.NewInnerWindow("DTC Reader", dtcreader.New(getFW, getECU, mw.getAdapter, mw.Log, mw.Error)) + inner.Icon = theme.InfoIcon() + mw.wm.Add(inner) + inner.Resize(fyne.Size{Width: 600, Height: 400}) +} + +func (mw *MainWindow) openEditParameters() { + if w := mw.wm.HasWindow("Edit Parameters"); w != nil { + mw.wm.Raise(w) + return + } + param := editparameters.NewEditParameters(mw.getAdapter, mw.Error, mw.Log) + inner := multiwindow.NewInnerWindow("Edit Parameters", param) + inner.Icon = theme.InfoIcon() + mw.wm.Add(inner) +} + +func (mw *MainWindow) openRegisterEU0D() { + if w := mw.wm.HasWindow("Register EU0D"); w != nil { + mw.wm.Raise(w) + return + } + inner := multiwindow.NewInnerWindow("Register EU0D", NewMyrtilosRegistration(mw)) + inner.Icon = theme.InfoIcon() + mw.wm.Add(inner) +} + +func (mw *MainWindow) openESPCalibration() { + if w := mw.wm.HasWindow("ESP Calibration selection"); w != nil { + mw.wm.Raise(w) + return + } + t, ok := mw.fw.(*symbol.T7File) + if !ok { + mw.Error(errors.New("not a T7 file")) + return + } + esp := t7esp.New(mw.filename, t) + inner := multiwindow.NewInnerWindow("ESP Calibration selection", esp) + inner.Icon = theme.InfoIcon() + inner.DisableResize = true + mw.wm.Add(inner) +} + +func (mw *MainWindow) openFirmwareInfoEdit() { + if w := mw.wm.HasWindow("Firmware info edit"); w != nil { + mw.wm.Raise(w) + return + } + tf := new(t8file.T8File) + filename := fyne.CurrentApp().Preferences().String("lastBinFile") + tf.GetInfo(filename) + tf.ShowEditT8Dialog(mw) +} + +func (mw *MainWindow) openPgmMod() { + if w := mw.wm.HasWindow("Pgm_mod!"); w != nil { + mw.wm.Raise(w) + return + } + symZ := mw.fw.GetByName("Pgm_mod!") + if symZ == nil { + mw.Error(errors.New("Pgm_mod! symbol not found in loaded binary")) + return + } + pgm := pgmmod.New() + pgm.LoadFunc = func() ([]byte, error) { + if mw.dlc != nil { + log.Printf("Loading Pgm_mod! from ECU $%X", symZ.SramOffset) + data, err := mw.dlc.GetRAM(symZ.SramOffset, uint32(symZ.Length)) + if err != nil { + return nil, err + } + return data, nil + } + log.Printf("Loading Pgm_mod! from Binary $%X", symZ.Address) + return symZ.Bytes(), nil + } + + pgm.SaveFunc = func(data []byte) error { + if len(data) != int(symZ.Length) { + return fmt.Errorf("data length mismatch: got %d, want %d", len(data), symZ.Length) + } + if mw.dlc != nil { + log.Printf("Saving Pgm_mod! to ECU $%X", symZ.SramOffset) + if err := mw.dlc.SetRAM(symZ.SramOffset, data); err != nil { + return err + } + return nil + } + log.Printf("Saving Pgm_mod! to Binary $%X", symZ.Address) + return symZ.SetData(data) + } + + pgm.Set(symZ.Bytes()) + mapWindow := multiwindow.NewInnerWindow("Pgm_mod!", pgm) + mapWindow.Icon = theme.GridIcon() + mw.wm.Add(mapWindow) +} + +func (mw *MainWindow) openPgmStatus() { + if w := mw.wm.HasWindow("Pgm_status"); w != nil { + return + } + pgs := pgmstatus.New() + cancel := ebus.SubscribeFunc("Pgm_status", pgs.Set) + iw := multiwindow.NewInnerWindow("Pgm_status", pgs) + iw.Icon = theme.InfoIcon() + iw.OnClose = func() { + if cancel != nil { + cancel() + } + } + mw.wm.Add(iw) } func (mw *MainWindow) loadBinary() { @@ -269,22 +327,38 @@ func (mw *MainWindow) loadBinary() { var openMapLock sync.Mutex -func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) { - if mw.fw == nil { - mw.Error(fmt.Errorf("no binary loaded")) - return - } - - axis := symbol.GetInfo(typ, mapName) - - windowName := axis.Z - if title != "" { - windowName += " - " + title - } - - if w := mw.wm.HasWindow(windowName); w != nil { - mw.wm.Raise(w) - return +// newMapViewer builds a fully wired MapViewer for a single symbol (file/ECU +// load+save funcs, live X/Y crosshair subscriptions) but does not create a +// window. openMap wraps one; openMultiMap arranges several in a grid. The +// returned cancelFuncs must be called when the containing window closes. +func (mw *MainWindow) newMapViewer(typ symbol.ECUType, mapName string, regionMap string) (*mapviewer.MapViewer, *mapviewer.Config, symbol.Axis, []func(), error) { + var axis symbol.Axis + if mw.as2 != nil { + axis.Z = mapName + axes := mw.as2.Axes(mapName) + if len(axes) == 0 { + return nil, nil, axis, nil, fmt.Errorf("map %q not found in as2 file", mapName) + } + if len(axes) == 1 { + axis.Y = axes[0].SupportPoints + axis.YFrom = axes[0].Signal + } else { + for i, a := range axes { + log.Printf("Axis %d: %+v", i, a) + if i == 0 { + axis.X = a.SupportPoints + axis.XFrom = a.Signal + continue + } + if i == 1 { + axis.Y = a.SupportPoints + axis.YFrom = a.Signal + continue + } + } + } + } else { + axis = symbol.GetInfo(typ, mapName) } symX := mw.fw.GetByName(axis.X) @@ -303,9 +377,14 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) symY := mw.fw.GetByName(axis.Y) symZ := mw.fw.GetByName(axis.Z) + if mw.as2 != nil { + if symZ != nil { + symZ.Correctionfactor = mw.as2.GetCorrectionfactor(mapName) + } + } + if symZ == nil { - mw.Error(fmt.Errorf("failed to find symbol %s", axis.Z)) - return + return nil, nil, axis, nil, fmt.Errorf("failed to find symbol %s", axis.Z) } var xData, yData, zData []float64 @@ -325,10 +404,9 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) kyltempSteg := mw.fw.GetByName("Kyltemp_steg!") kyltempTab := mw.fw.GetByName("Kyltemp_tab!") if kyltempSteg == nil || kyltempTab == nil { - mw.Error(fmt.Errorf("missing coolant temperature symbols")) - return + return nil, nil, axis, nil, fmt.Errorf("missing coolant temperature symbols") } - realTemp := LookupCoolantTemperature(val, kyltempSteg.Ints(), kyltempTab.Ints()) + realTemp := lookupCoolantTemperature(val, kyltempSteg.Ints(), kyltempTab.Ints()) yData[idx] = float64(realTemp) } } else { @@ -468,15 +546,51 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) } var xPrecision, yPrecision, zPrecision int - if symX != nil { - xPrecision = symbol.GetPrecision(symX.Correctionfactor) + + if mw.as2 != nil { + if symX != nil { + xPrecision = mw.as2.Precision(axis.X) + log.Printf("Precision for %s: %d", axis.X, xPrecision) + //if xPrecision == 0 { + // log.Printf("Warning: precision for %s is 0, defaulting to 2", axis.X) + // xPrecision = 2 + //} + } + if symY != nil { + yPrecision = mw.as2.Precision(axis.Y) + log.Printf("Precision for %s: %d", axis.Y, yPrecision) + //if yPrecision == 0 { + // log.Printf("Warning: precision for %s is 0, defaulting to 2", axis.Y) + // yPrecision = 2 + //} + } + zPrecision = mw.as2.Precision(axis.Z) + log.Printf("Precision for %s: %d", axis.Z, zPrecision) + //if zPrecision == 0 { + // log.Printf("Warning: precision for %s is 0, defaulting to 2", axis.Z) + // zPrecision = 2 + //} + } else { + if symX != nil { + xPrecision = symbol.GetPrecision(symX.Correctionfactor) + } + if symY != nil { + yPrecision = symbol.GetPrecision(symY.Correctionfactor) + } + zPrecision = symbol.GetPrecision(symZ.Correctionfactor) } - if symY != nil { - yPrecision = symbol.GetPrecision(symY.Correctionfactor) + switch mapName { + case "TransCal.m_TriggMaxTab": + yData = []float64{0, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 5500, 6000, 6500} + case "TransCal.FilterConstAir": + yData = []float64{899, 2499, 3499, 3500} } - zPrecision = symbol.GetPrecision(symZ.Correctionfactor) + var regionBorder []bool + if regionMap != "" { + regionBorder = mw.closedLoopRegion(typ, regionMap, xData, yData) + } cfg := &mapviewer.Config{ Name: symZ.Name, @@ -485,6 +599,8 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) YData: yData, ZData: zData, + RegionBorder: regionBorder, + XPrecision: xPrecision, YPrecision: yPrecision, ZPrecision: zPrecision, @@ -500,6 +616,7 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) OnUpdateCell: updateFunc, MeshView: mw.settings.GetMeshView(), + MeshRenderer: mw.settings.GetMeshRenderer(), Editable: true, CursorFollowCrosshair: mw.settings.GetCursorFollowCrosshair(), ColorblindMode: mw.settings.GetColorBlindMode(), @@ -550,41 +667,126 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) }, } - mv, err := mapviewer.New(cfg) + var err error + mv, err = mapviewer.New(cfg) + if err != nil { + return nil, nil, axis, nil, err + } + + var cancelFuncs []func() + if axis.XFrom != "" { + cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(axis.XFrom, mv.SetX)) + } + if axis.YFrom != "" { + cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(axis.YFrom, mv.SetY)) + } + cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(ebus.TOPIC_COLORBLINDMODE, func(value float64) { + mv.SetColorBlindMode(colors.ColorBlindMode(int(value))) + })) + + return mv, cfg, axis, cancelFuncs, nil +} + +func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string, regionMap string) { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + + mv, cfg, axis, cancelFuncs, err := mw.newMapViewer(typ, mapName, regionMap) if err != nil { mw.Error(err) return } + windowName := axis.Z + if title != "" { + windowName += " - " + title + } + if w := mw.wm.HasWindow(windowName); w != nil { + mw.wm.Raise(w) + for _, fn := range cancelFuncs { + fn() + } + return + } + + mapWindow := multiwindow.NewInnerWindow(axis.Z+" - "+axis.ZDescription, mv) + mapWindow.Icon = theme.GridIcon() + + cfg.OnMouseDown = func() { + mw.wm.Raise(mapWindow) + } + + mapWindow.OnClose = func() { + for _, fn := range cancelFuncs { + fn() + } + } + if mw.settings.GetAutoLoad() && mw.dlc != nil { go func() { openMapLock.Lock() defer openMapLock.Unlock() p := progressmodal.New(mw.Window.Canvas(), "Loading "+axis.Z) fyne.DoAndWait(p.Show) - loadRamFunc() + cfg.LoadECUFunc() fyne.Do(p.Hide) }() } - mapWindow := multiwindow.NewInnerWindow(axis.Z+" - "+axis.ZDescription, mv) - mapWindow.Icon = theme.GridIcon() + mw.wm.Add(mapWindow) +} - cfg.OnMouseDown = func() { - mw.wm.Raise(mapWindow) +// openMultiMap opens several maps (data = symbol names joined by "|") tightly +// arranged in one window for an at-a-glance overview, e.g. boost RegMap plus +// P/I/D factors. ponytail: plain 2-column grid of MapViewers, no dedicated +// widget — add one only if these views ever need shared crosshair/selection. +func (mw *MainWindow) openMultiMap(typ symbol.ECUType, title string, data string) { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + if w := mw.wm.HasWindow(title); w != nil { + mw.wm.Raise(w) + return } + grid := container.NewGridWithColumns(2) + var cfgs []*mapviewer.Config + var loadFuncs []func() var cancelFuncs []func() - if axis.XFrom != "" { - cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(axis.XFrom, mv.SetX)) + + for _, name := range strings.Split(data, "|") { + mv, cfg, axis, cancels, err := mw.newMapViewer(typ, strings.TrimSpace(name), "") + if err != nil { + mw.Error(err) + continue + } + cfgs = append(cfgs, cfg) + cancelFuncs = append(cancelFuncs, cancels...) + if cfg.LoadECUFunc != nil { + loadFuncs = append(loadFuncs, cfg.LoadECUFunc) + } + label := axis.Z + if axis.ZDescription != "" { + label += " - " + axis.ZDescription + } + grid.Add(container.NewBorder(widget.NewLabel(label), nil, nil, nil, mv)) } - if axis.YFrom != "" { - cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(axis.YFrom, mv.SetY)) + + if len(grid.Objects) == 0 { + return } - cancelFuncs = append(cancelFuncs, ebus.SubscribeFunc(ebus.TOPIC_COLORBLINDMODE, func(value float64) { - mv.SetColorBlindMode(colors.ColorBlindMode(int(value))) - })) + mapWindow := multiwindow.NewInnerWindow(title, grid) + mapWindow.Icon = theme.GridIcon() + + for _, cfg := range cfgs { + cfg.OnMouseDown = func() { + mw.wm.Raise(mapWindow) + } + } mapWindow.OnClose = func() { for _, fn := range cancelFuncs { @@ -592,10 +794,73 @@ func (mw *MainWindow) openMap(typ symbol.ECUType, title string, mapName string) } } + if mw.settings.GetAutoLoad() && mw.dlc != nil { + go func() { + openMapLock.Lock() + defer openMapLock.Unlock() + p := progressmodal.New(mw.Window.Canvas(), "Loading "+title) + fyne.DoAndWait(p.Show) + for _, fn := range loadFuncs { + fn() + } + fyne.Do(p.Hide) + }() + } + mw.wm.Add(mapWindow) + mapWindow.Resize(fyne.NewSize(1000, 750)) +} + +// closedLoopRegion flags the cells of a fuel map (X = airmass, Y = rpm) that lie +// in the closed-loop area: airmass <= the per-rpm max load read from a LambdaCal +// MaxLoad table (regionMap). The table is indexed by its own rpm axis, so the +// limit is linearly interpolated onto each map rpm row. Result is row-major +// (rpmIdx*len(xData)+airIdx), matching ZData order. Returns nil if anything is +// missing so the caller simply skips the outline. +func (mw *MainWindow) closedLoopRegion(typ symbol.ECUType, regionMap string, xData, yData []float64) []bool { + sym := mw.fw.GetByName(regionMap) + if sym == nil { + return nil + } + rpmSym := mw.fw.GetByName(symbol.GetInfo(typ, regionMap).Y) + if rpmSym == nil { + return nil + } + limitRpm := rpmSym.Float64s() + limit := sym.Float64s() + if len(limitRpm) == 0 || len(limit) != len(limitRpm) { + return nil + } + region := make([]bool, len(xData)*len(yData)) + for r, rpm := range yData { + maxAir := lookup1D(limitRpm, limit, rpm) + for c, air := range xData { + region[r*len(xData)+c] = air <= maxAir + } + } + return region +} + +// lookup1D does a clamped linear interpolation of ys over the (ascending) xs +// breakpoints. xs and ys must be the same non-zero length. +func lookup1D(xs, ys []float64, x float64) float64 { + n := len(xs) + if x <= xs[0] { + return ys[0] + } + if x >= xs[n-1] { + return ys[n-1] + } + for i := 1; i < n; i++ { + if x <= xs[i] { + t := (x - xs[i-1]) / (xs[i] - xs[i-1]) + return ys[i-1] + t*(ys[i]-ys[i-1]) + } + } + return ys[n-1] } -func LookupCoolantTemperature(axisvalue int, kyltempSteg, kyltempTab []int) int { +func lookupCoolantTemperature(axisvalue int, kyltempSteg, kyltempTab []int) int { index := -1 retval := -1 smallestDiff := 256 @@ -659,3 +924,137 @@ func LookupCoolantTemperature(axisvalue int, kyltempSteg, kyltempTab []int) int return retval } + +// openBoostTuner opens (or raises) the T7 boost auto-tuner. It reads the current +// BoostCal maps from the loaded binary and writes tuned maps back through a save +// closure that takes a one-time .bak of the file before the first write. +func (mw *MainWindow) openBoostTuner() { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + if w := mw.wm.HasWindow("Boost Auto-Tuner"); w != nil { + mw.wm.Raise(w) + return + } + save := func(symbolName string, data []float64) error { + sym := mw.fw.GetByName(symbolName) + if sym == nil { + return fmt.Errorf("symbol %s not found", symbolName) + } + if err := sym.SetData(sym.EncodeFloat64s(data)); err != nil { + return err + } + if mw.filename != "" { + if bak := mw.filename + ".bak"; !fileExists(bak) { + if orig, err := os.ReadFile(mw.filename); err == nil { + _ = os.WriteFile(bak, orig, 0o644) + } + } + } + return mw.fw.Save(mw.filename) + } + bt := boosttuner.New(boosttuner.Config{ + Symbols: mw.fw, + Save: save, + MeshRenderer: mw.settings.GetMeshRenderer(), + Colorblind: mw.settings.GetColorBlindMode(), + }) + inner := multiwindow.NewInnerWindow("Boost Auto-Tuner", bt) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(1100, 760)) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// openMatrixBuilder opens (or raises) the matrix builder window. The builder +// loads its own log files, so it is independent of any open log player. +func (mw *MainWindow) openMatrixBuilder() { + if w := mw.wm.HasWindow("Matrix builder"); w != nil { + mw.wm.Raise(w) + return + } + inner := multiwindow.NewInnerWindow("Matrix builder", matrixbuilder.New(mw.settings.GetMeshRenderer())) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(1000, 720)) +} + +// openRescaler opens (or raises) the map rescaler for a single map. It reads the +// map and its X/Y axes from the loaded binary, lets the user edit the axis +// support points, resamples the surface onto them, and writes the result back +// through a save closure that takes a one-time .bak before the first write. +func (mw *MainWindow) openRescaler(typ symbol.ECUType, mapName string) { + if mw.fw == nil { + mw.Error(fmt.Errorf("no binary loaded")) + return + } + winName := "Rescale " + mapName + if w := mw.wm.HasWindow(winName); w != nil { + mw.wm.Raise(w) + return + } + + axis := symbol.GetInfo(typ, mapName) + symX := mw.fw.GetByName(axis.X) + symY := mw.fw.GetByName(axis.Y) + symZ := mw.fw.GetByName(axis.Z) + if symZ == nil || symY == nil { + mw.Error(fmt.Errorf("rescaler: missing symbol(s) for %s", mapName)) + return + } + + xData := []float64{0} + xPrecision := 0 + if symX != nil { + xData = symX.Float64s() + xPrecision = symbol.GetPrecision(symX.Correctionfactor) + } + + cfg := &rescaler.Config{ + Name: axis.Z, + XLabel: axis.X, + YLabel: axis.Y, + ZLabel: axis.Z, + XData: xData, + YData: symY.Float64s(), + ZData: symZ.Float64s(), + XPrecision: xPrecision, + YPrecision: symbol.GetPrecision(symY.Correctionfactor), + ZPrecision: symbol.GetPrecision(symZ.Correctionfactor), + Apply: func(newX, newY, newZ []float64) error { + if symX != nil { + if err := symX.SetData(symX.EncodeFloat64s(newX)); err != nil { + return err + } + } + if err := symY.SetData(symY.EncodeFloat64s(newY)); err != nil { + return err + } + if err := symZ.SetData(symZ.EncodeFloat64s(newZ)); err != nil { + return err + } + if mw.filename != "" { + if bak := mw.filename + ".bak"; !fileExists(bak) { + if orig, err := os.ReadFile(mw.filename); err == nil { + _ = os.WriteFile(bak, orig, 0o644) + } + } + } + if err := mw.fw.Save(mw.filename); err != nil { + return err + } + mw.Log("Rescaled and saved " + axis.Z) + return nil + }, + } + + inner := multiwindow.NewInnerWindow(winName, rescaler.New(cfg)) + inner.Icon = theme.GridIcon() + mw.wm.Add(inner) + inner.Resize(fyne.NewSize(900, 720)) +} diff --git a/pkg/windows/mainWindow_toolbar.go b/pkg/windows/mainWindow_toolbar.go index 022424e7..63347bcc 100644 --- a/pkg/windows/mainWindow_toolbar.go +++ b/pkg/windows/mainWindow_toolbar.go @@ -3,9 +3,9 @@ package windows import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" - "github.com/roffe/txlogger/pkg/widgets/canflasher" "github.com/roffe/txlogger/pkg/widgets/multiwindow" ) @@ -21,80 +21,73 @@ func (mw *MainWindow) newToolbar() *fyne.Container { widget.NewSeparator(), mw.buttons.symbolListBtn, mw.buttons.logBtn, + mw.buttons.livePlotBtn, mw.buttons.dashboardBtn, + mw.buttons.addGaugeBtn, widget.NewButtonWithIcon("", theme.GridIcon(), func() { mw.wm.Arrange(&multiwindow.GridArranger{}) }), - mw.buttons.addGaugeBtn, - widget.NewButtonWithIcon("", theme.ContentClearIcon(), func() { - mw.wm.CloseAll() - }), + widget.NewButtonWithIcon("", theme.ContentClearIcon(), mw.wm.CloseAll), + layout.NewSpacer(), + container.NewBorder( + nil, + nil, + nil, + mw.buttons.layoutRefreshBtn, + mw.selects.layoutSelect, + ), ) - if mw.previewFeatures { - toolbar.Add(widget.NewButtonWithIcon("", theme.UploadIcon(), func() { - if w := mw.wm.HasWindow("Canflasher"); w != nil { + //if mw.previewFeatures { + /* + toolbar.Add(widget.NewButtonWithIcon("", theme.DocumentIcon(), func() { + if w := mw.wm.HasWindow("txweb"); w != nil { mw.wm.Raise(w) return } - inner := multiwindow.NewInnerWindow("Canflasher", canflasher.New(&canflasher.Config{ - CSW: mw.settings, - })) - inner.Icon = theme.UploadIcon() + txb := txweb.New() + txb.LoadFileFunc = func(name string, data []byte) error { + switch filepath.Ext(name) { + case ".bin": + if err := mw.LoadSymbolsFromBytes(name, data); err != nil { + return err + } + return nil + case ".csv": // ".t5l", ".t7l", ".t8l", + mw.LoadLogfile(name, bytes.NewReader(data), fyne.NewPos(100, 100)) + return nil + } + return nil + } + inner := multiwindow.NewInnerWindow("txweb", txb) + inner.Icon = theme.FileApplicationIcon() mw.wm.Add(inner) - inner.Resize(fyne.NewSize(450, 250)) + inner.Resize(fyne.NewSize(700, 500)) }), ) + */ - /* - toolbar.Add(widget.NewButtonWithIcon("", theme.DocumentIcon(), func() { - if w := mw.wm.HasWindow("txweb"); w != nil { - mw.wm.Raise(w) - return - } - txb := txweb.New() - txb.LoadFileFunc = func(name string, data []byte) error { - switch filepath.Ext(name) { - case ".bin": - if err := mw.LoadSymbolsFromBytes(name, data); err != nil { - return err - } - return nil - case ".csv": // ".t5l", ".t7l", ".t8l", - mw.LoadLogfile(name, bytes.NewReader(data), fyne.NewPos(100, 100)) - return nil - } - return nil - } - inner := multiwindow.NewInnerWindow("txweb", txb) - inner.Icon = theme.FileApplicationIcon() - mw.wm.Add(inner) - inner.Resize(fyne.NewSize(700, 500)) - }), + /* + widget.NewButtonWithIcon("", theme.NavigateNextIcon(), func() { + if w := mw.wm.HasWindow("Map"); w != nil { + mw.wm.Raise(w) + return + } + mapp := maps.NewMap() + cnt := container.NewBorder( + nil, + widget.NewButtonWithIcon("", theme.ContentClearIcon(), func() { + mapp.SetCenter(59.644810, 17.058252) + }), + nil, + nil, + mapp, ) - */ - - /* - widget.NewButtonWithIcon("", theme.NavigateNextIcon(), func() { - if w := mw.wm.HasWindow("Map"); w != nil { - mw.wm.Raise(w) - return - } - mapp := maps.NewMap() - cnt := container.NewBorder( - nil, - widget.NewButtonWithIcon("", theme.ContentClearIcon(), func() { - mapp.SetCenter(59.644810, 17.058252) - }), - nil, - nil, - mapp, - ) - inner := multiwindow.NewInnerWindow("Map", cnt) - inner.Icon = theme.NavigateNextIcon() - mw.wm.Add(inner) - }), - */ - } + inner := multiwindow.NewInnerWindow("Map", cnt) + inner.Icon = theme.NavigateNextIcon() + mw.wm.Add(inner) + }), + */ + //} return toolbar } diff --git a/pkg/windows/mainmenu.go b/pkg/windows/mainmenu.go index 165cf718..394edab8 100644 --- a/pkg/windows/mainmenu.go +++ b/pkg/windows/mainmenu.go @@ -8,83 +8,72 @@ import ( symbol "github.com/roffe/ecusymbol" ) -type MainMenu struct { - w fyne.Window - leading, trailing []*fyne.Menu - openFunc func(symbol.ECUType, string, string) - //multiFunc func(symbol.ECUType, ...string) - funcMap map[string]func(string) +type MenuItem struct { + Name string + Children []MenuItem + Func func() + Data string + // Region is an optional LambdaCal MaxLoad table whose per-rpm airmass limit + // outlines the closed-loop region on the opened map. Only used for single-map + // items (Data without "|"). + Region string } -func NewMenu(w fyne.Window, leading, trailing []*fyne.Menu, openFunc func(symbol.ECUType, string, string), funcMap map[string]func(string)) *MainMenu { - return &MainMenu{ - w: w, - openFunc: openFunc, - leading: leading, - trailing: trailing, - funcMap: funcMap, - } -} - -func (mw *MainMenu) GetMenu(name string) *fyne.MainMenu { - var order []string - var ecuM map[string][]string +func (mw *MainWindow) GetMenu(name string) *fyne.MainMenu { + var tree []MenuItem var typ symbol.ECUType switch name { case "T5": - order = T5SymbolsTuningOrder - ecuM = T5SymbolsTuning + tree = mw.t5Menu() typ = symbol.ECU_T5 case "T7": - order = T7SymbolsTuningOrder - ecuM = T7SymbolsTuning + tree = mw.t7Menu() typ = symbol.ECU_T7 case "T8": - order = T8SymbolsTuningOrder - ecuM = T8SymbolsTuning + tree = mw.t8Menu() typ = symbol.ECU_T8 } - menus := append([]*fyne.Menu{}, mw.leading...) - - for _, category := range order { - var items []*fyne.MenuItem - for _, mapName := range ecuM[category] { - if f, ok := mw.funcMap[mapName]; ok { - itm := fyne.NewMenuItemWithIcon(mapName, theme.ComputerIcon(), func() { - f(mapName) - }) - items = append(items, itm) - continue - } + menus := append([]*fyne.Menu{}, mw.leadingMenus...) + for _, category := range tree { + menus = append(menus, fyne.NewMenu(category.Name, mw.buildItems(typ, category.Children)...)) + } + menus = append(menus, mw.trailingMenus...) - if strings.Contains(mapName, "|") { - parts := strings.Split(mapName, "|") - names := parts[1:] - if len(parts) == 2 { - itm := fyne.NewMenuItemWithIcon(parts[0], theme.GridIcon(), func() { - mw.openFunc(typ, parts[0], names[0]) - }) - items = append(items, itm) - continue - } - //itm := fyne.NewMenuItem(parts[0], func() { - // mw.multiFunc(typ, names...) - //}) - //items = append(items, itm) - continue - } + return fyne.NewMainMenu(menus...) +} - itm := fyne.NewMenuItemWithIcon(mapName, theme.GridIcon(), func() { - mw.openFunc(typ, "", mapName) - }) - items = append(items, itm) - } - menus = append(menus, fyne.NewMenu(category, items...)) +func (mw *MainWindow) buildItems(typ symbol.ECUType, items []MenuItem) []*fyne.MenuItem { + var out []*fyne.MenuItem + for _, item := range items { + out = append(out, mw.buildItem(typ, item)) } + return out +} - menus = append(menus, mw.trailing...) - - return fyne.NewMainMenu(menus...) +func (mw *MainWindow) buildItem(typ symbol.ECUType, item MenuItem) *fyne.MenuItem { + switch { + case len(item.Children) > 0: + itm := fyne.NewMenuItemWithIcon(item.Name, theme.FolderIcon(), nil) + itm.ChildMenu = fyne.NewMenu(item.Name, mw.buildItems(typ, item.Children)...) + return itm + case item.Func != nil: + return fyne.NewMenuItemWithIcon(item.Name, theme.ComputerIcon(), item.Func) + case strings.Contains(item.Data, "|"): + // title + multiple symbols joined by "|": open tightly arranged together + return fyne.NewMenuItemWithIcon(item.Name, theme.GridIcon(), func() { + mw.openMultiMap(typ, item.Name, item.Data) + }) + case item.Data != "": + // title + symbol: open as a map + return fyne.NewMenuItemWithIcon(item.Name, theme.GridIcon(), func() { + mw.openMap(typ, item.Name, item.Data, item.Region) + }) + default: + // Name is itself a symbol + return fyne.NewMenuItemWithIcon(item.Name, theme.GridIcon(), func() { + mw.openMap(typ, "", item.Name, "") + }) + } } diff --git a/pkg/windows/mainmenu_t5.go b/pkg/windows/mainmenu_t5.go index 75eca147..d31e37a9 100644 --- a/pkg/windows/mainmenu_t5.go +++ b/pkg/windows/mainmenu_t5.go @@ -1,67 +1,57 @@ package windows -var T5SymbolsTuningOrder = []string{ - "Diagnostics", - "Options", - "Injection [Fuel]", - "Ignition", - "Turbo control [M]", - "Turbo control [A]", - "Knock detection", - "Warmup", - "Idle", -} - -var T5SymbolsTuning = map[string][]string{ - "Diagnostics": { - "DTC Reader", - "Pgm_status", - }, - "Options": { - "Pgm_mod!", - }, - "Injection [Fuel]": { - "VE map - normal|Insp_mat!", - "VE map - knock|Fuel_knock_mat!", - "Injector scaling|Inj_konst!", - "Battery correction map|Batt_korr_tab!", - "Fuel cut in overboost|Tryck_vakt_tab!", - }, - "Ignition": { - "Ignition normal|Ign_map_0!", - "Ignition knock|Ign_map_2!", - "Ignition warmup|Ign_map_4!", - }, - "Turbo control [M]": { - "Boost request map|Tryck_mat!", - "Boost control bias|Reg_kon_mat!", - "P factors|P_fors!", - "I factors|I_fors!", - "D factors|D_fors!", - "Boost limit in 1st gear|Regl_tryck_fgm!", - "Boost limit in 2nd gear|Regl_tryck_sgm!", - }, - "Turbo control [A]": { - "Boost request map|Tryck_mat_a!", - "Boost control bias|Reg_kon_mat_a!", - "P factors|P_fors_a!", - "I factors|I_fors_a!", - "D factors|D_fors_a!", - "Boost limit in 1st gear|Regl_tryck_fgaut!", - }, - "Knock detection": { - "Knock sensitivity map|Knock_ref_matrix!", - "Ignition retard limit|Knock_lim_tab!", - "Boost reduction map|Apc_knock_tab!", - }, - "Warmup": { - "Afterstart enrichment (1)|Eftersta_fak!", - "Afterstart enrichment (2)|Eftersta_fak2!", - }, - "Idle": { - "Idle target RPM|Idle_rpm_tab!", - "Idle ignition|Ign_idle_angle!", - "Idle ignition correction|Ign_map_1!", - "Idle fuel map|Idle_fuel_korr!", - }, +func (mw *MainWindow) t5Menu() []MenuItem { + return []MenuItem{ + {Name: "Diagnostics", Children: []MenuItem{ + {Name: "DTC Reader", Func: mw.openDTCReader}, + {Name: "Pgm_status", Func: mw.openPgmStatus}, + }}, + {Name: "Options", Children: []MenuItem{ + {Name: "Pgm_mod!", Func: mw.openPgmMod}, + }}, + {Name: "Injection [Fuel]", Children: []MenuItem{ + {Name: "VE map - normal", Data: "Insp_mat!"}, + {Name: "VE map - knock", Data: "Fuel_knock_mat!"}, + {Name: "Injector scaling", Data: "Inj_konst!"}, + {Name: "Battery correction map", Data: "Batt_korr_tab!"}, + {Name: "Fuel cut in overboost", Data: "Tryck_vakt_tab!"}, + }}, + {Name: "Ignition", Children: []MenuItem{ + {Name: "Ignition normal", Data: "Ign_map_0!"}, + {Name: "Ignition knock", Data: "Ign_map_2!"}, + {Name: "Ignition warmup", Data: "Ign_map_4!"}, + }}, + {Name: "Turbo control [M]", Children: []MenuItem{ + {Name: "Boost request map", Data: "Tryck_mat!"}, + {Name: "Boost control bias", Data: "Reg_kon_mat!"}, + {Name: "P factors", Data: "P_fors!"}, + {Name: "I factors", Data: "I_fors!"}, + {Name: "D factors", Data: "D_fors!"}, + {Name: "Boost limit in 1st gear", Data: "Regl_tryck_fgm!"}, + {Name: "Boost limit in 2nd gear", Data: "Regl_tryck_sgm!"}, + }}, + {Name: "Turbo control [A]", Children: []MenuItem{ + {Name: "Boost request map", Data: "Tryck_mat_a!"}, + {Name: "Boost control bias", Data: "Reg_kon_mat_a!"}, + {Name: "P factors", Data: "P_fors_a!"}, + {Name: "I factors", Data: "I_fors_a!"}, + {Name: "D factors", Data: "D_fors_a!"}, + {Name: "Boost limit in 1st gear", Data: "Regl_tryck_fgaut!"}, + }}, + {Name: "Knock detection", Children: []MenuItem{ + {Name: "Knock sensitivity map", Data: "Knock_ref_matrix!"}, + {Name: "Ignition retard limit", Data: "Knock_lim_tab!"}, + {Name: "Boost reduction map", Data: "Apc_knock_tab!"}, + }}, + {Name: "Warmup", Children: []MenuItem{ + {Name: "Afterstart enrichment (1)", Data: "Eftersta_fak!"}, + {Name: "Afterstart enrichment (2)", Data: "Eftersta_fak2!"}, + }}, + {Name: "Idle", Children: []MenuItem{ + {Name: "Idle target RPM", Data: "Idle_rpm_tab!"}, + {Name: "Idle ignition", Data: "Ign_idle_angle!"}, + {Name: "Idle ignition correction", Data: "Ign_map_1!"}, + {Name: "Idle fuel map", Data: "Idle_fuel_korr!"}, + }}, + } } diff --git a/pkg/windows/mainmenu_t7.go b/pkg/windows/mainmenu_t7.go index d34e2f65..3e967555 100644 --- a/pkg/windows/mainmenu_t7.go +++ b/pkg/windows/mainmenu_t7.go @@ -1,106 +1,132 @@ package windows -var T7SymbolsTuningOrder = []string{ - "Diagnostics", - "Calibration", - "Injectors", - "Fuel", - "Ignition", - "Airmass", - "Boost", - "Knock", - "Limiters", - "Adaption", - "Myrtilos", -} - -var T7SymbolsTuning = map[string][]string{ - "Diagnostics": { - "DTC Reader", - // "Firmware information", - "F_KnkDetAdap.FKnkCntMap", - "F_KnkDetAdap.RKnkCntMap", - "KnkDetAdap.KnkCntMap", - "MissfAdap.MissfCntMap", - }, - "Calibration": { - "ESP Calibration", - "AirCompCal.PressMap", - "Ethanol adaption value|E85.X_EthAct_Tech2", - "MAFCal.m_RedundantAirMap", - "TCompCal.EnrFacE85Tab", - "TCompCal.EnrFacTab", - "VIOSMAFCal.FreqSP", - "VIOSMAFCal.Q_AirInletTab2", - }, - "Injectors": { - "Injector dead time|InjCorrCal.BattCorrTab", - "Injector dead time (Y)|InjCorrCal.BattCorrSP", - "Injector constant|InjCorrCal.InjectorConst", - }, - "Airmass": { - "Pedal request map|PedalMapCal.m_RequestMap", - "Pedal request airmass (Y)|TorqueCal.m_PedYSP", - "Air/Torque calibration|TorqueCal.m_AirTorqMap", - "Air/Torque (X)|TorqueCal.M_EngXSP", - "Nom. torque map|TorqueCal.M_NominalMap", - "Nom. torque map (X)|TorqueCal.m_AirXSP", - }, - "Fuel": { - "VE map|BFuelCal.Map", - "Startup VE map / E85 VE map|BFuelCal.StartMap", - "Gas VE map|BFuelCal.GasMap", - "Enrichment factor during starting|StartCal.EnrFacTab", - "Enrichment factor during starting E85|StartCal.EnrFacE85Tab", - }, - "Ignition": { - "Ignition map|IgnNormCal.Map", - "Ignition for E85|IgnE85Cal.fi_AbsMap", - "Ignition for Gas|IgnNormCal.GasMap", - "Ignition Idle|IgnIdleCal.fi_IdleMap", - "Ignition Start|IgnStartCal.fi_StartMap", - "Knock pull map|IgnKnkCal.IndexMap", - "Max knock pull|KnkFuelCal.fi_MapMaxOff", - }, - "Boost": { - "Boost calibration|BoostCal.RegMap", - "P factor|BoostCal.PMap", - "I factor|BoostCal.IMap", - "D factor|BoostCal.DMap", - }, - "Knock": { - "Knock enrichment|KnkFuelCal.EnrichmentMap", - "Knock sensitivity|KnkDetCal.RefFactorMap", - }, - "Limiters": { - "Airmass (M)|BstKnkCal.MaxAirmass", - "Engine torque (M)|TorqueCal.M_EngMaxTab", - "Engine torque for E85 (M)|TorqueCal.M_EngMaxE85Tab", - "Gear Torque (M)|TorqueCal.M_ManGearLim", - "Gear Torque (5th)|TorqueCal.M_5GearLimTab", - "Airmass (A)|BstKnkCal.MaxAirmassAu", - "Engine torque (A)|TorqueCal.M_EngMaxAutTab", - "Engine torque for E85 (A)|TorqueCal.M_EngMaxE85TabAut", - "RPM limiter|MaxSpdCal.n_EngLimAir", - "Fuel cut|FCutCal.m_AirInletLimit", - "Speed limiter|MaxVehicCal.v_MaxSpeed", - "Overboost|TorqueCal.M_OverBoostTab", - }, - "Adaption": { - "Temp limit for adaption|AdpFuelCal.T_AdaptLim", - "Fuelcut enabled|FCutCal.ST_Enable", - "Closed loop regulation|LambdaCal.ST_Enable", - "Purge enabled|PurgeCal.ST_PurgeEnable", - "Biopower enabled|E85Cal.ST_Enable", - }, - "Myrtilos": { - "Register EU0D", - "MyrtilosCal.Launch_DisableSpeed", - "MyrtilosCal.Launch_Ign_fi_Min", - "MyrtilosCal.Launch_RPM", - "MyrtilosCal.Launch_InjFac_at_rpm", - "MyrtilosCal.Launch_PWM_max_at_stand", - "MyrtilosAdap.WBLambda_FeedbackMap", - "MyrtilosAdap.WBLambda_FFMap", - }, +func (mw *MainWindow) t7Menu() []MenuItem { + return []MenuItem{ + {Name: "Diagnostics", Children: []MenuItem{ + {Name: "DTC Reader", Func: mw.openDTCReader}, + {Name: "F_KnkDetAdap.FKnkCntMap"}, + {Name: "F_KnkDetAdap.RKnkCntMap"}, + {Name: "KnkDetAdap.KnkCntMap"}, + {Name: "MissfAdap.MissfCntMap"}, + }}, + {Name: "Calibration", Children: []MenuItem{ + {Name: "ESP Calibration", Func: mw.openESPCalibration}, + {Name: "AirCompCal.PressMap"}, + {Name: "AirCtrlCal.map"}, + {Name: "AreaCal.Area"}, + {Name: "AreaCal.Table"}, + {Name: "Ethanol adaption value", Data: "E85.X_EthAct_Tech2"}, + {Name: "MAFCal.m_RedundantAirMap"}, + {Name: "TCompCal.EnrFacE85Tab"}, + {Name: "TCompCal.EnrFacTab"}, + {Name: "VIOSMAFCal.FreqSP"}, + {Name: "VIOSMAFCal.Q_AirInletTab2"}, + }}, + {Name: "Injectors", Children: []MenuItem{ + {Name: "Injector dead time", Data: "InjCorrCal.BattCorrTab"}, + {Name: "Injector dead time (Y)", Data: "InjCorrCal.BattCorrSP"}, + {Name: "Injector constant", Data: "InjCorrCal.InjectorConst"}, + }}, + {Name: "Airmass", Children: []MenuItem{ + {Name: "Pedal request map", Data: "PedalMapCal.m_RequestMap"}, + {Name: "Pedal request airmass (Y)", Data: "TorqueCal.m_PedYSP"}, + {Name: "Air/Torque calibration", Data: "TorqueCal.m_AirTorqMap"}, + {Name: "Air/Torque (X)", Data: "TorqueCal.M_EngXSP"}, + {Name: "Nom. torque map", Data: "TorqueCal.M_NominalMap"}, + {Name: "Nom. torque map (X)", Data: "TorqueCal.m_AirXSP"}, + }}, + {Name: "Fuel", Children: []MenuItem{ + {Name: "Fuel cut", Children: []MenuItem{ + {Name: "FCutCal.ST_Enable"}, + {Name: "FCutCal.FuelFactor"}, + {Name: "FCutCal.n_CombSP (Y)", Data: "FCutCal.n_CombSP"}, + }}, + {Name: "Closed Loop", Children: []MenuItem{ + {Name: "Max load norm", Data: "LambdaCal.MaxLoadNormTab"}, + {Name: "Max load E85", Data: "LambdaCal.MaxLoadE85Tab"}, + }}, + {Name: "TransCal", Children: []MenuItem{ + {Name: "TransCal.ST_Enable"}, + {Name: "TransCal.ST_DecNoLim"}, + {Name: "TransCal.AccRampFac"}, + {Name: "TransCal.DecRampFac"}, + {Name: "TransCal.AccFacMap"}, + {Name: "TransCal.DecFacMap"}, + {Name: "TransCal.RpmSP (Y)", Data: "TransCal.RpmSP"}, + {Name: "TransCal.AccSP (X)", Data: "TransCal.AccSP"}, + {Name: "TransCal.DecSP (X)", Data: "TransCal.DecSP"}, + {Name: "TransCal.RetMul"}, + {Name: "TransCal.AccMul"}, + {Name: "TransCal.RetMulConst"}, + {Name: "TransCal.AccMulConst"}, + {Name: "TransCal.Delay1"}, + {Name: "TransCal.Delay2"}, + {Name: "TransCal.m_TriggMaxTab"}, + {Name: "TransCal.FilterConstAir"}, + }}, + {Name: "VE map", Data: "BFuelCal.Map", Region: "LambdaCal.MaxLoadNormTab"}, + {Name: "Startup / E85 VE map", Data: "BFuelCal.StartMap", Region: "LambdaCal.MaxLoadE85Tab"}, + {Name: "Gas VE map", Data: "BFuelCal.GasMap"}, + {Name: "Enrichment factor during starting", Data: "StartCal.EnrFacTab"}, + {Name: "Enrichment factor during starting E85", Data: "StartCal.EnrFacE85Tab"}, + {Name: "Enable cloosed loop regulation", Data: "LambdaCal.ST_Enable"}, + {Name: "Common for tuning", Data: "AdpFuelCal.T_AdaptLim|E85Cal.ST_Enable|FCutCal.ST_Enable|LambdaCal.ST_Enable|PurgeCal.ST_PurgeEnable|TorqueCal.M_BrakeLimit"}, + {Name: "Injection end angles", Data: "InjAnglCal.Map"}, + }}, + {Name: "Ignition", Children: []MenuItem{ + {Name: "Ignition map", Data: "IgnNormCal.Map", Region: "LambdaCal.MaxLoadNormTab"}, + {Name: "Ignition for E85", Data: "IgnE85Cal.fi_AbsMap", Region: "LambdaCal.MaxLoadE85Tab"}, + {Name: "Ignition for Gas", Data: "IgnNormCal.GasMap"}, + {Name: "Ignition Idle", Data: "IgnIdleCal.fi_IdleMap"}, + {Name: "Ignition Start", Data: "IgnStartCal.fi_StartMap"}, + {Name: "Knock pull map", Data: "IgnKnkCal.IndexMap"}, + {Name: "Max knock pull", Data: "KnkFuelCal.fi_MapMaxOff"}, + }}, + {Name: "Boost", Children: []MenuItem{ + {Name: "Boost regulation overview", Data: "BoostCal.RegMap|BoostCal.PMap|BoostCal.IMap|BoostCal.DMap"}, + {Name: "Boost calibration", Data: "BoostCal.RegMap"}, + {Name: "P factor", Data: "BoostCal.PMap"}, + {Name: "I factor", Data: "BoostCal.IMap"}, + {Name: "D factor", Data: "BoostCal.DMap"}, + }}, + {Name: "Knock", Children: []MenuItem{ + {Name: "Knock enrichment", Data: "KnkFuelCal.EnrichmentMap"}, + {Name: "Knock sensitivity", Data: "KnkDetCal.RefFactorMap"}, + }}, + {Name: "Limiters", Children: []MenuItem{ + {Name: "Manual", Children: []MenuItem{ + {Name: "Airmass (M)", Data: "BstKnkCal.MaxAirmass"}, + {Name: "Engine torque (M)", Data: "TorqueCal.M_EngMaxTab"}, + {Name: "Engine torque for E85 (M)", Data: "TorqueCal.M_EngMaxE85Tab"}, + {Name: "Gear Torque (M)", Data: "TorqueCal.M_ManGearLim"}, + {Name: "Gear Torque (5th)", Data: "TorqueCal.M_5GearLimTab"}, + }}, + {Name: "Automatic", Children: []MenuItem{ + {Name: "Airmass (A)", Data: "BstKnkCal.MaxAirmassAu"}, + {Name: "Engine torque (A)", Data: "TorqueCal.M_EngMaxAutTab"}, + {Name: "Engine torque for E85 (A)", Data: "TorqueCal.M_EngMaxE85TabAut"}, + }}, + {Name: "RPM limiter", Data: "MaxSpdCal.n_EngLimAir"}, + {Name: "Fuel cut", Data: "FCutCal.m_AirInletLimit"}, + {Name: "Speed limiter", Data: "MaxVehicCal.v_MaxSpeed"}, + {Name: "Overboost", Data: "TorqueCal.M_OverBoostTab"}, + }}, + {Name: "Adaption", Children: []MenuItem{ + {Name: "Temp limit for adaption", Data: "AdpFuelCal.T_AdaptLim"}, + {Name: "Fuelcut enabled", Data: "FCutCal.ST_Enable"}, + {Name: "Closed loop regulation", Data: "LambdaCal.ST_Enable"}, + {Name: "Purge enabled", Data: "PurgeCal.ST_PurgeEnable"}, + {Name: "Biopower enabled", Data: "E85Cal.ST_Enable"}, + }}, + {Name: "Myrtilos", Children: []MenuItem{ + {Name: "Register EU0D", Func: mw.openRegisterEU0D}, + {Name: "MyrtilosCal.Launch_DisableSpeed"}, + {Name: "MyrtilosCal.Launch_Ign_fi_Min"}, + {Name: "MyrtilosCal.Launch_RPM"}, + {Name: "MyrtilosCal.Launch_InjFac_at_rpm"}, + {Name: "MyrtilosCal.Launch_PWM_max_at_stand"}, + {Name: "MyrtilosAdap.WBLambda_FeedbackMap"}, + {Name: "MyrtilosAdap.WBLambda_FFMap"}, + }}, + } } diff --git a/pkg/windows/mainmenu_t8.go b/pkg/windows/mainmenu_t8.go index 7c62f361..b69c3818 100644 --- a/pkg/windows/mainmenu_t8.go +++ b/pkg/windows/mainmenu_t8.go @@ -1,86 +1,82 @@ package windows -var T8SymbolsTuningOrder = []string{ - "Diagnostics", - "Airmass", - "Torque", - "Injectors", - "Fuel", - "Boost", - "Ignition", - "Pedal", -} +import symbol "github.com/roffe/ecusymbol" -var T8SymbolsTuning = map[string][]string{ - "Diagnostics": { - "DTC Reader", - "Edit Parameters", - "Firmware info edit", - }, - "Airmass": { - "Max airmass map (manual)|BstKnkCal.MaxAirmass", - "Max airmass map (auto)|BstKnkCal.MaxAirmassAu", - "Airmass Fuelcut|FCutCal.m_AirInletLimit", - "AirCtrlCal.AirmassLimiter", - "AirCtrlCal.PRatioMaxTab", - }, - "Torque": { - "Nominal torque map|TrqMastCal.Trq_NominalMap", - "Airmass torque map|TrqMastCal.m_AirTorqMap", - "Ambient pressure trq limiter|TrqLimCal.Trq_CompressorNoiseRedLimMAP", - "Trq limit in overboost|TrqLimCal.Trq_OverBoostTab", - "Trq limit manual 150hp|TrqLimCal.Trq_MaxEngineManTab2", - "Trq limit manual 175+hp|TrqLimCal.Trq_MaxEngineManTab1", - "Trq limit auto 150hp|TrqLimCal.Trq_MaxEngineAutTab2", - "Trq limit auto 175+hp|TrqLimCal.Trq_MaxEngineAutTab1", - "Manual gear trq limit|TrqLimCal.Trq_ManGear", - "RPM limiter|MaxEngSpdCal.n_EngLimTab", - "TrqLimCal.Trq_MaxEngineTab1", - "TrqLimCal.Trq_MaxEngineTab2", - "FFTrqCal.FFTrq_MaxEngineTab1", - "FFTrqCal.FFTrq_MaxEngineTab2", - }, - "Injectors": { - "Inj. Constant|InjCorrCal.InjectorConst", - "Inj. dead time|InjCorrCal.BattCorrTab", - "Inj. dead time (Y)|InjCorrCal.BattCorrSP", - }, - "Fuel": { - "Fuel correction map|BFuelCal.LambdaOneFacMap", - "Enrichment Petrol|BFuelCal.TempEnrichFacMap", - "Enrichment E85|FFFuelCal.TempEnrichFacMAP", - "Knock fuel map|KnkFuelCal.EnrichmentMap", - "Injection end angle map|InjAnglCal.Map", - "Jerk enrichment petrol|BFuelCal.m_AirJerkTab", - "Jerk enrichment Fuelmaster|BFuelCal.JerkEnrichFacTab", - "PurgeCal.ST_PurgeEnable", - "LambdaCal.ST_Enable", - "FCutCal.ST_Enable", - "FFFuelCal.ST_enable", - "FuelDynCal.ST_Enable", - "TCompCal.ST_Enable", - }, - "Boost": { - "Boost regulation map|AirCtrlCal.RegMap", - "P factor|AirCtrlCal.Ppart_BoostMap", - "I factor|AirCtrlCal.Ipart_BoostMap", - "D factor|AirCtrlCal.Dpart_BoostMap", - "AirCtrlCal.ST_BoostEnable", - "BoostAdapCal.ST_enable", - "FrompAdapCal.ST_enable", - "AreaAdapCal.ST_enable", - }, - "Ignition": { - "Normal ignition map|IgnAbsCal.fi_NormalMAP", - "High octane map|IgnAbsCal.fi_highOctanMAP", - "Low octane map|IgnAbsCal.fi_lowOctanMAP", - "MBT ignition map|IgnAbsCal.fi_IgnMBTMAP", - "Fuel cut ignition map|IgnAbsCal.fi_FuelCutMAP", - "Startup map|IgnAbsCal.fi_StartMAP", - "IgnAbsCal.ST_EnableOctanMaps", - }, - "Pedal": { - "Pedal position map|TrqMastCal.X_AccPedalMAP", - "Torque request map|PedalMapCal.Trq_RequestMap", - }, +func (mw *MainWindow) t8Menu() []MenuItem { + return []MenuItem{ + {Name: "Diagnostics", Children: []MenuItem{ + {Name: "DTC Reader", Func: mw.openDTCReader}, + {Name: "Edit Parameters", Func: mw.openEditParameters}, + {Name: "Firmware info edit", Func: mw.openFirmwareInfoEdit}, + }}, + {Name: "Airmass", Children: []MenuItem{ + {Name: "Max airmass map (manual)", Data: "BstKnkCal.MaxAirmass"}, + {Name: "Max airmass map (auto)", Data: "BstKnkCal.MaxAirmassAu"}, + {Name: "Airmass Fuelcut", Data: "FCutCal.m_AirInletLimit"}, + {Name: "AirCtrlCal.AirmassLimiter"}, + {Name: "AirCtrlCal.PRatioMaxTab"}, + }}, + {Name: "Torque", Children: []MenuItem{ + {Name: "Nominal torque map", Data: "TrqMastCal.Trq_NominalMap"}, + {Name: "Airmass torque map", Data: "TrqMastCal.m_AirTorqMap"}, + {Name: "Ambient pressure trq limiter", Data: "TrqLimCal.Trq_CompressorNoiseRedLimMAP"}, + {Name: "Trq limit in overboost", Data: "TrqLimCal.Trq_OverBoostTab"}, + {Name: "Trq limit manual 150hp", Data: "TrqLimCal.Trq_MaxEngineManTab2"}, + {Name: "Trq limit manual 175+hp", Data: "TrqLimCal.Trq_MaxEngineManTab1"}, + {Name: "Trq limit auto 150hp", Data: "TrqLimCal.Trq_MaxEngineAutTab2"}, + {Name: "Trq limit auto 175+hp", Data: "TrqLimCal.Trq_MaxEngineAutTab1"}, + {Name: "Manual gear trq limit", Data: "TrqLimCal.Trq_ManGear"}, + {Name: "RPM limiter", Data: "MaxEngSpdCal.n_EngLimTab"}, + {Name: "TrqLimCal.Trq_MaxEngineTab1"}, + {Name: "TrqLimCal.Trq_MaxEngineTab2"}, + {Name: "FFTrqCal.FFTrq_MaxEngineTab1"}, + {Name: "FFTrqCal.FFTrq_MaxEngineTab2"}, + }}, + {Name: "Injectors", Children: []MenuItem{ + {Name: "Inj. Constant", Data: "InjCorrCal.InjectorConst"}, + {Name: "Inj. dead time", Data: "InjCorrCal.BattCorrTab"}, + {Name: "Inj. dead time (Y)", Data: "InjCorrCal.BattCorrSP"}, + }}, + {Name: "Fuel", Children: []MenuItem{ + {Name: "Fuel correction map", Data: "BFuelCal.LambdaOneFacMap"}, + {Name: "Enrichment Petrol", Data: "BFuelCal.TempEnrichFacMap"}, + {Name: "Enrichment E85", Data: "FFFuelCal.TempEnrichFacMAP"}, + {Name: "Knock fuel map", Data: "KnkFuelCal.EnrichmentMap"}, + {Name: "Injection end angle map", Data: "InjAnglCal.Map"}, + {Name: "Jerk enrichment petrol", Data: "BFuelCal.m_AirJerkTab"}, + {Name: "Jerk enrichment Fuelmaster", Data: "BFuelCal.JerkEnrichFacTab"}, + {Name: "PurgeCal.ST_PurgeEnable"}, + {Name: "LambdaCal.ST_Enable"}, + {Name: "FCutCal.ST_Enable"}, + {Name: "FFFuelCal.ST_enable"}, + {Name: "FuelDynCal.ST_Enable"}, + {Name: "TCompCal.ST_Enable"}, + }}, + {Name: "Boost", Children: []MenuItem{ + {Name: "Boost regulation map", Data: "AirCtrlCal.RegMap"}, + {Name: "P factor", Data: "AirCtrlCal.Ppart_BoostMap"}, + {Name: "I factor", Data: "AirCtrlCal.Ipart_BoostMap"}, + {Name: "D factor", Data: "AirCtrlCal.Dpart_BoostMap"}, + {Name: "AirCtrlCal.ST_BoostEnable"}, + {Name: "BoostAdapCal.ST_enable"}, + {Name: "FrompAdapCal.ST_enable"}, + {Name: "AreaAdapCal.ST_enable"}, + }}, + {Name: "Ignition", Children: []MenuItem{ + {Name: "Normal ignition map", Data: "IgnAbsCal.fi_NormalMAP"}, + {Name: "High octane map", Data: "IgnAbsCal.fi_highOctanMAP"}, + {Name: "Low octane map", Data: "IgnAbsCal.fi_lowOctanMAP"}, + {Name: "MBT ignition map", Data: "IgnAbsCal.fi_IgnMBTMAP"}, + {Name: "Fuel cut ignition map", Data: "IgnAbsCal.fi_FuelCutMAP"}, + {Name: "Startup map", Data: "IgnAbsCal.fi_StartMAP"}, + {Name: "IgnAbsCal.ST_EnableOctanMaps"}, + }}, + {Name: "Pedal", Children: []MenuItem{ + {Name: "Pedal position map", Data: "TrqMastCal.X_AccPedalMAP"}, + {Name: "Torque request map", Data: "PedalMapCal.Trq_RequestMap"}, + {Name: "Rescale AccPedalMap", Func: func() { + mw.openRescaler(symbol.ECU_T8, "TrqMastCal.X_AccPedalMAP") + }}, + }}, + } } diff --git a/pkg/windows/mainwindow_layout.go b/pkg/windows/mainwindow_layout.go index 655ba1c0..8841057e 100644 --- a/pkg/windows/mainwindow_layout.go +++ b/pkg/windows/mainwindow_layout.go @@ -58,12 +58,13 @@ func (mw *MainWindow) SaveLayout() error { return nil } + func writeLayout(name string, data []byte) error { layoutPath, err := common.GetLayoutPath() if err != nil { return err } - if err := os.WriteFile(filepath.Join(layoutPath, name+".json"), data, 0644); err != nil { + if err := os.WriteFile(filepath.Join(layoutPath, name+".json"), data, 0o644); err != nil { return err } return nil @@ -117,7 +118,6 @@ func (mw *MainWindow) jsonLayout() ([]byte, error) { Preset: mw.selects.presetSelect.Selected, Windows: history, }) - if err != nil { return nil, err } @@ -144,7 +144,7 @@ func (mw *MainWindow) LoadLayout(name string) error { mw.wm.CloseAll() if mw.dlc == nil { - //mw.selects.ecuSelect.SetSelected(layout.ECU) + // mw.selects.ecuSelect.SetSelected(layout.ECU) mw.selects.ecuSelect.Selected = layout.ECU mw.selects.presetSelect.SetSelected(layout.Preset) } @@ -163,16 +163,14 @@ func (mw *MainWindow) LoadLayout(name string) error { } if h.GaugeConfig != nil { - gauge, cancelFuncs, err := gauge.New(h.GaugeConfig) + gauge, cancelFn, err := gauge.New(h.GaugeConfig) if err != nil { mw.Error(fmt.Errorf("failed to create gauge: %w", err)) continue } iw := multiwindow.NewInnerWindow(h.Title, gauge) iw.OnClose = func() { - for _, cancel := range cancelFuncs { - cancel() - } + cancelFn() } mw.wm.Add(iw) continue @@ -184,7 +182,7 @@ func (mw *MainWindow) LoadLayout(name string) error { } if openMap { - mw.openMap(symbol.ECUTypeFromString(layout.ECU), h.Title, parts[0]) + mw.openMap(symbol.ECUTypeFromString(layout.ECU), h.Title, parts[0], "") } } diff --git a/pkg/windows/mainwindow_newgauge.go b/pkg/windows/mainwindow_newgauge.go index c8f21fd3..19183ffe 100644 --- a/pkg/windows/mainwindow_newgauge.go +++ b/pkg/windows/mainwindow_newgauge.go @@ -58,17 +58,11 @@ func NewGaugeCreator(mw *MainWindow) *GaugeCreator { g.entries.symbolNameSecondary = widget.NewSelect(symbols, func(s string) {}) g.entries.symbolNameSecondary.Disable() - g.entries.min = numericentry.New() - g.entries.min.SetText("0") - g.entries.max = numericentry.New() - g.entries.max.SetText("100") - - g.entries.center = numericentry.New() - g.entries.center.SetText("50") + g.entries.min = numericentry.New("0") + g.entries.max = numericentry.New("100") + g.entries.center = numericentry.New("50") g.entries.center.Disable() - - g.entries.steps = numericentry.New() - g.entries.steps.SetText("10") + g.entries.steps = numericentry.New("10") g.entries.typ = widget.NewSelect([]string{"Dial", "DualDial", "VBar", "HBar", "CBar"}, func(s string) { switch s { diff --git a/pkg/windows/mainwindow_selects.go b/pkg/windows/mainwindow_selects.go index 46fe0e45..f2316fc3 100644 --- a/pkg/windows/mainwindow_selects.go +++ b/pkg/windows/mainwindow_selects.go @@ -32,7 +32,7 @@ func (mw *MainWindow) createSelects() { mw.app.Preferences().SetString(prefsSelectedECU, s) idx := symbol.ECUTypeFromString(s) ebus.Publish(ebus.TOPIC_ECU, float64(idx)) - mw.SetMainMenu(mw.menu.GetMenu(s)) + mw.SetMainMenu(mw.GetMenu(s)) pres := mw.app.Preferences().StringWithFallback(s+prefsSelectedPreset, s+" Dash") mw.selects.presetSelect.SetSelected(pres) }) diff --git a/pprof.go b/pprof.go new file mode 100644 index 00000000..055522d2 --- /dev/null +++ b/pprof.go @@ -0,0 +1,28 @@ +//go:build pprof + +package main + +import ( + "log" + "net/http" + _ "net/http/pprof" + "os" + "os/signal" + "runtime/pprof" + "syscall" +) + +func init() { + // kill -USR1 to dump leaks + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGUSR1) + go func() { + for range sig { + pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1) + } + }() + + go func() { + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() +} diff --git a/setup_build_env.ps1 b/setup_build_env.ps1 index e7bf741f..add4034f 100644 --- a/setup_build_env.ps1 +++ b/setup_build_env.ps1 @@ -31,10 +31,9 @@ Expand-Archive -Path "$temp_dir\canusb_dll_driver.zip" -DestinationPath ".\canus Write-Output "Downloading llvm-MinGW" Invoke-WebRequest -Uri $llvm -OutFile "$temp_dir\llvm-mingw.zip" -# Write-Output "Extracting llvm-MinGW" +Write-Output "Extracting llvm-MinGW" Expand-Archive -Path "$temp_dir\llvm-mingw.zip" -DestinationPath ".\" -Force -# rename folder llvm-mingw-20251007-ucrt-x86_64 to llvm-mingw Write-Output "Renaming llvm-MinGW folder" Rename-Item -Path ".\llvm-mingw-20251216-ucrt-x86_64" -NewName "llvm-mingw" diff --git a/txconfigurator/main.go b/txconfigurator/main.go index 7c831cca..679e2f8c 100644 --- a/txconfigurator/main.go +++ b/txconfigurator/main.go @@ -15,7 +15,11 @@ func init() { func main() { myApp := app.New() myWindow := myApp.NewWindow("txbridge configurator") - cfg := txconfigurator.NewConfigurator() + + p := func() string { + return "192.168.4.1:1337" + } + cfg := txconfigurator.NewConfigurator(p) myWindow.SetContent(cfg) myWindow.Resize(fyne.NewSize(480, 200)) myWindow.ShowAndRun()