Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53e5733100 | ||
|
|
11543947bd | ||
|
|
e79bb0e1b0 | ||
|
|
d1e8e3a2ed | ||
|
|
4a7acbb630 |
@@ -21,6 +21,8 @@ type triggerOpts struct {
|
|||||||
hasSignal bool
|
hasSignal bool
|
||||||
signalID string
|
signalID string
|
||||||
value string
|
value string
|
||||||
|
window bool
|
||||||
|
preventDefault bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type withSignalOpt struct {
|
type withSignalOpt struct {
|
||||||
@@ -34,6 +36,28 @@ func (o withSignalOpt) apply(opts *triggerOpts) {
|
|||||||
opts.value = o.value
|
opts.value = o.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type withWindowOpt struct{}
|
||||||
|
|
||||||
|
func (o withWindowOpt) apply(opts *triggerOpts) {
|
||||||
|
opts.window = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWindow makes the event listener attach to the window instead of the element.
|
||||||
|
func WithWindow() ActionTriggerOption {
|
||||||
|
return withWindowOpt{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type withPreventDefaultOpt struct{}
|
||||||
|
|
||||||
|
func (o withPreventDefaultOpt) apply(opts *triggerOpts) {
|
||||||
|
opts.preventDefault = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPreventDefault calls evt.preventDefault() for matched keys.
|
||||||
|
func WithPreventDefault() ActionTriggerOption {
|
||||||
|
return withPreventDefaultOpt{}
|
||||||
|
}
|
||||||
|
|
||||||
// WithSignal sets a signal value before triggering the action.
|
// WithSignal sets a signal value before triggering the action.
|
||||||
func WithSignal(sig *signal, value string) ActionTriggerOption {
|
func WithSignal(sig *signal, value string) ActionTriggerOption {
|
||||||
return withSignalOpt{
|
return withSignalOpt{
|
||||||
@@ -54,7 +78,7 @@ func buildOnExpr(base string, opts *triggerOpts) string {
|
|||||||
if !opts.hasSignal {
|
if !opts.hasSignal {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("$%s=%s;%s", opts.signalID, opts.value, base)
|
return fmt.Sprintf("$%s=%s,%s", opts.signalID, opts.value, base)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyOptions(options ...ActionTriggerOption) triggerOpts {
|
func applyOptions(options ...ActionTriggerOption) triggerOpts {
|
||||||
@@ -92,5 +116,49 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
|
|||||||
if key != "" {
|
if key != "" {
|
||||||
condition = fmt.Sprintf("evt.key==='%s' &&", key)
|
condition = fmt.Sprintf("evt.key==='%s' &&", key)
|
||||||
}
|
}
|
||||||
return h.Data("on:keydown", fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
|
attrName := "on:keydown"
|
||||||
|
if opts.window {
|
||||||
|
attrName = "on:keydown__window"
|
||||||
|
}
|
||||||
|
return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyBinding pairs a key with an action and per-binding options.
|
||||||
|
type KeyBinding struct {
|
||||||
|
Key string
|
||||||
|
Action *actionTrigger
|
||||||
|
Options []ActionTriggerOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyBind creates a KeyBinding for use with OnKeyDownMap.
|
||||||
|
func KeyBind(key string, action *actionTrigger, options ...ActionTriggerOption) KeyBinding {
|
||||||
|
return KeyBinding{Key: key, Action: action, Options: options}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnKeyDownMap produces a single window-scoped keydown attribute that dispatches
|
||||||
|
// to different actions based on the pressed key. Each binding can reference a
|
||||||
|
// different action and carry its own signal/preventDefault options.
|
||||||
|
func OnKeyDownMap(bindings ...KeyBinding) h.H {
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expr := ""
|
||||||
|
for i, b := range bindings {
|
||||||
|
opts := applyOptions(b.Options...)
|
||||||
|
|
||||||
|
branch := ""
|
||||||
|
if opts.preventDefault {
|
||||||
|
branch = "evt.preventDefault(),"
|
||||||
|
}
|
||||||
|
branch += buildOnExpr(actionURL(b.Action.id), &opts)
|
||||||
|
|
||||||
|
if i > 0 {
|
||||||
|
expr += " : "
|
||||||
|
}
|
||||||
|
expr += fmt.Sprintf("evt.key==='%s' ? (%s)", b.Key, branch)
|
||||||
|
}
|
||||||
|
expr += " : void 0"
|
||||||
|
|
||||||
|
return h.Data("on:keydown__window", expr)
|
||||||
}
|
}
|
||||||
|
|||||||
17
context.go
17
context.go
@@ -32,6 +32,7 @@ type Context struct {
|
|||||||
reqCtx context.Context
|
reqCtx context.Context
|
||||||
subscriptions []Subscription
|
subscriptions []Subscription
|
||||||
subsMu sync.Mutex
|
subsMu sync.Mutex
|
||||||
|
disposeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// View defines the UI rendered by this context.
|
// View defines the UI rendered by this context.
|
||||||
@@ -350,11 +351,23 @@ func (c *Context) ReplaceURLf(format string, a ...any) {
|
|||||||
c.ReplaceURL(fmt.Sprintf(format, a...))
|
c.ReplaceURL(fmt.Sprintf(format, a...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks.
|
// dispose idempotently tears down this context: unsubscribes all pubsub
|
||||||
|
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
|
||||||
|
func (c *Context) dispose() {
|
||||||
|
c.disposeOnce.Do(func() {
|
||||||
|
c.unsubscribeAll()
|
||||||
|
c.stopAllRoutines()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
|
||||||
|
// goroutines (OnIntervalRoutine, SSE loop) that this context is done.
|
||||||
func (c *Context) stopAllRoutines() {
|
func (c *Context) stopAllRoutines() {
|
||||||
select {
|
select {
|
||||||
case c.ctxDisposedChan <- struct{}{}:
|
case <-c.ctxDisposedChan:
|
||||||
|
// already closed
|
||||||
default:
|
default:
|
||||||
|
close(c.ctxDisposedChan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
internal/examples/keyboard/main.go
Normal file
74
internal/examples/keyboard/main.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
const gridSize = 8
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
v := via.New()
|
||||||
|
v.Config(via.Options{DocumentTitle: "Keyboard", ServerAddress: ":7331"})
|
||||||
|
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
x, y := 0, 0
|
||||||
|
dir := c.Signal("")
|
||||||
|
|
||||||
|
move := c.Action(func() {
|
||||||
|
switch dir.String() {
|
||||||
|
case "up":
|
||||||
|
y = max(0, y-1)
|
||||||
|
case "down":
|
||||||
|
y = min(gridSize-1, y+1)
|
||||||
|
case "left":
|
||||||
|
x = max(0, x-1)
|
||||||
|
case "right":
|
||||||
|
x = min(gridSize-1, x+1)
|
||||||
|
}
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
var rows []h.H
|
||||||
|
for row := range gridSize {
|
||||||
|
var cells []h.H
|
||||||
|
for col := range gridSize {
|
||||||
|
bg := "#e0e0e0"
|
||||||
|
if col == x && row == y {
|
||||||
|
bg = "#4a90d9"
|
||||||
|
}
|
||||||
|
cells = append(cells, h.Div(
|
||||||
|
h.Attr("style", fmt.Sprintf(
|
||||||
|
"width:48px;height:48px;background:%s;border:1px solid #ccc;",
|
||||||
|
bg,
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
rows = append(rows, h.Div(
|
||||||
|
append([]h.H{h.Attr("style", "display:flex;")}, cells...)...,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.Div(
|
||||||
|
h.H1(h.Text("Keyboard Grid")),
|
||||||
|
h.P(h.Text("Move with WASD or arrow keys")),
|
||||||
|
h.Div(rows...),
|
||||||
|
via.OnKeyDownMap(
|
||||||
|
via.KeyBind("w", move, via.WithSignal(dir, "up")),
|
||||||
|
via.KeyBind("a", move, via.WithSignal(dir, "left")),
|
||||||
|
via.KeyBind("s", move, via.WithSignal(dir, "down")),
|
||||||
|
via.KeyBind("d", move, via.WithSignal(dir, "right")),
|
||||||
|
via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()),
|
||||||
|
via.KeyBind("ArrowLeft", move, via.WithSignal(dir, "left"), via.WithPreventDefault()),
|
||||||
|
via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()),
|
||||||
|
via.KeyBind("ArrowRight", move, via.WithSignal(dir, "right"), via.WithPreventDefault()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
v.Start()
|
||||||
|
}
|
||||||
90
via.go
90
via.go
@@ -7,6 +7,7 @@
|
|||||||
package via
|
package via
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -16,9 +17,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
ossignal "os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
@@ -34,6 +38,7 @@ var datastarJS []byte
|
|||||||
type V struct {
|
type V struct {
|
||||||
cfg Options
|
cfg Options
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
|
server *http.Server
|
||||||
logger zerolog.Logger
|
logger zerolog.Logger
|
||||||
contextRegistry map[string]*Context
|
contextRegistry map[string]*Context
|
||||||
contextRegistryMutex sync.RWMutex
|
contextRegistryMutex sync.RWMutex
|
||||||
@@ -254,14 +259,82 @@ func (v *V) getCtx(id string) (*Context, error) {
|
|||||||
return nil, fmt.Errorf("ctx '%s' not found", id)
|
return nil, fmt.Errorf("ctx '%s' not found", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the Via HTTP server on the given address.
|
// Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
|
||||||
|
// signal is received, then performs a graceful shutdown.
|
||||||
func (v *V) Start() {
|
func (v *V) Start() {
|
||||||
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
|
|
||||||
handler := http.Handler(v.mux)
|
handler := http.Handler(v.mux)
|
||||||
if v.sessionManager != nil {
|
if v.sessionManager != nil {
|
||||||
handler = v.sessionManager.LoadAndSave(v.mux)
|
handler = v.sessionManager.LoadAndSave(v.mux)
|
||||||
}
|
}
|
||||||
v.logger.Fatal().Err(http.ListenAndServe(v.cfg.ServerAddress, handler)).Msg("http server failed")
|
v.server = &http.Server{
|
||||||
|
Addr: v.cfg.ServerAddress,
|
||||||
|
Handler: handler,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- v.server.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
ossignal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sig := <-sigCh:
|
||||||
|
v.logInfo(nil, "received signal %v, shutting down", sig)
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil && err != http.ErrServerClosed {
|
||||||
|
v.logger.Fatal().Err(err).Msg("http server failed")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown gracefully shuts down the server and all contexts.
|
||||||
|
// Safe for programmatic or test use.
|
||||||
|
func (v *V) Shutdown() {
|
||||||
|
v.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *V) shutdown() {
|
||||||
|
v.logInfo(nil, "draining all contexts")
|
||||||
|
v.drainAllContexts()
|
||||||
|
|
||||||
|
if v.server != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := v.server.Shutdown(ctx); err != nil {
|
||||||
|
v.logErr(nil, "http server shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.pubsub != nil {
|
||||||
|
if err := v.pubsub.Close(); err != nil {
|
||||||
|
v.logErr(nil, "pubsub close error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.logInfo(nil, "shutdown complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *V) drainAllContexts() {
|
||||||
|
v.contextRegistryMutex.Lock()
|
||||||
|
contexts := make([]*Context, 0, len(v.contextRegistry))
|
||||||
|
for _, c := range v.contextRegistry {
|
||||||
|
contexts = append(contexts, c)
|
||||||
|
}
|
||||||
|
v.contextRegistry = make(map[string]*Context)
|
||||||
|
v.contextRegistryMutex.Unlock()
|
||||||
|
|
||||||
|
for _, c := range contexts {
|
||||||
|
v.logDebug(c, "disposing context")
|
||||||
|
c.dispose()
|
||||||
|
}
|
||||||
|
v.logInfo(nil, "drained %d context(s)", len(contexts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPServeMux returns the underlying HTTP request multiplexer to enable user extentions, middleware and
|
// HTTPServeMux returns the underlying HTTP request multiplexer to enable user extentions, middleware and
|
||||||
@@ -445,10 +518,10 @@ func New() *V {
|
|||||||
case <-sse.Context().Done():
|
case <-sse.Context().Done():
|
||||||
v.logDebug(c, "SSE connection ended")
|
v.logDebug(c, "SSE connection ended")
|
||||||
return
|
return
|
||||||
case patch, ok := <-c.patchChan:
|
case <-c.ctxDisposedChan:
|
||||||
if !ok {
|
v.logDebug(c, "context disposed, closing SSE")
|
||||||
continue
|
return
|
||||||
}
|
case patch := <-c.patchChan:
|
||||||
switch patch.typ {
|
switch patch.typ {
|
||||||
case patchTypeElements:
|
case patchTypeElements:
|
||||||
if err := sse.PatchElements(patch.content); err != nil {
|
if err := sse.PatchElements(patch.content); err != nil {
|
||||||
@@ -530,8 +603,7 @@ func New() *V {
|
|||||||
v.logErr(c, "failed to handle session close: %v", err)
|
v.logErr(c, "failed to handle session close: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.unsubscribeAll()
|
c.dispose()
|
||||||
c.stopAllRoutines()
|
|
||||||
v.logDebug(c, "session close event triggered")
|
v.logDebug(c, "session close event triggered")
|
||||||
if v.cfg.DevMode {
|
if v.cfg.DevMode {
|
||||||
v.devModeRemovePersisted(c)
|
v.devModeRemovePersisted(c)
|
||||||
|
|||||||
95
via_test.go
95
via_test.go
@@ -128,6 +128,101 @@ func TestAction(t *testing.T) {
|
|||||||
assert.Contains(t, body, "/_action/")
|
assert.Contains(t, body, "/_action/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOnKeyDownWithWindow(t *testing.T) {
|
||||||
|
var trigger *actionTrigger
|
||||||
|
v := New()
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
trigger = c.Action(func() {})
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(trigger.OnKeyDown("Enter", WithWindow()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
assert.Contains(t, body, "data-on:keydown__window")
|
||||||
|
assert.Contains(t, body, "evt.key==='Enter'")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnKeyDownMap(t *testing.T) {
|
||||||
|
t.Run("multiple bindings with different actions", func(t *testing.T) {
|
||||||
|
var move, shoot *actionTrigger
|
||||||
|
var dir *signal
|
||||||
|
v := New()
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
dir = c.Signal("none")
|
||||||
|
move = c.Action(func() {})
|
||||||
|
shoot = c.Action(func() {})
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(
|
||||||
|
OnKeyDownMap(
|
||||||
|
KeyBind("w", move, WithSignal(dir, "up")),
|
||||||
|
KeyBind("ArrowUp", move, WithSignal(dir, "up"), WithPreventDefault()),
|
||||||
|
KeyBind(" ", shoot, WithPreventDefault()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
// Single attribute, window-scoped
|
||||||
|
assert.Contains(t, body, "data-on:keydown__window")
|
||||||
|
|
||||||
|
// Key dispatching
|
||||||
|
assert.Contains(t, body, "evt.key==='w'")
|
||||||
|
assert.Contains(t, body, "evt.key==='ArrowUp'")
|
||||||
|
assert.Contains(t, body, "evt.key===' '")
|
||||||
|
|
||||||
|
// Different actions referenced
|
||||||
|
assert.Contains(t, body, "/_action/"+move.id)
|
||||||
|
assert.Contains(t, body, "/_action/"+shoot.id)
|
||||||
|
|
||||||
|
// preventDefault only on ArrowUp and space branches
|
||||||
|
assert.Contains(t, body, "evt.key==='ArrowUp' ? (evt.preventDefault()")
|
||||||
|
assert.Contains(t, body, "evt.key===' ' ? (evt.preventDefault()")
|
||||||
|
|
||||||
|
// 'w' branch should NOT have preventDefault
|
||||||
|
assert.NotContains(t, body, "evt.key==='w' ? (evt.preventDefault()")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithSignal per binding", func(t *testing.T) {
|
||||||
|
var move *actionTrigger
|
||||||
|
var dir *signal
|
||||||
|
v := New()
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
dir = c.Signal("none")
|
||||||
|
move = c.Action(func() {})
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(
|
||||||
|
OnKeyDownMap(
|
||||||
|
KeyBind("w", move, WithSignal(dir, "up")),
|
||||||
|
KeyBind("s", move, WithSignal(dir, "down")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
assert.Contains(t, body, "$"+dir.ID()+"='up'")
|
||||||
|
assert.Contains(t, body, "$"+dir.ID()+"='down'")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty bindings returns nil", func(t *testing.T) {
|
||||||
|
result := OnKeyDownMap()
|
||||||
|
assert.Nil(t, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfig(t *testing.T) {
|
func TestConfig(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
v.Config(Options{DocumentTitle: "Test"})
|
v.Config(Options{DocumentTitle: "Test"})
|
||||||
|
|||||||
Reference in New Issue
Block a user