first commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
kindle_fbink_go
|
||||||
|
test-go
|
||||||
41
build_kindle.sh
Executable file
41
build_kindle.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONTAINER_NAME=kindle_go_builder
|
||||||
|
WORKDIR=/src
|
||||||
|
OUTFILE=kindle_fbink_go
|
||||||
|
|
||||||
|
if ! docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}\$"; then
|
||||||
|
echo "[sh] Creating container"
|
||||||
|
docker run -dit --name ${CONTAINER_NAME} debian:bookworm sleep infinity
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[sh] Installing toolchain inside container"
|
||||||
|
docker exec ${CONTAINER_NAME} bash -c "
|
||||||
|
set -e
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y golang-go curl xz-utils build-essential gcc-arm-linux-gnueabi g++-arm-linux-gnueabi
|
||||||
|
if [ ! -d /usr/local/arm-linux-musleabi-cross ]; then
|
||||||
|
echo '[sh] Downloading musl cross toolchain...'
|
||||||
|
cd /usr/local
|
||||||
|
curl -L https://musl.cc/arm-linux-musleabi-cross.tgz | tar xz
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "[sh] Copying project into container"
|
||||||
|
docker exec ${CONTAINER_NAME} rm -rf ${WORKDIR}
|
||||||
|
docker cp ./go ${CONTAINER_NAME}:${WORKDIR}
|
||||||
|
|
||||||
|
echo "[sh] Building for ARMv7 soft-float (musl static)"
|
||||||
|
docker exec ${CONTAINER_NAME} bash -c "
|
||||||
|
set -e
|
||||||
|
export PATH=/usr/local/arm-linux-musleabi-cross/bin:\$PATH
|
||||||
|
cd ${WORKDIR}
|
||||||
|
export GOOS=linux GOARCH=arm GOARM=5 CGO_ENABLED=1 CC=arm-linux-gnueabi-gcc
|
||||||
|
go build -ldflags '-linkmode external -extldflags "-static"' -o kindle_fbink_go .
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "[sh] Copying binary back to hsot"
|
||||||
|
docker cp ${CONTAINER_NAME}:${WORKDIR}/${OUTFILE} ./${OUTFILE}
|
||||||
|
|
||||||
|
echo "[sh] Build complete: ${OUTFILE}"
|
||||||
73
src/display.go
Normal file
73
src/display.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DisplayController struct {
|
||||||
|
brightness BrightnessInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrightnessInfo struct {
|
||||||
|
maxBrightness int
|
||||||
|
currentBrightness int
|
||||||
|
brightnessPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKLIGHT_DIR = "/sys/class/backlight/"
|
||||||
|
|
||||||
|
func (a *App) initBrightness() {
|
||||||
|
entries, err := os.ReadDir(BACKLIGHT_DIR)
|
||||||
|
if err != nil || len(entries) != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := entries[0].Name()
|
||||||
|
brightnessPath := fmt.Sprintf("%s%s/brightness", BACKLIGHT_DIR, entry)
|
||||||
|
maxPath := fmt.Sprintf("%s%s/max_brightness", BACKLIGHT_DIR, entry)
|
||||||
|
|
||||||
|
if err := os.WriteFile(brightnessPath, []byte("256\n"), 0644); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxBytes, err := os.ReadFile(maxPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxVal, err := strconv.Atoi(strings.TrimSpace(string(maxBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controller := DisplayController{
|
||||||
|
brightness: BrightnessInfo{
|
||||||
|
maxBrightness: maxVal,
|
||||||
|
currentBrightness: 256,
|
||||||
|
brightnessPath: brightnessPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fmt.Println("display controller assembled")
|
||||||
|
a.dispCtrl = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) setBrightness(newBrightness int) {
|
||||||
|
filepath := a.dispCtrl.brightness.brightnessPath
|
||||||
|
if filepath == "" {
|
||||||
|
fmt.Println("no filepath to change brightness")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newBrightness > a.dispCtrl.brightness.maxBrightness {
|
||||||
|
newBrightness = a.dispCtrl.brightness.maxBrightness
|
||||||
|
}
|
||||||
|
if newBrightness < 0 {
|
||||||
|
newBrightness = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
os.WriteFile(filepath, []byte(strconv.Itoa(newBrightness)+"\n"), 0644)
|
||||||
|
a.dispCtrl.brightness.currentBrightness = newBrightness
|
||||||
|
|
||||||
|
}
|
||||||
236
src/draw.go
Normal file
236
src/draw.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
type Vec3 struct{ x, y, z float64 }
|
||||||
|
|
||||||
|
func project(v Vec3, w, h int, scale float64) (int, int) {
|
||||||
|
return int(v.x*scale) + w/2, int(v.y*scale) + h/2
|
||||||
|
}
|
||||||
|
func rotate(v Vec3, ax, ay, az float64) Vec3 {
|
||||||
|
cx, sx := math.Cos(ax), math.Sin(ax)
|
||||||
|
cy, sy := math.Cos(ay), math.Sin(ay)
|
||||||
|
cz, sz := math.Cos(az), math.Sin(az)
|
||||||
|
x := v.x*cy*cz + v.y*(sx*sy*cz-cx*sz) + v.z*(cx*sy*cz+sx*sz)
|
||||||
|
y := v.x*cy*sz + v.y*(sx*sy*sz+cx*cz) + v.z*(cx*sy*sz-sx*cz)
|
||||||
|
z := v.x*-sy + v.y*sx*cy + v.z*cx*cy
|
||||||
|
return Vec3{x, y, z}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPixel(buf []byte, stride, x, y int, color byte) {
|
||||||
|
if x < 0 || y < 0 || x >= stride {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := y*stride + x
|
||||||
|
if idx < 0 || idx >= len(buf) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf[idx] = color
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawHSpan(buf []byte, stride, x0, x1, y int, color byte) {
|
||||||
|
if x0 > x1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if y < 0 || y*stride >= len(buf) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if x0 < 0 {
|
||||||
|
x0 = 0
|
||||||
|
}
|
||||||
|
if x1 >= stride {
|
||||||
|
x1 = stride - 1
|
||||||
|
}
|
||||||
|
row := y * stride
|
||||||
|
if row < 0 || row >= len(buf) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for x := x0; x <= x1; x++ {
|
||||||
|
idx := row + x
|
||||||
|
if idx >= 0 && idx < len(buf) {
|
||||||
|
buf[idx] = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawLine(buf []byte, stride, x0, y0, x1, y1 int, color byte) {
|
||||||
|
dx := int(math.Abs(float64(x1 - x0)))
|
||||||
|
sx := -1
|
||||||
|
if x0 < x1 {
|
||||||
|
sx = 1
|
||||||
|
}
|
||||||
|
dy := -int(math.Abs(float64(y1 - y0)))
|
||||||
|
sy := -1
|
||||||
|
if y0 < y1 {
|
||||||
|
sy = 1
|
||||||
|
}
|
||||||
|
e := dx + dy
|
||||||
|
for {
|
||||||
|
if x0 >= 0 && x0 < stride && y0 >= 0 && y0*stride+x0 < len(buf) {
|
||||||
|
buf[y0*stride+x0] = color
|
||||||
|
}
|
||||||
|
if x0 == x1 && y0 == y1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
e2 := 2 * e
|
||||||
|
if e2 >= dy {
|
||||||
|
e += dy
|
||||||
|
x0 += sx
|
||||||
|
}
|
||||||
|
if e2 <= dx {
|
||||||
|
e += dx
|
||||||
|
y0 += sy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawRect(buf []byte, stride, x, y, w, h int, color byte) {
|
||||||
|
for j := y; j < y+h; j++ {
|
||||||
|
if j < 0 || j*stride >= len(buf) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := j * stride
|
||||||
|
for i := x; i < x+w; i++ {
|
||||||
|
if i >= 0 && i < stride && row+i < len(buf) {
|
||||||
|
buf[row+i] = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strokeRect(buf []byte, stride, x, y, w, h, thickness int, color byte) {
|
||||||
|
if thickness <= 0 || w <= 0 || h <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if thickness > w/2 {
|
||||||
|
thickness = w / 2
|
||||||
|
}
|
||||||
|
if thickness > h/2 {
|
||||||
|
thickness = h / 2
|
||||||
|
}
|
||||||
|
drawRect(buf, stride, x, y, w, thickness, color)
|
||||||
|
drawRect(buf, stride, x, y+h-thickness, w, thickness, color)
|
||||||
|
drawRect(buf, stride, x, y, thickness, h, color)
|
||||||
|
drawRect(buf, stride, x+w-thickness, y, thickness, h, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillRoundedRect(buf []byte, stride, x, y, w, h, radius int, color byte) {
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if radius <= 0 {
|
||||||
|
drawRect(buf, stride, x, y, w, h, color)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxRadius := w
|
||||||
|
if h < maxRadius {
|
||||||
|
maxRadius = h
|
||||||
|
}
|
||||||
|
maxRadius /= 2
|
||||||
|
if radius > maxRadius {
|
||||||
|
radius = maxRadius
|
||||||
|
}
|
||||||
|
if radius <= 0 {
|
||||||
|
drawRect(buf, stride, x, y, w, h, color)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
innerHeight := h - 2*radius
|
||||||
|
if innerHeight > 0 {
|
||||||
|
drawRect(buf, stride, x, y+radius, w, innerHeight, color)
|
||||||
|
}
|
||||||
|
floatR := float64(radius)
|
||||||
|
for oy := 0; oy < radius; oy++ {
|
||||||
|
dy := floatR - (float64(oy) + 0.5)
|
||||||
|
delta := floatR*floatR - dy*dy
|
||||||
|
if delta < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
span := int(math.Sqrt(delta))
|
||||||
|
left := x + radius - span
|
||||||
|
right := x + w - radius + span - 1
|
||||||
|
drawHSpan(buf, stride, left, right, y+oy, color)
|
||||||
|
drawHSpan(buf, stride, left, right, y+h-1-oy, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strokeRoundedRect(buf []byte, stride, x, y, w, h, thickness, radius int, color byte) {
|
||||||
|
if thickness <= 0 || w <= 0 || h <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if thickness > w/2 {
|
||||||
|
thickness = w / 2
|
||||||
|
}
|
||||||
|
if thickness > h/2 {
|
||||||
|
thickness = h / 2
|
||||||
|
}
|
||||||
|
if radius <= 0 {
|
||||||
|
strokeRect(buf, stride, x, y, w, h, thickness, color)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxRadius := w
|
||||||
|
if h < maxRadius {
|
||||||
|
maxRadius = h
|
||||||
|
}
|
||||||
|
maxRadius /= 2
|
||||||
|
if radius > maxRadius {
|
||||||
|
radius = maxRadius
|
||||||
|
}
|
||||||
|
if radius <= 0 {
|
||||||
|
strokeRect(buf, stride, x, y, w, h, thickness, color)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
innerRadius := radius - thickness
|
||||||
|
if innerRadius < 0 {
|
||||||
|
innerRadius = 0
|
||||||
|
}
|
||||||
|
topWidth := w - 2*radius
|
||||||
|
if topWidth < 0 {
|
||||||
|
topWidth = 0
|
||||||
|
}
|
||||||
|
if topWidth > 0 {
|
||||||
|
drawRect(buf, stride, x+radius, y, topWidth, thickness, color)
|
||||||
|
drawRect(buf, stride, x+radius, y+h-thickness, topWidth, thickness, color)
|
||||||
|
}
|
||||||
|
sideHeight := h - 2*radius
|
||||||
|
if sideHeight < 0 {
|
||||||
|
sideHeight = 0
|
||||||
|
}
|
||||||
|
if sideHeight > 0 {
|
||||||
|
drawRect(buf, stride, x, y+radius, thickness, sideHeight, color)
|
||||||
|
drawRect(buf, stride, x+w-thickness, y+radius, thickness, sideHeight, color)
|
||||||
|
}
|
||||||
|
floatOuter := float64(radius)
|
||||||
|
floatInner := float64(innerRadius)
|
||||||
|
outerSq := floatOuter * floatOuter
|
||||||
|
innerSq := floatInner * floatInner
|
||||||
|
for oy := 0; oy < radius; oy++ {
|
||||||
|
pyTop := y + oy
|
||||||
|
pyBottom := y + h - 1 - oy
|
||||||
|
dy := floatOuter - (float64(oy) + 0.5)
|
||||||
|
outerDelta := outerSq - dy*dy
|
||||||
|
if outerDelta < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outerSpan := int(math.Sqrt(outerDelta))
|
||||||
|
outerLeft := x + radius - outerSpan
|
||||||
|
outerRight := x + w - radius + outerSpan - 1
|
||||||
|
if innerRadius <= 0 {
|
||||||
|
drawHSpan(buf, stride, outerLeft, outerRight, pyTop, color)
|
||||||
|
drawHSpan(buf, stride, outerLeft, outerRight, pyBottom, color)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
innerDelta := innerSq - dy*dy
|
||||||
|
if innerDelta <= 0 {
|
||||||
|
drawHSpan(buf, stride, outerLeft, outerRight, pyTop, color)
|
||||||
|
drawHSpan(buf, stride, outerLeft, outerRight, pyBottom, color)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
innerSpan := int(math.Sqrt(innerDelta))
|
||||||
|
innerLeft := x + radius - innerSpan
|
||||||
|
innerRight := x + w - radius + innerSpan - 1
|
||||||
|
drawHSpan(buf, stride, outerLeft, innerLeft-1, pyTop, color)
|
||||||
|
drawHSpan(buf, stride, innerRight+1, outerRight, pyTop, color)
|
||||||
|
drawHSpan(buf, stride, outerLeft, innerLeft-1, pyBottom, color)
|
||||||
|
drawHSpan(buf, stride, innerRight+1, outerRight, pyBottom, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
1804
src/fbink/fbink.h
Normal file
1804
src/fbink/fbink.h
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/fbink/libfbink.a
Normal file
BIN
src/fbink/libfbink.a
Normal file
Binary file not shown.
76
src/fbinkutil.go
Normal file
76
src/fbinkutil.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -I./fbink
|
||||||
|
#cgo LDFLAGS: -L./fbink -lfbink -lm
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include "fbink.h"
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FB struct {
|
||||||
|
file *os.File
|
||||||
|
fd C.int
|
||||||
|
cfg C.FBInkConfig
|
||||||
|
screenWidth int
|
||||||
|
screenHeight int
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitFBInk(width, height int) (*FB, error) {
|
||||||
|
fb, err := os.OpenFile("/dev/fb0", os.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open fb0: %w", err)
|
||||||
|
}
|
||||||
|
f := &FB{file: fb, fd: C.int(fb.Fd()), screenWidth: width, screenHeight: height}
|
||||||
|
C.fbink_init(f.fd, &f.cfg)
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FB) Close() { _ = f.file.Close() }
|
||||||
|
|
||||||
|
func (f *FB) WriteBuffer(buf []byte) error {
|
||||||
|
_, err := f.file.WriteAt(buf, 0)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FB) Clear(doRefresh bool) {
|
||||||
|
var r C.FBInkRect
|
||||||
|
C.fbink_cls(f.fd, &f.cfg, &r, C._Bool(doRefresh))
|
||||||
|
if doRefresh {
|
||||||
|
C.fbink_wait_for_complete(f.fd, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FB) PrintAtPixel(x, y int, text string, fontmult int) {
|
||||||
|
cstr := C.CString(text)
|
||||||
|
defer C.free(unsafe.Pointer(cstr))
|
||||||
|
f.cfg.hoffset = C.short(x)
|
||||||
|
f.cfg.voffset = C.short(y)
|
||||||
|
f.cfg.fontmult = C.uint8_t(fontmult)
|
||||||
|
C.fbink_print(f.fd, cstr, &f.cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FB) RefreshRect(top, left, w, h int) {
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
C.fbink_refresh(
|
||||||
|
f.fd,
|
||||||
|
C.uint32_t(top),
|
||||||
|
C.uint32_t(left),
|
||||||
|
C.uint32_t(w),
|
||||||
|
C.uint32_t(h),
|
||||||
|
&f.cfg,
|
||||||
|
)
|
||||||
|
C.fbink_wait_for_complete(f.fd, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FB) RefreshFull() {
|
||||||
|
f.RefreshRect(0, 0, f.screenWidth, f.screenHeight)
|
||||||
|
}
|
||||||
3
src/go.mod
Normal file
3
src/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module kindle_fbink_go
|
||||||
|
|
||||||
|
go 1.19
|
||||||
185
src/main.go
Normal file
185
src/main.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("startup")
|
||||||
|
const (
|
||||||
|
width = 1072
|
||||||
|
height = 1448
|
||||||
|
)
|
||||||
|
|
||||||
|
fb, err := InitFBInk(width, height)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer fb.Close()
|
||||||
|
|
||||||
|
var evfd *os.File
|
||||||
|
var (
|
||||||
|
touchAxisX AxisRange
|
||||||
|
touchAxisY AxisRange
|
||||||
|
haveAxis bool
|
||||||
|
)
|
||||||
|
|
||||||
|
if path, err := findTouchEventNode(); err == nil {
|
||||||
|
if f, e := openNonblock(path); e == nil {
|
||||||
|
evfd = f
|
||||||
|
fmt.Println("touch input:", path)
|
||||||
|
defer evfd.Close()
|
||||||
|
if xr, yr, err := ReadTouchAxisRanges(path); err == nil {
|
||||||
|
fmt.Printf("touch axis from sysfs X:[%d,%d] Y:[%d,%d]\n", xr.Min, xr.Max, yr.Min, yr.Max)
|
||||||
|
touchAxisX = xr
|
||||||
|
touchAxisY = yr
|
||||||
|
haveAxis = true
|
||||||
|
} else {
|
||||||
|
fmt.Println("warning: touch axis range:", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("warning: open touch:", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("warning:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := NewApp(fb, evfd, width, height)
|
||||||
|
if os.Getenv("KINDLE_UI_TOUCH_DEBUG") != "" {
|
||||||
|
app.EnableTouchDebug(true)
|
||||||
|
}
|
||||||
|
if haveAxis {
|
||||||
|
app.SetTouchAxisRange(touchAxisX, touchAxisY)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Using default touch axis range")
|
||||||
|
app.SetTouchAxisRange(
|
||||||
|
AxisRange{Min: 0, Max: 4095},
|
||||||
|
AxisRange{Min: 0, Max: 4095},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = "Welcome"
|
||||||
|
|
||||||
|
styles := map[string]*ButtonStyle{
|
||||||
|
"primary": {
|
||||||
|
BorderRadius: 20,
|
||||||
|
FillColor: bytePtr(0xE0),
|
||||||
|
StrokeColor: bytePtr(0x30),
|
||||||
|
StrokeThickness: 2,
|
||||||
|
FontMultiplier: 2,
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
BorderRadius: 20,
|
||||||
|
FillColor: bytePtr(0xF4),
|
||||||
|
StrokeColor: bytePtr(0x40),
|
||||||
|
StrokeThickness: 2,
|
||||||
|
FontMultiplier: 2,
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
BorderRadius: 20,
|
||||||
|
StrokeColor: bytePtr(0x30),
|
||||||
|
StrokeThickness: 2,
|
||||||
|
FontMultiplier: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
homePage := &Page{
|
||||||
|
Name: "home",
|
||||||
|
Background: 0xFF,
|
||||||
|
OnDraw: func(a *App) {
|
||||||
|
a.DrawText(40, 80, "test go ui", 3)
|
||||||
|
a.DrawText(40, 140, message, 2)
|
||||||
|
},
|
||||||
|
Buttons: []*Button{
|
||||||
|
{
|
||||||
|
Rect: Rect{X: width/2 - 220, Y: 260, W: 440, H: 120},
|
||||||
|
Label: "Say Hello",
|
||||||
|
Style: styles["primary"],
|
||||||
|
OnTap: func(a *App) {
|
||||||
|
message = "Hello from your Kindle!"
|
||||||
|
fmt.Println("Button pressed: Say Hello")
|
||||||
|
a.RequestRender()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Rect: Rect{X: width/2 - 220, Y: 420, W: 440, H: 120},
|
||||||
|
Label: "Go To Counter",
|
||||||
|
Style: styles["primary"],
|
||||||
|
OnTap: func(a *App) {
|
||||||
|
a.Navigate("counter")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Rect: Rect{X: width/2 - 220, Y: 580, W: 440, H: 120},
|
||||||
|
Label: "Log To Console",
|
||||||
|
Style: styles["secondary"],
|
||||||
|
OnTap: func(a *App) {
|
||||||
|
fmt.Println("console button tapped at", time.Now().Format(time.RFC3339))
|
||||||
|
message = "Logged a message to the console."
|
||||||
|
a.RequestRender()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Rect: Rect{X: width/2 - 220, Y: 740, W: 440, H: 120},
|
||||||
|
Label: "Higher brightness",
|
||||||
|
Style: styles["ghost"],
|
||||||
|
OnTap: func(a *App) {
|
||||||
|
fmt.Println("brightness change")
|
||||||
|
if a.dispCtrl.brightness.currentBrightness > 0 {
|
||||||
|
a.setBrightness(0)
|
||||||
|
} else {
|
||||||
|
a.setBrightness(256)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
counter := 0
|
||||||
|
|
||||||
|
counterPage := &Page{
|
||||||
|
Name: "counter",
|
||||||
|
Background: 0xFF,
|
||||||
|
OnDraw: func(a *App) {
|
||||||
|
a.DrawText(40, 80, "basically react start example", 3)
|
||||||
|
a.DrawText(40, 140, fmt.Sprintf("Current value: %d", counter), 2)
|
||||||
|
},
|
||||||
|
Buttons: []*Button{
|
||||||
|
{
|
||||||
|
Rect: Rect{X: width/2 - 220, Y: 260, W: 440, H: 120},
|
||||||
|
Label: "Increment",
|
||||||
|
Style: styles["primary"],
|
||||||
|
OnTapCount: func(a *App, count int) {
|
||||||
|
counter += count
|
||||||
|
a.RequestRender()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Rect: Rect{X: width/2 - 220, Y: 420, W: 440, H: 120},
|
||||||
|
Label: "Reset",
|
||||||
|
Style: styles["secondary"],
|
||||||
|
OnTap: func(a *App) {
|
||||||
|
counter = 0
|
||||||
|
a.RequestRender()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Rect: Rect{X: width/2 - 220, Y: 580, W: 440, H: 120},
|
||||||
|
Label: "Back",
|
||||||
|
Style: styles["ghost"],
|
||||||
|
OnTap: func(a *App) {
|
||||||
|
a.Navigate("home")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.AddPage(homePage)
|
||||||
|
app.AddPage(counterPage)
|
||||||
|
app.Navigate("home")
|
||||||
|
|
||||||
|
app.Run()
|
||||||
|
}
|
||||||
279
src/touch.go
Normal file
279
src/touch.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Event type categories (high-level groupings)
|
||||||
|
EV_SYN = 0x00 // Synchronization: marks end of a full event packet (a "frame" of updates)
|
||||||
|
EV_KEY = 0x01 // Key/button events: pressed/released toggles (also used for touch "down/up")
|
||||||
|
EV_ABS = 0x03 // Absolute axis events: reports positional or pressure data, not deltas
|
||||||
|
|
||||||
|
// EV_SYN codes
|
||||||
|
SYN_REPORT = 0 // Sent after a batch of ABS/KEY updates; tells you "this frame is complete"
|
||||||
|
|
||||||
|
// EV_KEY codes (pretend the touchscreen is a keyboard with one key)
|
||||||
|
BTN_TOUCH = 0x14a // Touch contact active (1 = finger down, 0 = finger up)
|
||||||
|
BTN_TOOL_FINGER = 0x145 // "A finger tool is in range" - capacitive proximity (optional)
|
||||||
|
|
||||||
|
// EV_ABS axis codes: continuous values like coordinates and pressure
|
||||||
|
ABS_X = 0x00 // Generic absolute X position (used on single-touch panels)
|
||||||
|
ABS_Y = 0x01 // Generic absolute Y position
|
||||||
|
ABS_MT_POSITION_X = 0x35 // Multi-touch X position for a specific contact slot
|
||||||
|
ABS_MT_POSITION_Y = 0x36 // Multi-touch Y position
|
||||||
|
ABS_PRESSURE = 0x18 // Pressure value (force of touch, if hardware supports it)
|
||||||
|
ABS_MT_TRACKING_ID = 0x39 // Unique ID for a finger contact (changes when finger lifts/reappears)
|
||||||
|
ABS_MT_SLOT = 0x2f // Selects which multi-touch "slot" subsequent MT events apply to
|
||||||
|
)
|
||||||
|
|
||||||
|
type inputEvent struct {
|
||||||
|
Sec int32
|
||||||
|
USec int32
|
||||||
|
Type uint16
|
||||||
|
Code uint16
|
||||||
|
Value int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type AxisRange struct {
|
||||||
|
Min int
|
||||||
|
Max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTouchEventNode() (string, error) {
|
||||||
|
candidates, _ := filepath.Glob("/dev/input/event*")
|
||||||
|
for _, ev := range candidates {
|
||||||
|
namePath := fmt.Sprintf("/sys/class/input/%s/device/name", filepath.Base(ev))
|
||||||
|
if b, err := os.ReadFile(namePath); err == nil {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(string(b)))
|
||||||
|
if strings.Contains(name, "touch") || strings.Contains(name, "synaptics") ||
|
||||||
|
strings.Contains(name, "atmel") || strings.Contains(name, "elan") ||
|
||||||
|
strings.Contains(name, "ft") || strings.Contains(name, "mxt") {
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ev := range []string{"/dev/input/event1", "/dev/input/event0"} {
|
||||||
|
if _, err := os.Stat(ev); err == nil {
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no input event node found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func openNonblock(path string) (*os.File, error) {
|
||||||
|
return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadTouchAxisRanges(eventPath string) (AxisRange, AxisRange, error) {
|
||||||
|
base := filepath.Base(eventPath)
|
||||||
|
baseDir := filepath.Join("/sys/class/input", base)
|
||||||
|
searchRoots := []string{
|
||||||
|
filepath.Join(baseDir, "device"),
|
||||||
|
filepath.Join(baseDir, "device", "device"),
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
data []byte
|
||||||
|
err error
|
||||||
|
tried []string
|
||||||
|
addPath = func(p string) {
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tried = append(tried, p)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for _, root := range searchRoots {
|
||||||
|
direct := filepath.Join(root, "abs")
|
||||||
|
addPath(direct)
|
||||||
|
patterns := []string{
|
||||||
|
filepath.Join(root, "*", "abs"),
|
||||||
|
filepath.Join(root, "*", "*", "abs"),
|
||||||
|
}
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if matches, e := filepath.Glob(pattern); e == nil {
|
||||||
|
for _, m := range matches {
|
||||||
|
addPath(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tried) == 0 {
|
||||||
|
tried = append(tried,
|
||||||
|
filepath.Join(baseDir, "device", "abs"),
|
||||||
|
filepath.Join(baseDir, "device", "device", "abs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, p := range tried {
|
||||||
|
if b, e := os.ReadFile(p); e == nil {
|
||||||
|
data = b
|
||||||
|
err = nil
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
err = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
return AxisRange{}, AxisRange{}, fmt.Errorf("read axis ranges: %w", err)
|
||||||
|
}
|
||||||
|
ranges := parseAxisRanges(data)
|
||||||
|
var xr, yr AxisRange
|
||||||
|
var ok bool
|
||||||
|
if xr, ok = ranges[ABS_MT_POSITION_X]; !ok {
|
||||||
|
xr = ranges[ABS_X]
|
||||||
|
}
|
||||||
|
if yr, ok = ranges[ABS_MT_POSITION_Y]; !ok {
|
||||||
|
yr = ranges[ABS_Y]
|
||||||
|
}
|
||||||
|
if xr.Max <= xr.Min {
|
||||||
|
xr = AxisRange{Min: 0, Max: touchMaxX - 1}
|
||||||
|
}
|
||||||
|
if yr.Max <= yr.Min {
|
||||||
|
yr = AxisRange{Min: 0, Max: touchMaxY - 1}
|
||||||
|
}
|
||||||
|
return xr, yr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAxisRanges(data []byte) map[uint16]AxisRange {
|
||||||
|
result := make(map[uint16]AxisRange)
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line = strings.ReplaceAll(line, ":", "")
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
codeVal, err := strconv.ParseInt(fields[0], 0, 32)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
minVal, err := strconv.ParseInt(fields[1], 0, 32)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
maxVal, err := strconv.ParseInt(fields[2], 0, 32)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[uint16(codeVal)] = AxisRange{Min: int(minVal), Max: int(maxVal)}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var touchDebugEnabled bool
|
||||||
|
|
||||||
|
func SetTouchDebugEnabled(enabled bool) {
|
||||||
|
touchDebugEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollTouch drains up to maxReads events. Returns last known x,y, whether a touch is down, and whether we have valid coordinates.
|
||||||
|
func PollTouch(evfd *os.File, maxReads int) (x, y int, pressed, haveXY bool) {
|
||||||
|
x = lastTouchX
|
||||||
|
y = lastTouchY
|
||||||
|
pressed = lastTouchPressed
|
||||||
|
haveXY = lastTouchHave
|
||||||
|
|
||||||
|
currentX := x
|
||||||
|
currentY := y
|
||||||
|
currentPressed := pressed
|
||||||
|
currentHave := haveXY
|
||||||
|
trackingActive := pressed
|
||||||
|
|
||||||
|
updated := false
|
||||||
|
debug := touchDebugEnabled
|
||||||
|
currentSlot := -1
|
||||||
|
for i := 0; i < maxReads; i++ {
|
||||||
|
var ev inputEvent
|
||||||
|
if err := binary.Read(evfd, binary.LittleEndian, &ev); err != nil {
|
||||||
|
if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EWOULDBLOCK) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("event type=0x%X code=0x%X value=%d\n", ev.Type, ev.Code, ev.Value)
|
||||||
|
}
|
||||||
|
switch ev.Type {
|
||||||
|
case EV_ABS:
|
||||||
|
switch ev.Code {
|
||||||
|
case ABS_X, ABS_MT_POSITION_X:
|
||||||
|
if trackingActive {
|
||||||
|
currentX = int(ev.Value)
|
||||||
|
currentHave = true
|
||||||
|
}
|
||||||
|
case ABS_Y, ABS_MT_POSITION_Y:
|
||||||
|
if trackingActive {
|
||||||
|
currentY = int(ev.Value)
|
||||||
|
currentHave = true
|
||||||
|
}
|
||||||
|
case ABS_MT_TRACKING_ID:
|
||||||
|
if ev.Value >= 0 {
|
||||||
|
currentPressed = true
|
||||||
|
trackingActive = true
|
||||||
|
} else {
|
||||||
|
currentPressed = false
|
||||||
|
trackingActive = false
|
||||||
|
}
|
||||||
|
case ABS_PRESSURE:
|
||||||
|
if ev.Value > 0 {
|
||||||
|
currentPressed = true
|
||||||
|
} else {
|
||||||
|
currentPressed = false
|
||||||
|
}
|
||||||
|
case ABS_MT_SLOT:
|
||||||
|
currentSlot = int(ev.Value)
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("event slot=%d\n", currentSlot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case EV_KEY:
|
||||||
|
if ev.Code == BTN_TOUCH || ev.Code == BTN_TOOL_FINGER {
|
||||||
|
currentPressed = ev.Value != 0
|
||||||
|
}
|
||||||
|
case EV_SYN:
|
||||||
|
if ev.Code == SYN_REPORT {
|
||||||
|
x = currentX
|
||||||
|
y = currentY
|
||||||
|
pressed = currentPressed
|
||||||
|
haveXY = currentHave
|
||||||
|
updated = true
|
||||||
|
// Return after a single report so callers observe each touch frame.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updated {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updated {
|
||||||
|
lastTouchX = x
|
||||||
|
lastTouchY = y
|
||||||
|
lastTouchPressed = pressed
|
||||||
|
lastTouchHave = haveXY
|
||||||
|
} else {
|
||||||
|
x = lastTouchX
|
||||||
|
y = lastTouchY
|
||||||
|
pressed = lastTouchPressed
|
||||||
|
haveXY = lastTouchHave
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
lastTouchX int
|
||||||
|
lastTouchY int
|
||||||
|
lastTouchPressed bool
|
||||||
|
lastTouchHave bool
|
||||||
|
)
|
||||||
708
src/ui.go
Normal file
708
src/ui.go
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
touchMaxX = 4096
|
||||||
|
touchMaxY = 4096
|
||||||
|
maxInt = int(^uint(0) >> 1)
|
||||||
|
displayRefreshCooldown = 200 * time.Millisecond
|
||||||
|
tapQueueMaxDelay = 350 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
func bytePtr(v byte) *byte {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rect struct {
|
||||||
|
X, Y, W, H int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Rect) Contains(x, y int) bool {
|
||||||
|
return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonStyle struct {
|
||||||
|
BorderRadius int
|
||||||
|
FillColor *byte
|
||||||
|
StrokeColor *byte
|
||||||
|
StrokeThickness int
|
||||||
|
FontMultiplier int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Button struct {
|
||||||
|
Rect Rect
|
||||||
|
Label string
|
||||||
|
FontMultiplier int
|
||||||
|
BorderRadius int
|
||||||
|
Style *ButtonStyle
|
||||||
|
OnTap func(*App)
|
||||||
|
OnTapCount func(*App, int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Button) fontSize() int {
|
||||||
|
if b.FontMultiplier > 0 {
|
||||||
|
return b.FontMultiplier
|
||||||
|
}
|
||||||
|
if b.Style != nil && b.Style.FontMultiplier > 0 {
|
||||||
|
return b.Style.FontMultiplier
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Button) draw(buf []byte, stride int) {
|
||||||
|
if b.Rect.W <= 0 || b.Rect.H <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fill := byte(0xF0)
|
||||||
|
strokeColor := byte(0x00)
|
||||||
|
strokeThickness := 2
|
||||||
|
radius := b.BorderRadius
|
||||||
|
if b.Style != nil {
|
||||||
|
if b.Style.FillColor != nil {
|
||||||
|
fill = *b.Style.FillColor
|
||||||
|
}
|
||||||
|
if b.Style.StrokeColor != nil {
|
||||||
|
strokeColor = *b.Style.StrokeColor
|
||||||
|
}
|
||||||
|
if b.Style.StrokeThickness >= 0 {
|
||||||
|
strokeThickness = b.Style.StrokeThickness
|
||||||
|
}
|
||||||
|
if b.Style.BorderRadius > 0 {
|
||||||
|
radius = b.Style.BorderRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if radius > 0 {
|
||||||
|
fillRoundedRect(buf, stride, b.Rect.X, b.Rect.Y, b.Rect.W, b.Rect.H, radius, fill)
|
||||||
|
if strokeThickness > 0 {
|
||||||
|
strokeRoundedRect(buf, stride, b.Rect.X, b.Rect.Y, b.Rect.W, b.Rect.H, strokeThickness, radius, strokeColor)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
drawRect(buf, stride, b.Rect.X, b.Rect.Y, b.Rect.W, b.Rect.H, fill)
|
||||||
|
if strokeThickness > 0 {
|
||||||
|
strokeRect(buf, stride, b.Rect.X, b.Rect.Y, b.Rect.W, b.Rect.H, strokeThickness, strokeColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Button) drawLabel(fb *FB) {
|
||||||
|
if b.Label == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
font := b.fontSize()
|
||||||
|
textX := b.Rect.X + 16
|
||||||
|
textY := b.Rect.Y + b.Rect.H/2 - (8 * font)
|
||||||
|
if textY < b.Rect.Y+4 {
|
||||||
|
textY = b.Rect.Y + 4
|
||||||
|
}
|
||||||
|
fb.PrintAtPixel(textX, textY, b.Label, font)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Button) Tap(app *App) {
|
||||||
|
if b.OnTap != nil {
|
||||||
|
b.OnTap(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Button) TapTimes(app *App, count int) {
|
||||||
|
if count <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.OnTapCount != nil {
|
||||||
|
b.OnTapCount(app, count)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
b.Tap(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Page struct {
|
||||||
|
Name string
|
||||||
|
Background byte
|
||||||
|
Buttons []*Button
|
||||||
|
OnDraw func(*App)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Page) Render(app *App) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fill := p.Background
|
||||||
|
if fill == 0 {
|
||||||
|
fill = 0xFF
|
||||||
|
}
|
||||||
|
for i := range app.buffer {
|
||||||
|
app.buffer[i] = fill
|
||||||
|
}
|
||||||
|
for _, btn := range p.Buttons {
|
||||||
|
btn.draw(app.buffer, app.stride)
|
||||||
|
}
|
||||||
|
_ = app.fb.WriteBuffer(app.buffer)
|
||||||
|
if p.OnDraw != nil {
|
||||||
|
p.OnDraw(app)
|
||||||
|
}
|
||||||
|
for _, btn := range p.Buttons {
|
||||||
|
btn.drawLabel(app.fb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Page) buttonAt(x, y int) *Button {
|
||||||
|
for _, btn := range p.Buttons {
|
||||||
|
if btn.Rect.Contains(x, y) {
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TouchTransform struct {
|
||||||
|
SwapAxes bool
|
||||||
|
InvertX bool
|
||||||
|
InvertY bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type GestureType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
GestureTap GestureType = iota
|
||||||
|
GesturePanStart
|
||||||
|
GesturePanUpdate
|
||||||
|
GesturePanEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
type Gesture struct {
|
||||||
|
Type GestureType
|
||||||
|
StartX int
|
||||||
|
StartY int
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
StartRawX int
|
||||||
|
StartRawY int
|
||||||
|
RawX int
|
||||||
|
RawY int
|
||||||
|
DeltaX int
|
||||||
|
DeltaY int
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type touchSession struct {
|
||||||
|
active bool
|
||||||
|
startX int
|
||||||
|
startY int
|
||||||
|
lastX int
|
||||||
|
lastY int
|
||||||
|
startRawX int
|
||||||
|
startRawY int
|
||||||
|
lastRawX int
|
||||||
|
lastRawY int
|
||||||
|
startTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *touchSession) begin(x, y int, rawX, rawY int) {
|
||||||
|
s.active = true
|
||||||
|
s.startX = x
|
||||||
|
s.startY = y
|
||||||
|
s.lastX = x
|
||||||
|
s.lastY = y
|
||||||
|
s.startRawX = rawX
|
||||||
|
s.startRawY = rawY
|
||||||
|
s.lastRawX = rawX
|
||||||
|
s.lastRawY = rawY
|
||||||
|
s.startTime = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *touchSession) update(x, y int, rawX, rawY int) {
|
||||||
|
if !s.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.lastX = x
|
||||||
|
s.lastY = y
|
||||||
|
s.lastRawX = rawX
|
||||||
|
s.lastRawY = rawY
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *touchSession) end() (Gesture, bool) {
|
||||||
|
if !s.active {
|
||||||
|
return Gesture{}, false
|
||||||
|
}
|
||||||
|
duration := time.Duration(0)
|
||||||
|
if !s.startTime.IsZero() {
|
||||||
|
duration = time.Since(s.startTime)
|
||||||
|
}
|
||||||
|
g := Gesture{
|
||||||
|
Type: GestureTap,
|
||||||
|
StartX: s.startX,
|
||||||
|
StartY: s.startY,
|
||||||
|
X: s.lastX,
|
||||||
|
Y: s.lastY,
|
||||||
|
StartRawX: s.startRawX,
|
||||||
|
StartRawY: s.startRawY,
|
||||||
|
RawX: s.lastRawX,
|
||||||
|
RawY: s.lastRawY,
|
||||||
|
DeltaX: s.lastX - s.startX,
|
||||||
|
DeltaY: s.lastY - s.startY,
|
||||||
|
Duration: duration,
|
||||||
|
}
|
||||||
|
s.active = false
|
||||||
|
return g, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
fb *FB
|
||||||
|
evfd *os.File
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
stride int
|
||||||
|
dispCtrl DisplayController
|
||||||
|
|
||||||
|
buffer []byte
|
||||||
|
|
||||||
|
pages map[string]*Page
|
||||||
|
current *Page
|
||||||
|
|
||||||
|
dirty bool
|
||||||
|
lastTouchX int
|
||||||
|
lastTouchY int
|
||||||
|
lastRawX int
|
||||||
|
lastRawY int
|
||||||
|
touchDebug bool
|
||||||
|
|
||||||
|
axisMinX int
|
||||||
|
axisMaxX int
|
||||||
|
axisMinY int
|
||||||
|
axisMaxY int
|
||||||
|
axisSampleCnt int
|
||||||
|
axisFixed bool
|
||||||
|
|
||||||
|
transform TouchTransform
|
||||||
|
session touchSession
|
||||||
|
|
||||||
|
pendingTaps []pendingTap
|
||||||
|
displayBusyUntil time.Time
|
||||||
|
tapFlushDeadline time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingTap struct {
|
||||||
|
button *Button
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp(fb *FB, evfd *os.File, width, height int) *App {
|
||||||
|
return &App{
|
||||||
|
fb: fb,
|
||||||
|
evfd: evfd,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
stride: width,
|
||||||
|
buffer: make([]byte, width*height),
|
||||||
|
pages: make(map[string]*Page),
|
||||||
|
dirty: true,
|
||||||
|
axisMinX: maxInt,
|
||||||
|
axisMaxX: -1,
|
||||||
|
axisMinY: maxInt,
|
||||||
|
axisMaxY: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AddPage(p *Page) {
|
||||||
|
if p == nil || p.Name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.pages[p.Name] = p
|
||||||
|
if a.current == nil {
|
||||||
|
a.current = p
|
||||||
|
a.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Navigate(name string) {
|
||||||
|
if p, ok := a.pages[name]; ok {
|
||||||
|
if a.current != p {
|
||||||
|
fmt.Printf("navigate to page %q\n", name)
|
||||||
|
}
|
||||||
|
a.current = p
|
||||||
|
a.RequestRender()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("warning: unknown page %q\n", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RequestRender() {
|
||||||
|
a.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DrawText(x, y int, text string, fontMultiplier int) {
|
||||||
|
if fontMultiplier <= 0 {
|
||||||
|
fontMultiplier = 1
|
||||||
|
}
|
||||||
|
a.fb.PrintAtPixel(x, y, text, fontMultiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderCurrent() {
|
||||||
|
if a.current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.current.Render(a)
|
||||||
|
a.fb.RefreshFull()
|
||||||
|
a.displayBusyUntil = time.Now().Add(displayRefreshCooldown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) EnableTouchDebug(enabled bool) {
|
||||||
|
a.touchDebug = enabled
|
||||||
|
SetTouchDebugEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetTouchTransform(t TouchTransform) {
|
||||||
|
a.transform = t
|
||||||
|
if a.touchDebug {
|
||||||
|
fmt.Printf("touch transform set: swap=%v invertX=%v invertY=%v\n", t.SwapAxes, t.InvertX, t.InvertY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetTouchAxisRange(xRange, yRange AxisRange) {
|
||||||
|
if xRange.Max > xRange.Min {
|
||||||
|
span := xRange.Max - xRange.Min
|
||||||
|
if span > a.width*2 {
|
||||||
|
a.axisMinX = 0
|
||||||
|
a.axisMaxX = a.width - 1
|
||||||
|
} else {
|
||||||
|
a.axisMinX = xRange.Min
|
||||||
|
a.axisMaxX = xRange.Max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if yRange.Max > yRange.Min {
|
||||||
|
span := yRange.Max - yRange.Min
|
||||||
|
if span > a.height*2 {
|
||||||
|
a.axisMinY = 0
|
||||||
|
a.axisMaxY = a.height - 1
|
||||||
|
} else {
|
||||||
|
a.axisMinY = yRange.Min
|
||||||
|
a.axisMaxY = yRange.Max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.axisFixed = true
|
||||||
|
if a.touchDebug {
|
||||||
|
fmt.Printf("touch axis range set X:[%d,%d] Y:[%d,%d]\n", a.axisMinX, a.axisMaxX, a.axisMinY, a.axisMaxY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Run() {
|
||||||
|
idleSleep := 20 * time.Millisecond
|
||||||
|
lastPressed := false
|
||||||
|
a.initBrightness()
|
||||||
|
a.setBrightness(0)
|
||||||
|
for {
|
||||||
|
if a.dirty {
|
||||||
|
a.renderCurrent()
|
||||||
|
a.dirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.evfd == nil {
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rawX, rawY, pressed, have := PollTouch(a.evfd, 32)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if a.touchDebug && pressed != lastPressed {
|
||||||
|
fmt.Printf("PollTouch: raw=(%d,%d) pressed=%v have=%v\n", rawX, rawY, pressed, have)
|
||||||
|
}
|
||||||
|
|
||||||
|
if have {
|
||||||
|
// Lock axis on very first coordinate we see
|
||||||
|
if !a.axisFixed {
|
||||||
|
a.observeRaw(rawX, rawY)
|
||||||
|
}
|
||||||
|
nx, ny := a.normalize(rawX, rawY)
|
||||||
|
a.lastTouchX, a.lastTouchY = nx, ny
|
||||||
|
a.lastRawX, a.lastRawY = rawX, rawY
|
||||||
|
a.processTouchSample(nx, ny, rawX, rawY, pressed, true)
|
||||||
|
} else {
|
||||||
|
a.processTouchSample(a.lastTouchX, a.lastTouchY, a.lastRawX, a.lastRawY, pressed, false)
|
||||||
|
}
|
||||||
|
a.tryFlushTapQueue(now, pressed)
|
||||||
|
|
||||||
|
lastPressed = pressed
|
||||||
|
time.Sleep(idleSleep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) processTouchSample(x, y int, rawX, rawY int, pressed bool, hasPosition bool) {
|
||||||
|
if pressed {
|
||||||
|
if !a.session.active {
|
||||||
|
a.session.begin(x, y, rawX, rawY)
|
||||||
|
if a.touchDebug {
|
||||||
|
fmt.Printf("touch start norm=(%d,%d) raw=(%d,%d)\n", x, y, rawX, rawY)
|
||||||
|
}
|
||||||
|
} else if hasPosition {
|
||||||
|
prevX, prevY := a.session.lastX, a.session.lastY
|
||||||
|
a.session.update(x, y, rawX, rawY)
|
||||||
|
if a.touchDebug && (prevX != x || prevY != y) {
|
||||||
|
fmt.Printf("touch move norm=(%d,%d)->(%d,%d) raw=(%d,%d)\n", prevX, prevY, x, y, rawX, rawY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.session.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gesture, ok := a.session.end()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.touchDebug {
|
||||||
|
fmt.Printf("touch end duration=%s delta=(%d,%d) rawDelta=(%d,%d)\n",
|
||||||
|
gesture.Duration, gesture.DeltaX, gesture.DeltaY,
|
||||||
|
gesture.RawX-gesture.StartRawX, gesture.RawY-gesture.StartRawY)
|
||||||
|
}
|
||||||
|
a.handleGesture(gesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleGesture(g Gesture) {
|
||||||
|
switch g.Type {
|
||||||
|
case GestureTap:
|
||||||
|
a.handleTap(g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleTap(g Gesture) {
|
||||||
|
if a.current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
btn := a.current.buttonAt(g.X, g.Y)
|
||||||
|
closest, dxEdge, dyEdge, dist2 := a.closestButton(g.X, g.Y)
|
||||||
|
const tapCaptureRadius = 60
|
||||||
|
if btn == nil && closest != nil && dist2 <= tapCaptureRadius*tapCaptureRadius {
|
||||||
|
btn = closest
|
||||||
|
}
|
||||||
|
if btn == nil {
|
||||||
|
if a.touchDebug {
|
||||||
|
fmt.Printf("tap norm=(%d,%d) raw=(%d,%d) -> no button; nearest=%q offset=(%d,%d)\n",
|
||||||
|
g.X, g.Y, g.RawX, g.RawY, buttonLabel(closest), dxEdge, dyEdge)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.touchDebug {
|
||||||
|
fmt.Printf("tap norm=(%d,%d) raw=(%d,%d) -> %q offset=(%d,%d) [queued]\n",
|
||||||
|
g.X, g.Y, g.RawX, g.RawY, btn.Label, dxEdge, dyEdge)
|
||||||
|
}
|
||||||
|
a.enqueueTap(btn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) enqueueTap(btn *Button) {
|
||||||
|
if btn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(a.pendingTaps) > 0 {
|
||||||
|
last := &a.pendingTaps[len(a.pendingTaps)-1]
|
||||||
|
if last.button == btn {
|
||||||
|
last.count++
|
||||||
|
a.tapFlushDeadline = time.Now().Add(tapQueueMaxDelay)
|
||||||
|
if a.touchDebug && last.count > 1 {
|
||||||
|
fmt.Printf("tap aggregate %q x%d\n", btn.Label, last.count)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.pendingTaps = append(a.pendingTaps, pendingTap{button: btn, count: 1})
|
||||||
|
a.tapFlushDeadline = time.Now().Add(tapQueueMaxDelay)
|
||||||
|
if a.touchDebug {
|
||||||
|
fmt.Printf("tap queued %q x1\n", btn.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) flushTapQueue() {
|
||||||
|
if len(a.pendingTaps) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range a.pendingTaps {
|
||||||
|
if entry.button == nil || entry.count <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.touchDebug {
|
||||||
|
label := entry.button.Label
|
||||||
|
if entry.count > 1 {
|
||||||
|
fmt.Printf("tap dispatch %q x%d\n", label, entry.count)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("tap dispatch %q\n", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.button.TapTimes(a, entry.count)
|
||||||
|
a.RequestRender()
|
||||||
|
}
|
||||||
|
a.pendingTaps = a.pendingTaps[:0]
|
||||||
|
a.displayBusyUntil = time.Now().Add(displayRefreshCooldown)
|
||||||
|
a.tapFlushDeadline = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) tryFlushTapQueue(now time.Time, pressed bool) {
|
||||||
|
if len(a.pendingTaps) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pressed && a.session.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deadlineReached := !a.tapFlushDeadline.IsZero() && !now.Before(a.tapFlushDeadline)
|
||||||
|
if !deadlineReached {
|
||||||
|
if a.dirty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.displayReady(now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.flushTapQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) displayReady(now time.Time) bool {
|
||||||
|
if a.displayBusyUntil.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !now.Before(a.displayBusyUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buttonLabel(b *Button) string {
|
||||||
|
if b == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return b.Label
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) closestButton(x, y int) (*Button, int, int, int) {
|
||||||
|
if a.current == nil || len(a.current.Buttons) == 0 {
|
||||||
|
return nil, 0, 0, maxInt
|
||||||
|
}
|
||||||
|
closest := a.current.Buttons[0]
|
||||||
|
bestDX, bestDY := pointRectOffset(closest.Rect, x, y)
|
||||||
|
bestDist := distSquared(bestDX, bestDY)
|
||||||
|
for _, btn := range a.current.Buttons[1:] {
|
||||||
|
dx, dy := pointRectOffset(btn.Rect, x, y)
|
||||||
|
d := distSquared(dx, dy)
|
||||||
|
if d < bestDist {
|
||||||
|
closest = btn
|
||||||
|
bestDist = d
|
||||||
|
bestDX = dx
|
||||||
|
bestDY = dy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closest, bestDX, bestDY, bestDist
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointRectOffset(r Rect, x, y int) (int, int) {
|
||||||
|
clampedX := clamp(x, r.X, r.X+r.W-1)
|
||||||
|
clampedY := clamp(y, r.Y, r.Y+r.H-1)
|
||||||
|
return x - clampedX, y - clampedY
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v, min, max int) int {
|
||||||
|
if v < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if v > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func distSquared(dx, dy int) int {
|
||||||
|
return dx*dx + dy*dy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) observeRaw(x, y int) {
|
||||||
|
if a.axisFixed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect samples to infer axis range (only called between touches)
|
||||||
|
changed := false
|
||||||
|
if a.axisMinX == maxInt || x < a.axisMinX {
|
||||||
|
a.axisMinX = x
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if a.axisMaxX < x {
|
||||||
|
a.axisMaxX = x
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if a.axisMinY == maxInt || y < a.axisMinY {
|
||||||
|
a.axisMinY = y
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if a.axisMaxY < y {
|
||||||
|
a.axisMaxY = y
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
a.axisSampleCnt++
|
||||||
|
if a.touchDebug {
|
||||||
|
fmt.Printf("touch axis inferred X:[%d,%d] Y:[%d,%d] samples=%d\n", a.axisMinX, a.axisMaxX, a.axisMinY, a.axisMaxY, a.axisSampleCnt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lock axis range immediately after first sample
|
||||||
|
if a.axisSampleCnt >= 1 {
|
||||||
|
a.axisFixed = true
|
||||||
|
if a.touchDebug {
|
||||||
|
fmt.Printf("touch axis range LOCKED at X:[%d,%d] Y:[%d,%d]\n", a.axisMinX, a.axisMaxX, a.axisMinY, a.axisMaxY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) normalize(rawX, rawY int) (int, int) {
|
||||||
|
minX := a.axisMinX
|
||||||
|
maxX := a.axisMaxX
|
||||||
|
minY := a.axisMinY
|
||||||
|
maxY := a.axisMaxY
|
||||||
|
|
||||||
|
if minX == maxInt || maxX <= minX {
|
||||||
|
minX = 0
|
||||||
|
maxX = touchMaxX - 1
|
||||||
|
}
|
||||||
|
if minY == maxInt || maxY <= minY {
|
||||||
|
minY = 0
|
||||||
|
maxY = touchMaxY - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
spanX := maxX - minX
|
||||||
|
spanY := maxY - minY
|
||||||
|
if spanX <= 0 {
|
||||||
|
spanX = 1
|
||||||
|
}
|
||||||
|
if spanY <= 0 {
|
||||||
|
spanY = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
nx := (rawX - minX) * (a.width - 1) / spanX
|
||||||
|
ny := (rawY - minY) * (a.height - 1) / spanY
|
||||||
|
|
||||||
|
if a.transform.SwapAxes {
|
||||||
|
nx, ny = ny, nx
|
||||||
|
}
|
||||||
|
if a.transform.InvertX {
|
||||||
|
nx = (a.width - 1) - nx
|
||||||
|
}
|
||||||
|
if a.transform.InvertY {
|
||||||
|
ny = (a.height - 1) - ny
|
||||||
|
}
|
||||||
|
|
||||||
|
if nx < 0 {
|
||||||
|
nx = 0
|
||||||
|
}
|
||||||
|
if nx >= a.width {
|
||||||
|
nx = a.width - 1
|
||||||
|
}
|
||||||
|
if ny < 0 {
|
||||||
|
ny = 0
|
||||||
|
}
|
||||||
|
if ny >= a.height {
|
||||||
|
ny = a.height - 1
|
||||||
|
}
|
||||||
|
return nx, ny
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user