rename packages, refactor out tailwind compiler,

This commit is contained in:
2026-07-13 15:06:09 -04:00
parent c5b14d7c41
commit d52151cc1a
62 changed files with 1446 additions and 460 deletions

View File

@@ -1686,8 +1686,8 @@ type AutoTableState struct {
// thRefs memoizes header-cell refs, so a resize can measure them.
thRefs map[string]*vdom.Ref
// openSignals memoizes per-dropdown open state (see openSignal).
openSignals map[string]*vdom.Signal[bool]
// multiSelects memoizes the dropdown controllers (see multiSelect).
multiSelects map[string]*MultiSelect
// resolved by the last Render; kept so callers (export, toolbar actions) can ask
// what the current filter actually selected.
@@ -2116,34 +2116,33 @@ func (s *AutoTableState) MultiSelectSearch(identifier, placeholder string, value
for _, v := range values {
options = append(options, FormSelectOption{Value: v, Label: v})
}
open := s.openSignal(identifier)
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
FormMultiSelect(FormMultiSelectProps{
s.multiSelect(identifier).Render(FormMultiSelectProps{
Options: options,
Value: s.SearchValues(identifier),
Placeholder: pick(placeholder, "Any"),
Searchable: true,
ShowSelectAll: true,
Open: open.Get(),
OnToggle: func() { open.Set(!open.Get()) },
OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) },
}),
)
}
// openSignal memoizes the open state of a dropdown, keyed by identifier. It has to
// be memoized: a signal created fresh inside a render would reset to closed on
// every render, so the dropdown could never stay open.
func (s *AutoTableState) openSignal(key string) *vdom.Signal[bool] {
if s.openSignals == nil {
s.openSignals = map[string]*vdom.Signal[bool]{}
// multiSelect memoizes a dropdown controller, keyed by identifier.
//
// It MUST be memoized. A controller built inside a render would get fresh refs and a
// fresh closed state every frame — the dropdown could never stay open, and the
// outside-click listener would be watching an element that no longer exists.
func (s *AutoTableState) multiSelect(key string) *MultiSelect {
if s.multiSelects == nil {
s.multiSelects = map[string]*MultiSelect{}
}
sig, ok := s.openSignals[key]
ms, ok := s.multiSelects[key]
if !ok {
sig = vdom.NewSignal(false)
s.openSignals[key] = sig
ms = NewMultiSelect(DropdownOptions{})
s.multiSelects[key] = ms
}
return sig
return ms
}
// DateSearch renders a date field bound to an identifier. A date RANGE is two of
@@ -2719,17 +2718,13 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
if len(options) == 0 {
return nil
}
open := s.openSignal("__columns__")
return FormMultiSelect(FormMultiSelectProps{
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
Options: options,
Value: selected,
Placeholder: "Columns",
Searchable: true,
ShowSelectAll: true,
FieldWidth: "w-52",
Open: open.Get(),
OnToggle: func() { open.Set(!open.Get()) },
OnChange: func(visible []string) {
show := map[string]bool{}
for _, k := range visible {
@@ -3266,10 +3261,14 @@ type calcEditor struct {
position *vdom.Signal[int]
errorMsg *vdom.Signal[string]
opOpen *vdom.Signal[bool]
colMenu *Menu
fnMenu *Menu
constMenu *Menu
opSelect *MultiSelect // the Basic tab's operand picker
// The function menu's own search box.
fnSearch *vdom.Signal[string]
fnSearchRef *vdom.Ref
colMenu *Menu
fnMenu *Menu
constMenu *Menu
// The textarea and the highlight overlay behind it. Both are needed: the caret
// insert writes into the textarea, and the overlay's scroll has to follow it.
@@ -3282,27 +3281,40 @@ func (s *AutoTableState) calcEditorState() *calcEditor {
return s.editor
}
e := &calcEditor{
view: vdom.NewSignal("menu"),
editingID: vdom.NewSignal(""),
advanced: vdom.NewSignal(false),
name: vdom.NewSignal(""),
fn: vdom.NewSignal(string(CALC_FN_SUM)),
operands: vdom.NewSignal([]string{}),
formula: vdom.NewSignal(""),
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
precision: vdom.NewSignal(""),
position: vdom.NewSignal(int(COL_POS_RIGHT)),
errorMsg: vdom.NewSignal(""),
opOpen: vdom.NewSignal(false),
formulaRef: vdom.NewRef(),
overlayRef: vdom.NewRef(),
view: vdom.NewSignal("menu"),
editingID: vdom.NewSignal(""),
advanced: vdom.NewSignal(false),
name: vdom.NewSignal(""),
fn: vdom.NewSignal(string(CALC_FN_SUM)),
operands: vdom.NewSignal([]string{}),
formula: vdom.NewSignal(""),
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
precision: vdom.NewSignal(""),
position: vdom.NewSignal(int(COL_POS_RIGHT)),
errorMsg: vdom.NewSignal(""),
opSelect: NewMultiSelect(DropdownOptions{}),
fnSearch: vdom.NewSignal(""),
fnSearchRef: vdom.NewRef(),
formulaRef: vdom.NewRef(),
overlayRef: vdom.NewRef(),
// Standalone: these menus live INSIDE the editor's popover, and without it the
// single-open manager would read them as a rival panel and close their own
// parent as they opened.
colMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
fnMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
constMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
}
// Built after e exists, because it closes over it: closing the menu abandons the
// query. Reopening to a stale filter — four of thirty-five functions showing, for
// no visible reason — is worse than retyping.
e.fnMenu = NewMenu(MenuOptions{
Placement: PlacementBottomStart,
Standalone: true,
OnOpenChange: func(open bool) {
if !open {
e.fnSearch.Set("")
}
},
})
e.pop = NewPopover(PopoverProps{
Placement: PlacementBottomEnd,
// Closing the popover abandons the draft — reopening should start clean rather
@@ -3382,12 +3394,124 @@ var calcDataTypes = []struct {
{CALC_TYPE_PLAIN, "Plain"},
}
// FormulaFunctionNames are the functions a formula may call, for the insert menu.
var FormulaFunctionNames = []string{
"SUM", "AVERAGE", "MEDIAN", "MODE", "MIN", "MAX", "COUNT",
"ABS", "ROUND", "FLOOR", "CEILING", "SQRT", "POWER", "MOD", "EXP", "LN", "LOG",
"SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "ATAN2", "SINH", "COSH", "TANH",
"RADIANS", "DEGREES", "IF", "AND", "OR", "NOT", "ROW",
// FormulaFunction is one entry in the insert menu: what to type, what it takes, and
// what it does. The signature and the description are not decoration — they are what
// makes a list of thirty-five names usable by someone who does not already know them,
// and they are what the menu's search matches against.
type FormulaFunction struct {
Name string
Sig string
Desc string
}
// FormulaFunctionGroup is a category of functions.
type FormulaFunctionGroup struct {
Label string
Fns []FormulaFunction
}
// FormulaFunctionGroups are the functions a formula may call, grouped the way someone
// looking for one would think about them — by what they are FOR, not alphabetically.
// Someone who wants a total looks under Aggregate; nobody scans an A-to-Z list from
// ABS to TANH hoping to recognise something.
var FormulaFunctionGroups = []FormulaFunctionGroup{
{Label: "Aggregate", Fns: []FormulaFunction{
{"SUM", "SUM(range)", "Total of the values"},
{"AVERAGE", "AVERAGE(range)", "Mean of the values"},
{"MEDIAN", "MEDIAN(range)", "Middle value"},
{"MODE", "MODE(range)", "Most frequent value"},
{"MIN", "MIN(range)", "Smallest value"},
{"MAX", "MAX(range)", "Largest value"},
{"COUNT", "COUNT(range)", "How many numbers"},
}},
{Label: "Math", Fns: []FormulaFunction{
{"ABS", "ABS(n)", "Absolute value"},
{"ROUND", "ROUND(n, digits)", "Round to digits"},
{"FLOOR", "FLOOR(n)", "Round down"},
{"CEILING", "CEILING(n)", "Round up"},
{"SQRT", "SQRT(n)", "Square root"},
{"POWER", "POWER(n, p)", "n to the power p"},
{"MOD", "MOD(n, d)", "Remainder of n ÷ d"},
{"EXP", "EXP(n)", "e to the power n"},
{"LN", "LN(n)", "Natural log (base e)"},
{"LOG", "LOG(n, base)", "Log, base 10 by default"},
}},
{Label: "Trigonometry", Fns: []FormulaFunction{
{"SIN", "SIN(angle)", "Sine (radians)"},
{"COS", "COS(angle)", "Cosine (radians)"},
{"TAN", "TAN(angle)", "Tangent (radians)"},
{"ASIN", "ASIN(n)", "Inverse sine"},
{"ACOS", "ACOS(n)", "Inverse cosine"},
{"ATAN", "ATAN(n)", "Inverse tangent"},
{"ATAN2", "ATAN2(x, y)", "Angle of point (x, y)"},
{"SINH", "SINH(n)", "Hyperbolic sine"},
{"COSH", "COSH(n)", "Hyperbolic cosine"},
{"TANH", "TANH(n)", "Hyperbolic tangent"},
{"PI", "PI()", "π constant"},
{"RADIANS", "RADIANS(deg)", "Degrees → radians"},
{"DEGREES", "DEGREES(rad)", "Radians → degrees"},
}},
{Label: "Logic", Fns: []FormulaFunction{
{"IF", "IF(test, then, else)", "Choose by condition"},
{"AND", "AND(a, b, …)", "True if all are true"},
{"OR", "OR(a, b, …)", "True if any are true"},
{"NOT", "NOT(a)", "Negate"},
}},
{Label: "Row", Fns: []FormulaFunction{
{"ROW", "ROW()", "Current row number"},
}},
}
// FormulaConstant is a named constant, with what it is worth.
type FormulaConstant struct {
Name string
Desc string
}
// FormulaConstantOptions are the constants a formula may write bare, for the insert
// menu. The approximate value is shown because "PHI" tells you nothing and "≈ 1.618"
// tells you everything.
var FormulaConstantOptions = []FormulaConstant{
{"PI", "π ≈ 3.14159"},
{"E", "Euler's number ≈ 2.71828"},
{"TAU", "2π ≈ 6.28319"},
{"PHI", "Golden ratio ≈ 1.61803"},
{"SQRT2", "√2 ≈ 1.41421"},
}
// FormulaFunctionNames flattens the groups — for anything that just needs the names.
func FormulaFunctionNames() []string {
var out []string
for _, g := range FormulaFunctionGroups {
for _, f := range g.Fns {
out = append(out, f.Name)
}
}
return out
}
// filterFormulaGroups narrows the menu by a query, matching name, signature AND
// description — so "total" finds SUM, which is the whole point of carrying the prose
// around. Empty groups drop out rather than leaving a bare heading behind.
func filterFormulaGroups(query string) []FormulaFunctionGroup {
q := strings.TrimSpace(strings.ToLower(query))
if q == "" {
return FormulaFunctionGroups
}
var out []FormulaFunctionGroup
for _, g := range FormulaFunctionGroups {
var fns []FormulaFunction
for _, f := range g.Fns {
hay := strings.ToLower(f.Name + " " + f.Sig + " " + f.Desc)
if strings.Contains(hay, q) {
fns = append(fns, f)
}
}
if len(fns) > 0 {
out = append(out, FormulaFunctionGroup{Label: g.Label, Fns: fns})
}
}
return out
}
// operandOption is one entry in the operand pickers and the column insert menu: the
@@ -3743,14 +3867,12 @@ func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNod
msOpts = append(msOpts, FormSelectOption{Value: o.Key, Label: o.Label})
}
children = append(children, calcField("Columns (combined per row)",
FormMultiSelect(FormMultiSelectProps{
e.opSelect.Render(FormMultiSelectProps{
Options: msOpts,
Value: e.operands.Get(),
Placeholder: "Select columns…",
Searchable: true,
Small: true,
Open: e.opOpen.Get(),
OnToggle: func() { e.opOpen.Set(!e.opOpen.Get()) },
OnChange: func(v []string) { e.operands.Set(v) },
})))
}
@@ -3796,25 +3918,45 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
)
}
fnItems := make([]*vdom.VNode, 0, len(FormulaFunctionNames))
for _, name := range FormulaFunctionNames {
fname := name
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
OnClick: func() { e.insertAtCaret(fname + "()") },
}, vdom.Span(vdom.Attr("class", "font-mono text-emerald-700"), vdom.Text(fname))))
// The function menu, grouped by category and searchable. Thirty-five names in one
// flat list is a wall; grouped by what they are FOR, with a signature and a
// sentence, it is something you can actually find SUM in.
fnItems := []*vdom.VNode{e.fnSearchBox()}
groups := filterFormulaGroups(e.fnSearch.Get())
if len(groups) == 0 {
fnItems = append(fnItems, vdom.Div(
vdom.Attr("class", "px-2 py-3 text-center text-xs text-neutral-500"),
vdom.Text("No functions match"),
))
}
for _, g := range groups {
fnItems = append(fnItems, MenuSection("", vdom.Text(g.Label)))
for _, fn := range g.Fns {
f := fn
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
OnClick: func() { e.insertFunction(f) },
},
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5 min-w-0"),
// The signature is syntax-highlighted with the same highlighter the
// formula box uses, so the menu and the editor speak one language.
vdom.Span(vdom.Attr("class", "font-mono text-xs"), vdom.Raw(HighlightFormula(f.Sig))),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(f.Desc)),
),
))
}
}
constNames := make([]string, 0, len(FormulaConstants))
for name := range FormulaConstants {
constNames = append(constNames, name)
}
sort.Strings(constNames)
constItems := make([]*vdom.VNode, 0, len(constNames))
for _, name := range constNames {
cname := name
constItems := make([]*vdom.VNode, 0, len(FormulaConstantOptions))
for _, c := range FormulaConstantOptions {
cc := c
constItems = append(constItems, e.constMenu.Item(MenuItemProps{
OnClick: func() { e.insertAtCaret(cname) },
}, vdom.Span(vdom.Attr("class", "font-mono text-amber-600"), vdom.Text(cname))))
OnClick: func() { e.insertAtCaret(cc.Name) },
},
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5"),
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600"), vdom.Text(cc.Name)),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(cc.Desc)),
),
))
}
menus := vdom.Div(vdom.Attr("class", "flex flex-wrap items-center gap-1"),
@@ -3876,6 +4018,44 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
return calcField("Formula", children...)
}
// fnSearchBox filters the function menu. It is sticky, so it stays reachable as the
// list scrolls, and it is focused when the menu opens — you can type "total", see SUM,
// and never touch the mouse.
func (e *calcEditor) fnSearchBox() *vdom.VNode {
wasmruntime.AfterRender(func() { wasmruntime.Focus(e.fnSearchRef) })
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-neutral-200 bg-white p-1.5"),
vdom.Input(
vdom.WithRef(e.fnSearchRef),
vdom.Attr("type", "text"),
vdom.Attr("class", "w-full rounded-default border border-neutral-300 p-1 text-xs focus:outline-2 focus:outline-sky-500"),
vdom.Attr("placeholder", "Search functions…"),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", e.fnSearch.Get()),
vdom.OnEvent(vdom.EVENT_INPUT, func(ev vdom.Event) { e.fnSearch.Set(ev.Value()) }),
),
)
}
// insertFunction drops a call in and puts the caret INSIDE the parentheses, ready for
// the first argument — SUM() with the cursor after the "(" rather than after the ")",
// which would make you move it back every single time.
//
// A no-argument function (ROW(), PI()) has nothing to type, so the caret goes after it.
func (e *calcEditor) insertFunction(f FormulaFunction) {
noArgs := strings.HasSuffix(f.Sig, "()")
e.insertAtCaretOffset(f.Name+"()", boolToInt(!noArgs))
e.fnSearch.Set("")
e.fnMenu.Close()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// syncOverlayScroll keeps the colours under the caret. Imperative on purpose: this
// fires on every scroll frame, and a signal write would re-render the whole table.
func (e *calcEditor) syncOverlayScroll() {
@@ -3916,7 +4096,11 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
// insertAtCaret drops text where the cursor is, rather than at the end — which is
// the whole point of an insert menu. It needs the caret position and focus back
// afterwards, both of which come from the host API.
func (e *calcEditor) insertAtCaret(text string) {
func (e *calcEditor) insertAtCaret(text string) { e.insertAtCaretOffset(text, 0) }
// insertAtCaretOffset inserts text and leaves the caret `back` characters from its
// end — so a function can land with the cursor between its parentheses.
func (e *calcEditor) insertAtCaretOffset(text string, back int) {
src := e.formula.Get()
start, end := wasmruntime.SelectionRange(e.formulaRef)
if start < 0 || start > len(src) || end < start || end > len(src) {
@@ -3927,7 +4111,7 @@ func (e *calcEditor) insertAtCaret(text string) {
// The textarea's value is written by the render this signal just scheduled, so the
// caret can only be placed once that render has landed.
caret := start + len(text)
caret := start + len(text) - back
wasmruntime.AfterRender(func() {
wasmruntime.Focus(e.formulaRef)
wasmruntime.SetSelectionRange(e.formulaRef, caret, caret)