Add fonts, autotable, autotable examples

This commit is contained in:
2026-07-13 13:01:26 -04:00
parent ea3d2a6d03
commit cf8342f8d4
71 changed files with 16084 additions and 1443 deletions

295
go/webui/position.go Normal file
View File

@@ -0,0 +1,295 @@
package webui
import (
"strings"
"kjol/wasmruntime"
)
// The floating-position engine: given where the trigger is, how big the panel is,
// and how big the viewport is, work out where to put the panel. Pure math over
// structs — no DOM, no browser — so it tests natively and runs identically on the
// server (where it simply never gets called, because nothing is open during SSR).
//
// This replaces the hand-rolled positioning that the TSX kit had in four divergent
// copies (Floating.tsx, Tutorial.tsx, Menu's Submenu, DatePicker's dropdown). It
// deliberately FIXES three flaws in the original rather than reproducing them:
//
// 1. The original shifted on BOTH axes, so a tall panel that did not fit got
// clamped up into its own trigger — covering the thing you clicked. Shift here
// is cross-axis only (what floating-ui does): the main axis is the flip's job.
// 2. The original had no arrow offset, so a shifted panel kept its arrow pinned to
// its own centre and the arrow visibly detached from the trigger. Here the
// arrow tracks the trigger's centre, clamped to stay on the panel.
// 3. The original could not tell a caller how much room there actually was, so a
// long menu near the viewport edge just overflowed. MaxHeight / MaxWidth report
// the space available on the main axis so the panel can scroll instead.
//
// All coordinates are viewport coordinates (getBoundingClientRect + position:fixed
// speak the same language), so there is no scroll compensation anywhere. Adding
// scrollX/scrollY here would be a bug, not a fix.
// Placement values: a base side, optionally with a cross-axis alignment.
const (
PlacementTop = "top"
PlacementTopStart = "top-start"
PlacementTopEnd = "top-end"
PlacementBottom = "bottom"
PlacementBottomStart = "bottom-start"
PlacementBottomEnd = "bottom-end"
PlacementLeft = "left"
PlacementLeftStart = "left-start"
PlacementLeftEnd = "left-end"
PlacementRight = "right"
PlacementRightStart = "right-start"
PlacementRightEnd = "right-end"
)
// PositionOptions tunes ComputePosition. The zero value is not useful; use
// DefaultPositionOptions and override.
type PositionOptions struct {
Placement string // one of the Placement* constants; default bottom-start
Offset float64 // gap between trigger and panel, in px
Flip bool // move to the opposite side when this side does not fit
Shift bool // slide along the cross axis to stay in the viewport
Padding float64 // keep this far from the viewport edge (flip fit-check + shift clamp)
// ArrowSize is the arrow's full width/height in px. Zero means no arrow, and
// ArrowOffset is left at 0.
ArrowSize float64
// ArrowPadding keeps the arrow this far from the panel's corners, so it never
// pokes out of a rounded edge.
ArrowPadding float64
}
// DefaultPositionOptions matches the TSX defaults (bottom-start, 4px offset, flip
// and shift on, 8px viewport padding).
func DefaultPositionOptions() PositionOptions {
return PositionOptions{
Placement: PlacementBottomStart,
Offset: 4,
Flip: true,
Shift: true,
Padding: 8,
ArrowPadding: 4,
}
}
// Position is where to put the panel, in viewport coordinates.
type Position struct {
Top, Left float64
// Placement is the RESOLVED placement — after any flip. This is consumer-visible:
// an arrow must be drawn on the side opposite the resolved base, not the
// requested one, or a flipped tooltip points away from what it describes.
Placement string
// ArrowOffset is the arrow's centre along the panel's cross axis, measured from
// the panel's own top-left: an x for top/bottom placements, a y for left/right.
// Zero when ArrowSize is 0.
ArrowOffset float64
// MaxHeight / MaxWidth are the space available on the main axis at the resolved
// placement, for a panel that should scroll rather than overflow. Only the
// main-axis one is set; the other is 0, meaning unconstrained.
MaxHeight, MaxWidth float64
}
// ComputePosition places `floating` against `trigger` inside `vp`.
//
// The caller must have measured a real, laid-out panel: to know where a panel goes
// you must first know how big it is, which means rendering it (invisibly) and
// measuring it. See Floating.reposition, which does exactly that dance.
func ComputePosition(trigger, floating wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) Position {
if o.Placement == "" {
o.Placement = PlacementBottomStart
}
base, align := splitPlacement(o.Placement)
top, left := mainAxis(base, trigger, floating, o.Offset)
crossAxis(base, align, trigger, floating, &top, &left)
if o.Flip {
base = flip(base, top, left, trigger, floating, vp, o, &top, &left)
}
if o.Shift {
shiftCrossAxis(base, floating, vp, o.Padding, &top, &left)
}
pos := Position{Top: top, Left: left, Placement: joinPlacement(base, align)}
pos.MaxHeight, pos.MaxWidth = available(base, trigger, vp, o)
if o.ArrowSize > 0 {
pos.ArrowOffset = arrowOffset(base, trigger, floating, top, left, o)
}
return pos
}
// mainAxis positions the panel on the axis it is offset along — the only axis the
// base side controls.
func mainAxis(base string, t, f wasmruntime.Rect, offset float64) (top, left float64) {
switch base {
case "top":
top = t.Top() - f.Height - offset
case "bottom":
top = t.Bottom() + offset
case "left":
left = t.Left() - f.Width - offset
case "right":
left = t.Right() + offset
}
return
}
// crossAxis aligns the panel across the base side: start/center/end.
func crossAxis(base, align string, t, f wasmruntime.Rect, top, left *float64) {
if base == "top" || base == "bottom" {
switch align {
case "start":
*left = t.Left()
case "end":
*left = t.Right() - f.Width
default:
*left = t.CenterX() - f.Width/2
}
return
}
switch align {
case "start":
*top = t.Top()
case "end":
*top = t.Bottom() - f.Height
default:
*top = t.CenterY() - f.Height/2
}
}
// flip moves the panel to the opposite side when it does not fit on this one — but
// only if the opposite side actually fits. If neither side fits we keep the
// original: flipping into an equally bad position just makes it harder to predict.
// The cross-axis alignment is preserved (bottom-start flips to top-start).
func flip(base string, top, left float64, t, f wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions, outTop, outLeft *float64) string {
pad := o.Padding
fits := func(start, size, limit float64) bool { return start >= pad && start+size <= limit-pad }
switch base {
case "bottom":
if !fits(top, f.Height, vp.Height) {
if alt := t.Top() - f.Height - o.Offset; fits(alt, f.Height, vp.Height) {
*outTop = alt
return "top"
}
}
case "top":
if !fits(top, f.Height, vp.Height) {
if alt := t.Bottom() + o.Offset; fits(alt, f.Height, vp.Height) {
*outTop = alt
return "bottom"
}
}
case "right":
if !fits(left, f.Width, vp.Width) {
if alt := t.Left() - f.Width - o.Offset; fits(alt, f.Width, vp.Width) {
*outLeft = alt
return "left"
}
}
case "left":
if !fits(left, f.Width, vp.Width) {
if alt := t.Right() + o.Offset; fits(alt, f.Width, vp.Width) {
*outLeft = alt
return "right"
}
}
}
return base
}
// shiftCrossAxis slides the panel along the CROSS axis to keep it on screen. It
// deliberately does not touch the main axis: clamping there is what let the
// original push a panel on top of its own trigger.
func shiftCrossAxis(base string, f wasmruntime.Rect, vp wasmruntime.Size, pad float64, top, left *float64) {
if base == "top" || base == "bottom" {
*left = clamp(*left, pad, vp.Width-f.Width-pad)
return
}
*top = clamp(*top, pad, vp.Height-f.Height-pad)
}
// available reports the room between the trigger and the viewport edge on the
// resolved side, so a panel too big for it can scroll instead of overflowing.
func available(base string, t wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) (maxHeight, maxWidth float64) {
gap := o.Offset + o.Padding
switch base {
case "top":
return max(t.Top()-gap, 0), 0
case "bottom":
return max(vp.Height-t.Bottom()-gap, 0), 0
case "left":
return 0, max(t.Left()-gap, 0)
case "right":
return 0, max(vp.Width-t.Right()-gap, 0)
}
return 0, 0
}
// arrowOffset points the arrow at the trigger's centre, in panel-local coordinates,
// clamped so it stays on the panel even when shift has slid the panel away from the
// trigger. When the trigger is entirely off past the panel's edge the arrow sits at
// the clamp limit — visibly at the corner, which is the honest answer.
func arrowOffset(base string, t, f wasmruntime.Rect, top, left float64, o PositionOptions) float64 {
half := o.ArrowSize / 2
if base == "top" || base == "bottom" {
lo := o.ArrowPadding + half
hi := f.Width - o.ArrowPadding - half
return clamp(t.CenterX()-left, lo, max(lo, hi))
}
lo := o.ArrowPadding + half
hi := f.Height - o.ArrowPadding - half
return clamp(t.CenterY()-top, lo, max(lo, hi))
}
func splitPlacement(p string) (base, align string) {
base, align, found := strings.Cut(p, "-")
if !found {
align = "center"
}
switch base {
case "top", "bottom", "left", "right":
default:
base = "bottom"
}
return base, align
}
func joinPlacement(base, align string) string {
if align == "" || align == "center" {
return base
}
return base + "-" + align
}
// OppositeSide is the side an arrow lives on: a panel placed above its trigger has
// its arrow on the bottom edge, pointing down at it.
func OppositeSide(placement string) string {
base, _ := splitPlacement(placement)
switch base {
case "top":
return "bottom"
case "bottom":
return "top"
case "left":
return "right"
default:
return "left"
}
}
func clamp(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}