From 67f3c18120911a2ac86e06599d2551a40b8f601e Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 23 Oct 2026 18:52:33 +0200 Subject: [PATCH 1/2] Splitter: N panes with draggable, focusable dividers (WAI-ARIA window splitter) Paned has two panes, a fixed 10 px clamp and no keyboard. Splitter is the general one: - fixed panes sized by an mvvm.Observable (logical px), flexible panes by an Observable weight; Min and Max per pane; Visible Observable switches a pane (and its divider) off; - a divider moves the pane before it by +d and the one after by -d within their bounds, the other panes keep their pixels; a flexible pair changes its ratio, which survives a window resize; - Collapsible fixed panes go to nothing when dragged below half their Min, and come back on a double-click (a second press within 450 ms, or an EventClick tagged "double"), Enter, or a drag out; the divider stays; - keyboard per the APG pattern: arrows (Shift = 4 steps), Home/End, Enter, Tab between dividers and panes; RoleSeparator with aria-valuemin/max/now and "Resize " as its name; - the divider line, the accent and the focus ring are at least 3:1 against Background and Surface in both default themes (SplitterColors); - Cursor(x, y) tells the host "col-resize" or "row-resize"; Vertical gives the stacked variant; - OnResized reports, once per finished gesture, the panes the person changed, so a view-model can persist what was chosen and nothing that a layout produced. 100% statement coverage. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- splitter.go | 810 ++++++++++++++++++++++++++++++++++++++++++ splitter_more_test.go | 260 ++++++++++++++ splitter_test.go | 488 +++++++++++++++++++++++++ 4 files changed, 1559 insertions(+), 1 deletion(-) create mode 100644 splitter.go create mode 100644 splitter_more_test.go create mode 100644 splitter_test.go diff --git a/README.md b/README.md index a28b41e..289d153 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ every native target Go ships. | Input | `Entry`, `TextView` + `Selection` + IME preview + optional `Highlighter` (syntax spans) + `ShowLineNumbers` gutter, `SpinButton`, `Scale`, `RangeSlider`, `SearchEntry`, `TagField`, `ComboBox`, `FormField`, `Validate`/`Rule` | | Terminal | `TerminalView` (Cols×Rows `TermCell` grid, block cursor, wrap/scroll, per-cell FG/BG) | | Selection | `ListBox`, `TreeView`, `DropDown` | -| Containers | `Container` + swappable `Layout` (`FitLayout`, `BoxLayout`, `BorderLayout`, `CardLayout`, `FlowLayout`); convenience wrappers `HBox`, `VBox`, `Grid`, `Frame`, `Dock`, `Border`, `Stack`, `Overlay`, `Paned`, `Expander`, `Accordion`; declarative `Node` builder + `ViewController` (`LookupAs`) | +| Containers | `Container` + swappable `Layout` (`FitLayout`, `BoxLayout`, `BorderLayout`, `CardLayout`, `FlowLayout`); convenience wrappers `HBox`, `VBox`, `Grid`, `Frame`, `Dock`, `Border`, `Stack`, `Overlay`, `Paned`, `Splitter` (N panes with draggable, focusable dividers after the WAI-ARIA window-splitter pattern: fixed panes sized by an `mvvm.Observable`, flexible ones by weight, min/max, collapse on double-click, Enter or a drag to the minimum, arrows/Home/End/Enter, 3:1 divider and focus ring, resize cursor), `Expander`, `Accordion`; declarative `Node` builder + `ViewController` (`LookupAs`) | | Tabs | `Notebook`, `ViewSwitcher`, `Carousel` | | Scroll | `ScrollView`, `Scrollbar` | | Feedback | `ProgressBar`, `ProgressCircle`, `LevelBar`, `Spinner`, `Image`, `Tooltip`, `Notification`, `Toast`, `Banner`, `Alert`, `Badge`, `LoadMask`, `Backdrop`, `FocusRing` | diff --git a/splitter.go b/splitter.go new file mode 100644 index 0000000..0e75aee --- /dev/null +++ b/splitter.go @@ -0,0 +1,810 @@ +// Copyright (c) 2026 the wasmdesk/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "math" + "time" + + "github.com/go-widgets/mvvm" + "github.com/go-widgets/painter" +) + +// Splitter lays N panes side by side (or one above the other) with a draggable, +// focusable divider between each neighbouring pair, after the WAI-ARIA APG +// "window splitter" pattern (https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/). +// +// A pane is either FIXED (its Size, in logical pixels, is a reactive +// [mvvm.Observable] the divider writes) or FLEXIBLE (it shares what the fixed +// panes leave by its Weight, also an Observable): the Files column of an IDE is +// fixed, the editor and the preview beside it are flexible. Each pane has a Min +// and a Max; a Collapsible fixed pane drags down to nothing (Size 0) when the +// divider is pulled below half its Min, and comes back with a double-click, Enter +// or a drag out. A pane whose Visible Observable is false takes no room and loses +// its divider, so a View menu can switch panes off without the layout knowing. +// +// Moving a divider by d moves the pane before it by +d and the pane after it by +// -d, each within its bounds, the other panes keeping their size: the divider +// stays under the pointer until a bound stops it. Sizes live in the Observables, so +// a view-model can bind them, persist them and restore them; OnResized tells it +// what the PERSON changed (a drag, a key, a double-click), never a layout that +// followed a window resize. +// +// Keyboard (a divider holds the focus, a ring shows it; Tab moves between the +// dividers and the panes): Left/Right (Up/Down when Vertical) move it by Step +// logical pixels, four times that with Shift; Home and End take the pane before it +// to its smallest and largest; Enter collapses the fixed pane it controls and +// brings it back. The accessibility description of a divider is a WAI-ARIA +// "separator" with aria-valuemin/max/now (RoleSeparator). +type Splitter struct { + Base + // Vertical stacks the panes top to bottom (dividers are horizontal bars) + // instead of left to right. + Vertical bool + // Thickness is the divider's thickness and Step the keyboard step, in logical + // pixels (SplitterHandleW and SplitterStep when 0). + Thickness, Step int + // OnResized is called once for each gesture the person finished (a drag's + // release, a key press, a double-click) with the indexes of the panes whose + // size or weight it changed. + OnResized func(changed []int) + // Now is the clock the double-click detection reads (time.Now when nil). + Now func() time.Time + + panes []*SplitPane + handles []*dividerHandle + vis []int // the indexes of the panes that are on, in order + px []int // each pane's laid-out size along the axis, device pixels + + batch bool // sizes are being written: layout once, at the end + grab int // the divider being dragged, or -1 + grabAt int // the pointer's place along the axis when it was grabbed + grabBase []int + hover int // the divider under the pointer, or -1 + lastAt time.Time + lastDiv int + dragging *mvvm.Observable[bool] +} + +// SplitPane is one pane of a [Splitter]. +type SplitPane struct { + // Widget is what the pane shows. + Widget Widget + // Name is the pane's accessible name: its divider is "Resize ". + Name string + // Size, in logical pixels, makes the pane fixed; Weight makes it flexible. + // Exactly one of the two is set. + Size, Weight *mvvm.Observable[int] + // Min and Max bound the pane, in logical pixels (Max 0: no upper bound). + Min, Max int + // Collapsible lets a fixed pane go down to Size 0. + Collapsible bool + // Visible, when set, switches the pane (and a divider beside it) off. + Visible *mvvm.Observable[bool] + + restore int // the logical size before the pane was collapsed +} + +// Splitter metrics, in logical pixels. +const ( + SplitterHandleW = 10 + SplitterStep = 16 +) + +// splitterDoubleClick is how soon a second press on a divider counts as a +// double-click. +const splitterDoubleClick = 450 * time.Millisecond + +// RoleSeparator is the role of a divider the person can move: a focusable +// WAI-ARIA "separator" with a value. +const RoleSeparator Role = "separator" + +// NewSplitter builds a splitter over panes. Weights below 1 count as 1. +func NewSplitter(vertical bool, panes ...*SplitPane) *Splitter { + s := &Splitter{Vertical: vertical, panes: panes, grab: -1, hover: -1, lastDiv: -1, + dragging: mvvm.NewObservable(false), px: make([]int, len(panes))} + for j := 0; j+1 < len(panes); j++ { + s.handles = append(s.handles, ÷rHandle{s: s, j: j, a: -1, b: -1}) + } + relayout := func() { + if !s.batch { + s.layout() + } + } + for _, p := range panes { + if p.Size != nil { + p.Size.Subscribe(func(int) { relayout() }) + } + if p.Weight != nil { + p.Weight.Subscribe(func(int) { relayout() }) + } + if p.Visible != nil { + p.Visible.Subscribe(func(bool) { relayout() }) + } + } + return s +} + +// Dragging is true while a divider is held. +func (s *Splitter) Dragging() *mvvm.Observable[bool] { return s.dragging } + +func (s *Splitter) handleW() int { + if s.Thickness > 0 { + return scaled(s.Thickness) + } + return scaled(SplitterHandleW) +} + +func (s *Splitter) step() int { + if s.Step > 0 { + return scaled(s.Step) + } + return scaled(SplitterStep) +} + +func (s *Splitter) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +// SetBounds places the splitter and lays its panes out. +func (s *Splitter) SetBounds(r Rect) { + s.Base.SetBounds(r) + s.layout() +} + +// along is a rectangle's start and extent along the splitter's axis. +func (s *Splitter) along(r Rect) (pos, size int) { + if s.Vertical { + return r.Y, r.H + } + return r.X, r.W +} + +// rect builds a rectangle from a place and a size along the axis, the cross +// extent being the splitter's. +func (s *Splitter) rect(pos, size int) Rect { + r := s.Bounds() + if s.Vertical { + return Rect{X: r.X, Y: pos, W: r.W, H: size} + } + return Rect{X: pos, Y: r.Y, W: size, H: r.H} +} + +func (s *Splitter) on(i int) bool { + p := s.panes[i] + return p.Visible == nil || p.Visible.Get() +} + +const unbounded = 1 << 30 + +// bounds are a pane's lowest and highest size, device pixels. Both are kept +// around cur so that a pane squeezed out of its range by a small window is not +// pushed back into it by the first drag. +func (s *Splitter) bounds(i, cur int) (lo, hi int) { + p := s.panes[i] + lo, hi = scaled(p.Min), unbounded + if p.Max > 0 { + hi = scaled(p.Max) + } + return min(lo, cur), max(hi, cur) +} + +// collapsed reports whether pane i is a collapsible fixed pane at size 0. +func (s *Splitter) collapsed(i int) bool { + p := s.panes[i] + return p.Collapsible && p.Size != nil && p.Size.Get() <= 0 +} + +// Collapsed reports whether pane i has been pulled down to nothing. +func (s *Splitter) Collapsed(i int) bool { return s.collapsed(i) } + +// fixedPx is a fixed pane's size, device pixels, within its bounds. +func (s *Splitter) fixedPx(i int) int { + p := s.panes[i] + if s.collapsed(i) { + return 0 + } + lo, hi := scaled(p.Min), unbounded + if p.Max > 0 { + hi = scaled(p.Max) + } + return min(max(scaled(p.Size.Get()), lo), hi) +} + +// layout assigns the panes and the dividers their rectangles. +func (s *Splitter) layout() { + s.vis = s.vis[:0] + for i := range s.panes { + if s.on(i) { + s.vis = append(s.vis, i) + } else { + s.px[i] = 0 + s.panes[i].Widget.SetBounds(Rect{}) + } + } + for _, h := range s.handles { + h.SetBounds(Rect{}) + h.a, h.b = -1, -1 + } + r := s.Bounds() + if r.W <= 0 || r.H <= 0 || len(s.vis) == 0 { + for _, i := range s.vis { + s.px[i] = 0 + s.panes[i].Widget.SetBounds(Rect{}) + } + return + } + start, total := s.along(r) + hw := s.handleW() + avail := max(total-hw*(len(s.vis)-1), 0) + sizes := s.solve(avail) + pos := start + for j, i := range s.vis { + s.px[i] = sizes[j] + s.panes[i].Widget.SetBounds(s.rect(pos, sizes[j])) + pos += sizes[j] + if j+1 < len(s.vis) { + h := s.handles[j] + h.a, h.b = i, s.vis[j+1] + h.SetBounds(s.rect(pos, hw)) + pos += hw + } + } +} + +// solve shares avail pixels among the visible panes: the fixed ones as asked, +// squeezed from the last toward their Min when the flexible ones would not get +// theirs, then the flexible ones by weight, none under its Min. +func (s *Splitter) solve(avail int) []int { + n := len(s.vis) + sizes := make([]int, n) + var flex []int + fixedSum, flexMin, weights := 0, 0, 0 + for j, i := range s.vis { + p := s.panes[i] + if p.Size != nil { + sizes[j] = s.fixedPx(i) + fixedSum += sizes[j] + continue + } + flex = append(flex, j) + flexMin += scaled(p.Min) + weights += max(p.Weight.Get(), 1) + } + room := avail - fixedSum + if room < flexMin { // squeeze the fixed panes, the last first + need := flexMin - room + for j := n - 1; j >= 0 && need > 0; j-- { + i := s.vis[j] + if s.panes[i].Size == nil || sizes[j] == 0 { + continue + } + take := min(max(sizes[j]-scaled(s.panes[i].Min), 0), need) + sizes[j] -= take + need -= take + fixedSum -= take + } + room = avail - fixedSum + } + // a window too small even for the fixed panes at their Min: they give way + // further, the last first, rather than overrun it + for j := n - 1; j >= 0 && fixedSum > avail; j-- { + if s.panes[s.vis[j]].Size != nil { + take := min(sizes[j], fixedSum-avail) + sizes[j] -= take + fixedSum -= take + } + } + room = max(avail-fixedSum, 0) + if len(flex) == 0 { + return sizes + } + pinned := make([]bool, n) + if room >= flexMin { + for pass := 0; pass <= len(flex); pass++ { + changed := false + rest, w := room, weights + for _, j := range flex { + if pinned[j] { + rest -= sizes[j] + w -= max(s.panes[s.vis[j]].Weight.Get(), 1) + } + } + for _, j := range flex { + if pinned[j] { + continue + } + p := s.panes[s.vis[j]] + if rest*max(p.Weight.Get(), 1)/max(w, 1) < scaled(p.Min) { + pinned[j], sizes[j], changed = true, scaled(p.Min), true + } + } + if !changed { + break + } + } + } + rest, w := room, 0 + for _, j := range flex { + if pinned[j] { + rest -= sizes[j] + } else { + w += max(s.panes[s.vis[j]].Weight.Get(), 1) + } + } + rest = max(rest, 0) + given, acc := 0, 0 + last := -1 + for _, j := range flex { + if !pinned[j] { + last = j + } + } + for _, j := range flex { + if pinned[j] { + continue + } + acc += max(s.panes[s.vis[j]].Weight.Get(), 1) + share := (rest*acc*2+w)/(2*max(w, 1)) - given + if j == last { + share = rest - given + } + sizes[j] = share + given += share + } + return sizes +} + +// clamp is the size pane i takes when asked for want pixels: within its +// bounds, or nothing when it is collapsible and asked for under half its Min. +func (s *Splitter) clamp(i, want, cur int) int { + lo, hi := s.bounds(i, cur) + if s.panes[i].Collapsible && want < lo/2 { + return 0 + } + return min(max(want, lo), hi) +} + +// move shifts the divider after visible pane a (before pane b) by d device +// pixels from the sizes in base, and writes the result into the Observables. +func (s *Splitter) move(a, b int, base []int, d int) { + na := s.clamp(a, base[a]+d, base[a]) + d = na - base[a] + nb := s.clamp(b, base[b]-d, base[b]) + d = base[b] - nb + na = s.clamp(a, base[a]+d, base[a]) + nb = base[b] - (na - base[a]) + px := append([]int(nil), base...) + px[a], px[b] = na, nb + s.write(px, base) +} + +// write sets the Observables so that the panes come out at px (device pixels, +// one per pane); the fixed ones get their size, the flexible ones the weights +// that give them their share of the room the fixed ones leave. base is what the +// gesture started from: a pane that goes down to nothing remembers that size. +func (s *Splitter) write(px, base []int) { + s.batch = true + defer func() { + s.batch = false + s.layout() + }() + scale := MetricScale() + flexSum, weights := 0, 0 + for _, i := range s.vis { + p := s.panes[i] + if p.Size != nil { + if px[i] == s.px[i] { + continue + } + if px[i] == 0 && base[i] > 0 { + p.restore = int(float64(base[i])/scale + 0.5) + } + if px[i] == 0 { + p.Size.Set(0) + } else { + p.Size.Set(int(float64(px[i])/scale + 0.5)) + } + continue + } + flexSum += px[i] + weights += max(p.Weight.Get(), 1) + } + if flexSum <= 0 { + return + } + // weights fine enough to hold a hundredth of a pixel; every flexible pane + // takes its share of the new total, those that did not move included + k := max(weights, 100000) + for _, i := range s.vis { + if p := s.panes[i]; p.Weight != nil { + p.Weight.Set(max((px[i]*k+flexSum/2)/flexSum, 1)) + } + } +} + +// changedSince lists the panes whose laid-out size is not what it was in before. +func (s *Splitter) changedSince(before []int) []int { + var out []int + for i, v := range s.px { + if v != before[i] { + out = append(out, i) + } + } + return out +} + +func (s *Splitter) fire(before []int) { + if ch := s.changedSince(before); len(ch) > 0 && s.OnResized != nil { + s.OnResized(ch) + } +} + +// --- the dividers --------------------------------------------------------- + +// dividerHandle is a divider: a focusable widget the splitter places between two +// panes. It is not a child of the splitter (a drag that starts on it belongs to the +// splitter, which keeps its place), but the focus system reaches it. +type dividerHandle struct { + Base + focusState + s *Splitter + j int + a, b int // the panes before and after it; -1 while it is off +} + +// OnEvent takes the keys of a focused divider. +func (h *dividerHandle) OnEvent(ev Event) { + if ev.Kind == EventKeyDown && h.a >= 0 { + h.s.key(h, ev) + } +} + +// Draw paints the divider: a line at 3:1 against the ground, a grip, and a ring +// when it holds the focus; the accent while it is pointed at or held. +func (h *dividerHandle) Draw(p painter.Painter, theme *Theme) { + r := h.Bounds() + if h.a < 0 || r.W <= 0 || r.H <= 0 { + return + } + ink, active := SplitterColors(theme) + held := h.s.grab == h.j + hot := held || h.s.hover == h.j + vertical := !h.s.Vertical // a bar standing between panes side by side + col, width := ink, scaled(1) + if hot { + col, width = active, scaled(3) + fillRect(p, r.X, r.Y, r.W, r.H, blendRGBA(theme.Background, active, 0.14)) + } + cx, cy := r.X+r.W/2, r.Y+r.H/2 + long, short := min(scaled(32), max(r.H, r.W)/2), scaled(4) + if vertical { + fillRect(p, cx-width/2, r.Y, width, r.H, col) + fillRect(p, cx-short/2, cy-long/2, short, long, col) + } else { + fillRect(p, r.X, cy-width/2, r.W, width, col) + fillRect(p, cx-long/2, cy-short/2, long, short, col) + } + if h.focused { + strokeRect(p, r.X, r.Y, r.W, r.H, active) + strokeRect(p, r.X+1, r.Y+1, r.W-2, r.H-2, active) + } +} + +// A11y describes the divider as a WAI-ARIA separator: aria-valuenow is the size +// of the pane before it, aria-valuemin and aria-valuemax the bounds of that +// pane, all in logical pixels. +func (h *dividerHandle) A11y() A11yInfo { + if h.a < 0 { + return A11yInfo{Role: RoleSeparator} + } + p := h.s.panes[h.a] + lo, hi := p.Min, p.Max + if p.Collapsible { + lo = 0 + } + if hi <= 0 { + _, total := h.s.along(h.s.Bounds()) + hi = int(float64(total) / MetricScale()) + } + return A11yInfo{Role: RoleSeparator, Name: "Resize " + p.Name, HasRange: true, + Min: float64(lo), Max: float64(hi), Now: float64(h.s.px[h.a]) / MetricScale()} +} + +// handleAt is the divider at surface point (x, y), or -1. +func (s *Splitter) handleAt(x, y int) int { + for j, h := range s.handles { + if h.a >= 0 && h.HitTest(x, y) { + return j + } + } + return -1 +} + +// HandleRect is divider j's rectangle (surface coordinates); ok is false while +// it is off (fewer than j+2 panes are on). +func (s *Splitter) HandleRect(j int) (Rect, bool) { + if j < 0 || j >= len(s.handles) || s.handles[j].a < 0 { + return Rect{}, false + } + return s.handles[j].Bounds(), true +} + +// Handles is the number of dividers there are while every pane is on. +func (s *Splitter) Handles() int { return len(s.handles) } + +// Handle returns divider j as a widget (focusable, with an accessibility +// description), for a host that tracks the focus itself. +func (s *Splitter) Handle(j int) Widget { return s.handles[j] } + +// PaneSize is pane i's laid-out size along the axis, device pixels. +func (s *Splitter) PaneSize(i int) int { return s.px[i] } + +// Cursor is the CSS cursor the pointer at (x, y), local to the splitter, should +// show: "col-resize" or "row-resize" over a divider or while one is held, else +// "" (the host's default). +func (s *Splitter) Cursor(x, y int) string { + b := s.Bounds() + if s.grab < 0 && s.handleAt(x+b.X, y+b.Y) < 0 { + return "" + } + if s.Vertical { + return "row-resize" + } + return "col-resize" +} + +// FocusHandle gives divider j the keyboard (false while it is off). +func (s *Splitter) FocusHandle(j int) bool { + if j < 0 || j >= len(s.handles) || s.handles[j].a < 0 { + return false + } + for k, h := range s.handles { + h.focused = k == j + } + return true +} + +// FocusedHandle is the divider that holds the keyboard, or -1. +func (s *Splitter) FocusedHandle() int { + for j, h := range s.handles { + if h.focused && h.a >= 0 { + return j + } + } + return -1 +} + +// Blur takes the keyboard off every divider. +func (s *Splitter) Blur() { + for _, h := range s.handles { + h.focused = false + } +} + +// --- events ---------------------------------------------------------------- + +func (s *Splitter) focusableChildren() []Widget { + var out []Widget + for j, i := range s.vis { + out = append(out, s.panes[i].Widget) + if j+1 < len(s.vis) { + out = append(out, s.handles[j]) + } + } + return out +} + +// Children are the panes that are on, for the walks over the tree (drag +// capture finds the splitter itself on a divider). +func (s *Splitter) Children() []Widget { + var out []Widget + for _, i := range s.vis { + out = append(out, s.panes[i].Widget) + } + return out +} + +// Draw paints the panes that are on, then the dividers. +func (s *Splitter) Draw(p painter.Painter, theme *Theme) { + for _, i := range s.vis { + if b := s.panes[i].Widget.Bounds(); b.W > 0 && b.H > 0 { + s.panes[i].Widget.Draw(p, theme) + } + } + for _, h := range s.handles { + h.Draw(p, theme) + } +} + +// OnEvent routes a press on a divider to the splitter's own drag, a key to the +// divider that holds the focus, and the rest to the pane under the pointer. +func (s *Splitter) OnEvent(ev Event) { + if routeFocusKey(s, ev) { + return + } + pr := s.Bounds() + sx, sy := ev.X+pr.X, ev.Y+pr.Y + main := ev.X + if s.Vertical { + main = ev.Y + } + switch ev.Kind { + case EventMouseMove: + s.hover = s.handleAt(sx, sy) + for _, i := range s.vis { + w := s.panes[i].Widget + w.OnEvent(translateEvent(ev, pr, w.Bounds())) + } + return + case EventMouseDrag: + if s.grab >= 0 { + h := s.handles[s.grab] + if h.a >= 0 { + s.move(h.a, h.b, s.grabBase, main-s.grabAt) + } + return + } + case EventMouseUp: + if s.grab >= 0 { + before := s.grabBase + s.grab = -1 + s.dragging.Set(false) + s.fire(before) + return + } + case EventClick: + focusClick(s, sx, sy) + if j := s.handleAt(sx, sy); j >= 0 { + s.press(j, main, ev.Code == "double") + return + } + } + for _, i := range s.vis { + w := s.panes[i].Widget + if w.HitTest(sx, sy) { + w.OnEvent(translateEvent(ev, pr, w.Bounds())) + return + } + } +} + +// press is a press on divider j at place main along the axis: a second one +// soon after toggles the collapse of the pane it controls, else a drag begins. +func (s *Splitter) press(j, main int, double bool) { + now := s.now() + if double || (s.lastDiv == j && now.Sub(s.lastAt) < splitterDoubleClick) { + s.lastDiv = -1 + before := append([]int(nil), s.px...) + s.toggle(j) + s.fire(before) + return + } + s.lastDiv, s.lastAt = j, now + s.grab, s.grabAt = j, main + s.grabBase = append([]int(nil), s.px...) + s.dragging.Set(true) +} + +// control is the pane divider j's collapse acts on: the fixed collapsible one +// before it, else after it; -1 if neither is. +func (s *Splitter) control(h *dividerHandle) int { + for _, i := range []int{h.a, h.b} { + if p := s.panes[i]; p.Collapsible && p.Size != nil { + return i + } + } + return -1 +} + +// toggle collapses the pane divider j controls, or brings it back to the size +// it had (its Min if it never had one). +func (s *Splitter) toggle(j int) { + h := s.handles[j] + c := s.control(h) + if c < 0 { + return + } + p := s.panes[c] + target := 0 + if s.collapsed(c) { + back := p.restore + if back <= 0 { + back = max(p.Min, 1) + } + hi := unbounded + if p.Max > 0 { + hi = scaled(p.Max) + } + target = min(max(scaled(back), scaled(p.Min)), hi) + } + d := target - s.px[c] + if c == h.b { + d = -d + } + s.move(h.a, h.b, append([]int(nil), s.px...), d) +} + +// key moves the divider that holds the focus. +func (s *Splitter) key(h *dividerHandle, ev Event) { + before := append([]int(nil), s.px...) + step := s.step() + if ev.Shift { + step *= 4 + } + less, more := "ArrowLeft", "ArrowRight" + if s.Vertical { + less, more = "ArrowUp", "ArrowDown" + } + switch ev.Code { + case less: + s.move(h.a, h.b, before, -step) + case more: + s.move(h.a, h.b, before, step) + case "Home": + s.move(h.a, h.b, before, -unbounded) + case "End": + s.move(h.a, h.b, before, unbounded) + case "Enter": + s.toggle(h.j) + default: + return + } + s.fire(before) +} + +// --- colours --------------------------------------------------------------- + +func linear(v uint8) float64 { + f := float64(v) / 255 + if f <= 0.03928 { + return f / 12.92 + } + return math.Pow((f+0.055)/1.055, 2.4) +} + +func luminance(c RGBA) float64 { + return 0.2126*linear(c.R) + 0.7152*linear(c.G) + 0.0722*linear(c.B) +} + +// ContrastRatio is the WCAG 2 contrast ratio of two colours, 1 to 21. +func ContrastRatio(a, b RGBA) float64 { + la, lb := luminance(a), luminance(b) + if la < lb { + la, lb = lb, la + } + return (la + 0.05) / (lb + 0.05) +} + +// legible moves c toward the ink until it has at least 3:1 (the WCAG 2.1 +// non-text minimum) against every ground. +func legible(c, ink RGBA, grounds ...RGBA) RGBA { + for t := 0.0; t <= 1.0; t += 0.05 { + x := blendRGBA(c, ink, t) + ok := true + for _, g := range grounds { + ok = ok && ContrastRatio(x, g) >= 3 + } + if ok { + return x + } + } + return ink +} + +// SplitterColors are the colours a divider paints with in a theme: its line at +// rest, and the accent for the pointer, the drag and the focus ring, each at +// least 3:1 against the theme's Background and Surface. +func SplitterColors(theme *Theme) (ink, active RGBA) { + return legible(theme.Border, theme.OnBackground, theme.Background, theme.Surface), + legible(theme.Accent, theme.OnBackground, theme.Background, theme.Surface) +} + +// A11y describes the splitter itself as a plain container; its dividers are +// reached through Handle. +func (s *Splitter) A11y() A11yInfo { return A11yInfo{Role: RolePresentation} } diff --git a/splitter_more_test.go b/splitter_more_test.go new file mode 100644 index 0000000..8465f83 --- /dev/null +++ b/splitter_more_test.go @@ -0,0 +1,260 @@ +// Copyright (c) 2026 the wasmdesk/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "testing" + + "github.com/go-widgets/mvvm" + "github.com/go-widgets/painter" +) + +// recorder is a pane that keeps the events it was given. +type recorder struct { + Base + got []Event +} + +func (r *recorder) OnEvent(ev Event) { r.got = append(r.got, ev) } + +func TestSplitterForwardsEventsToThePaneUnderThePointer(t *testing.T) { + a, b := &recorder{}, &recorder{} + s := NewSplitter(false, + &SplitPane{Widget: a, Weight: mvvm.NewObservable(1), Min: 10}, + &SplitPane{Widget: b, Weight: mvvm.NewObservable(1), Min: 10}) + s.SetBounds(Rect{X: 20, Y: 30, W: 210, H: 100}) + // a press in the second pane arrives in the pane's own coordinates + bx := b.Bounds().X + s.OnEvent(Event{Kind: EventClick, X: bx - 20 + 15, Y: 40}) + if len(b.got) != 1 || b.got[0].X != 15 || b.got[0].Y != 40 || len(a.got) != 0 { + t.Fatalf("pane events: a=%v b=%v", a.got, b.got) + } + // a move goes to every pane, and hovers the divider it is over + hx, _ := s.HandleRect(0) + s.OnEvent(Event{Kind: EventMouseMove, X: hx.X - 20 + 2, Y: 5}) + if len(a.got) != 1 || len(b.got) != 2 { + t.Fatalf("a move reaches every pane: a=%d b=%d", len(a.got), len(b.got)) + } + if s.hover != 0 { + t.Fatalf("the divider under the pointer is hovered: %d", s.hover) + } + s.OnEvent(Event{Kind: EventMouseMove, X: 3, Y: 5}) + if s.hover != -1 { + t.Fatal("moving off the divider clears the hover") + } + // a release nobody grabbed, and a drag with no divider held, go to the pane + s.OnEvent(Event{Kind: EventMouseUp, X: 3, Y: 5}) + s.OnEvent(Event{Kind: EventMouseDrag, X: 3, Y: 5}) + if len(a.got) != 4 { // two moves, a release and a drag + t.Fatalf("a release and a drag outside a drag are the pane's: %d", len(a.got)) + } + // the press that missed every pane and divider is dropped + s.OnEvent(Event{Kind: EventClick, X: 5000, Y: 5}) +} + +func TestSplitterOnlyFixedPanesAndAFlexiblePaneAtZero(t *testing.T) { + a, b := NewLabel("a"), NewLabel("b") + sa, sb := mvvm.NewObservable(100), mvvm.NewObservable(100) + s := NewSplitter(false, + &SplitPane{Widget: a, Size: sa, Min: 20}, + &SplitPane{Widget: b, Size: sb, Min: 20}) + s.SetBounds(Rect{W: 400, H: 50}) + if s.PaneSize(0) != 100 || s.PaneSize(1) != 100 { + t.Fatalf("fixed panes keep their size: %d %d", s.PaneSize(0), s.PaneSize(1)) + } + // two fixed panes: the divider moves one and takes from the other + r, _ := s.HandleRect(0) + s.OnEvent(Event{Kind: EventClick, X: r.X + 2, Y: 5}) + s.OnEvent(Event{Kind: EventMouseDrag, X: r.X + 2 + 30, Y: 5}) + s.OnEvent(Event{Kind: EventMouseUp, X: r.X + 2 + 30, Y: 5}) + if sa.Get() != 130 || sb.Get() != 70 { + t.Fatalf("a divider between two fixed panes: %d and %d", sa.Get(), sb.Get()) + } + + // a flexible pane with no Min can be dragged to nothing + left, right := NewLabel("l"), NewLabel("r") + size := mvvm.NewObservable(100) + s2 := NewSplitter(false, + &SplitPane{Widget: left, Size: size, Min: 20}, + &SplitPane{Widget: right, Weight: mvvm.NewObservable(1)}) + s2.SetBounds(Rect{W: 300, H: 50}) + s2.FocusHandle(0) + s2.OnEvent(Event{Kind: EventKeyDown, Code: "End"}) + if s2.PaneSize(1) != 0 || size.Get() != 290 { + t.Fatalf("End leaves nothing for a pane without a Min: %d, %d", s2.PaneSize(1), size.Get()) + } +} + +func TestSplitterWithoutCollapsiblePaneDoubleClickIsInert(t *testing.T) { + a, b := NewLabel("a"), NewLabel("b") + s := NewSplitter(false, + &SplitPane{Widget: a, Weight: mvvm.NewObservable(1), Min: 10}, + &SplitPane{Widget: b, Weight: mvvm.NewObservable(1), Min: 10}) + s.SetBounds(Rect{W: 200, H: 50}) + before := s.PaneSize(0) + s.FocusHandle(0) + s.OnEvent(Event{Kind: EventKeyDown, Code: "Enter"}) // nothing to collapse + if s.PaneSize(0) != before { + t.Fatal("Enter on a divider between flexible panes does nothing") + } +} + +func TestSplitterBackFromZeroUsesTheMin(t *testing.T) { + d := newIDE(t, 1200) + d.files.Set(0) // stored collapsed: no size to come back to + if !d.s.Collapsed(0) { + t.Fatal("Size 0 on a collapsible pane is collapsed") + } + d.s.FocusHandle(0) + d.s.OnEvent(Event{Kind: EventKeyDown, Code: "Enter"}) + if d.files.Get() != 120 { + t.Fatalf("with nothing to restore, Enter gives the pane its Min: %d", d.files.Get()) + } +} + +func TestSplitterTheDividerHeldWhileItsPaneGoesAway(t *testing.T) { + d := newIDE(t, 1200) + r, _ := d.s.HandleRect(0) + d.s.OnEvent(Event{Kind: EventClick, X: r.X + 2, Y: 5}) + d.filesOn.Set(false) + d.s.OnEvent(Event{Kind: EventMouseDrag, X: r.X + 40, Y: 5}) // nothing to move + if d.files.Get() != 190 { + t.Fatalf("a drag of a divider that went away moves nothing: %d", d.files.Get()) + } + d.s.OnEvent(Event{Kind: EventMouseUp, X: r.X + 40, Y: 5}) + if d.s.Dragging().Get() { + t.Fatal("the release ends the drag") + } +} + +func TestSplitterAccessibilityOfAnUnusedDividerAndOfTheSplitter(t *testing.T) { + d := newIDE(t, 1200) + d.filesOn.Set(false) + off := d.s.Handle(2).(Accessible).A11y() + if off.Role != RoleSeparator || off.HasRange { + t.Fatalf("an unused divider has a role and no value: %+v", off) + } + d.s.Handle(2).OnEvent(Event{Kind: EventKeyDown, Code: "ArrowLeft"}) // ignored + flex := d.s.Handle(0).(Accessible).A11y() + if flex.Name != "Resize Editor" || flex.Min != 200 || flex.Max < 900 { + t.Fatalf("a pane with no Max reports the whole length: %+v", flex) + } + if d.s.A11y().Role != RolePresentation || d.s.Handles() != 3 { + t.Fatal("the splitter is a plain container with three dividers") + } +} + +func TestSplitterFocusHandleRefusesWhatIsNotThere(t *testing.T) { + d := newIDE(t, 1200) + d.filesOn.Set(false) + if d.s.FocusHandle(-1) || d.s.FocusHandle(2) || d.s.FocusHandle(9) { + t.Fatal("no divider there to focus") + } +} + +func TestSplitterThicknessAndStepAreSettable(t *testing.T) { + d := newIDE(t, 1200) + d.s.Thickness, d.s.Step = 14, 8 + d.s.SetBounds(d.s.Bounds()) + if r, _ := d.s.HandleRect(0); r.W != 14 { + t.Fatalf("Thickness: %d", r.W) + } + d.s.FocusHandle(0) + d.s.OnEvent(Event{Kind: EventKeyDown, Code: "ArrowRight"}) + if d.files.Get() != 198 { + t.Fatalf("Step: %d", d.files.Get()) + } +} + +func TestSplitterEmptyBoundsLeaveNothing(t *testing.T) { + d := newIDE(t, 1200) + d.s.SetBounds(Rect{}) + if d.fw.Bounds() != (Rect{}) || d.ew.Bounds() != (Rect{}) || d.s.PaneSize(1) != 0 { + t.Fatal("an empty splitter empties its panes") + } + if _, ok := d.s.HandleRect(0); ok { + t.Fatal("and has no divider") + } +} + +func TestSplitterDrawsHotAndVerticalDividers(t *testing.T) { + th := DefaultDark() + d := newIDE(t, 1200) + paint := func(s *Splitter, w, h int) []byte { + buf := make([]byte, w*h*4) + s.Draw(painter.NewPixelPainter(buf, w, h), th) + return buf + } + rest := paint(d.s, 1200, 400) + r, _ := d.s.HandleRect(0) + d.s.OnEvent(Event{Kind: EventMouseMove, X: r.X + 2, Y: 30}) + hot := paint(d.s, 1200, 400) + _, active := SplitterColors(th) + px := (30*1200 + r.X + r.W/2) * 4 + if hot[px] != active.R || hot[px+1] != active.G || hot[px+2] != active.B { + t.Fatalf("a hovered divider paints in the accent: %v want %v", hot[px:px+3], active) + } + if string(rest) == string(hot) { + t.Fatal("hover changes the picture") + } + + top, bottom := NewLabel("t"), NewLabel("b") + v := NewSplitter(true, + &SplitPane{Widget: top, Weight: mvvm.NewObservable(1), Min: 10}, + &SplitPane{Widget: bottom, Weight: mvvm.NewObservable(1), Min: 10}) + v.SetBounds(Rect{W: 100, H: 100}) + v.FocusHandle(0) + vr, _ := v.HandleRect(0) + img := paint(v, 100, 100) + ink, _ := SplitterColors(th) + vpx := ((vr.Y+vr.H/2)*100 + 10) * 4 + if img[vpx] != ink.R || img[vpx+1] != ink.G || img[vpx+2] != ink.B { + t.Fatalf("a horizontal bar paints its line across: %v want %v", img[vpx:vpx+3], ink) + } +} + +func TestSplitterColorsFallBackToTheInkWhenNothingReachesThreeToOne(t *testing.T) { + grey := RGB(0x80, 0x80, 0x80) + th := &Theme{Background: grey, Surface: grey, OnBackground: grey, Border: grey, Accent: grey} + ink, active := SplitterColors(th) + if ink != grey || active != grey { + t.Fatalf("with no contrast to be had the ink is returned: %v %v", ink, active) + } +} + +func TestSplitterWeightsWithNoFlexiblePaneLeftToWrite(t *testing.T) { + left, right := NewLabel("l"), NewLabel("r") + size := mvvm.NewObservable(100) + w := mvvm.NewObservable(1) + s := NewSplitter(false, + &SplitPane{Widget: left, Size: size, Min: 20}, + &SplitPane{Widget: right, Weight: w, Min: 0}) + s.SetBounds(Rect{W: 300, H: 50}) + // both flexible weights are written together, so a pane at zero is allowed + s.FocusHandle(0) + s.OnEvent(Event{Kind: EventKeyDown, Code: "End"}) + s.OnEvent(Event{Kind: EventKeyDown, Code: "Home"}) + if s.PaneSize(0) != 20 || w.Get() < 1 { + t.Fatalf("Home gives the fixed pane its Min: %d, weight %d", s.PaneSize(0), w.Get()) + } +} + +func TestSplitterCollapsesAPaneAfterItsDivider(t *testing.T) { + d := newIDE(t, 1200) + d.helpPane.Collapsible = true + d.s.FocusHandle(2) + d.s.OnEvent(Event{Kind: EventKeyDown, Code: "Enter"}) + if !d.s.Collapsed(3) || d.help.Get() != 0 || d.s.PaneSize(3) != 0 { + t.Fatalf("Enter collapses the pane after the divider: %d", d.help.Get()) + } + d.s.OnEvent(Event{Kind: EventKeyDown, Code: "Enter"}) + if d.s.Collapsed(3) || d.help.Get() != 280 { + t.Fatalf("and brings it back: %d", d.help.Get()) + } + // a divider that is off draws nothing + d.filesOn.Set(false) + buf := make([]byte, 1200*400*4) + d.s.Draw(painter.NewPixelPainter(buf, 1200, 400), DefaultLight()) +} diff --git a/splitter_test.go b/splitter_test.go new file mode 100644 index 0000000..5803417 --- /dev/null +++ b/splitter_test.go @@ -0,0 +1,488 @@ +// Copyright (c) 2026 the wasmdesk/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "testing" + "time" + + "github.com/go-widgets/mvvm" + "github.com/go-widgets/painter" +) + +// ide builds the shape the playground uses: Files (fixed, collapsible), an +// editor and a preview (flexible, 6:5), Help (fixed), in a 1200x400 splitter. +type ide struct { + s *Splitter + files, help *mvvm.Observable[int] + editor, preview *mvvm.Observable[int] + filesOn, helpOn *mvvm.Observable[bool] + resized [][]int + fw, ew, pw, hw Widget + now time.Time + filesPane, helpPane *SplitPane + editorPane, prevPane *SplitPane +} + +func newIDE(t *testing.T, w int) *ide { + t.Helper() + d := &ide{ + files: mvvm.NewObservable(190), help: mvvm.NewObservable(280), + editor: mvvm.NewObservable(600), preview: mvvm.NewObservable(500), + filesOn: mvvm.NewObservable(true), helpOn: mvvm.NewObservable(true), + fw: NewLabel("files"), ew: NewLabel("editor"), pw: NewLabel("preview"), hw: NewLabel("help"), + now: time.Unix(1000, 0), + } + d.filesPane = &SplitPane{Widget: d.fw, Name: "Files", Size: d.files, Min: 120, Max: 480, Collapsible: true, Visible: d.filesOn} + d.editorPane = &SplitPane{Widget: d.ew, Name: "Editor", Weight: d.editor, Min: 200} + d.prevPane = &SplitPane{Widget: d.pw, Name: "Preview", Weight: d.preview, Min: 200} + d.helpPane = &SplitPane{Widget: d.hw, Name: "Help", Size: d.help, Min: 180, Max: 560, Visible: d.helpOn} + d.s = NewSplitter(false, d.filesPane, d.editorPane, d.prevPane, d.helpPane) + d.s.Now = func() time.Time { return d.now } + d.s.OnResized = func(ch []int) { d.resized = append(d.resized, ch) } + d.s.SetBounds(Rect{W: w, H: 400}) + return d +} + +func (d *ide) sizes() [4]int { + return [4]int{d.s.PaneSize(0), d.s.PaneSize(1), d.s.PaneSize(2), d.s.PaneSize(3)} +} + +// drag presses divider j, moves by dx and releases. +func (d *ide) drag(t *testing.T, j, dx int) { + t.Helper() + r, ok := d.s.HandleRect(j) + if !ok { + t.Fatalf("no divider %d", j) + } + x := r.X + r.W/2 + d.s.OnEvent(Event{Kind: EventClick, X: x, Y: 50}) + d.s.OnEvent(Event{Kind: EventMouseDrag, X: x + dx/2, Y: 50}) + d.s.OnEvent(Event{Kind: EventMouseDrag, X: x + dx, Y: 50}) + d.s.OnEvent(Event{Kind: EventMouseUp, X: x + dx, Y: 50}) +} + +func TestSplitterDefaultLayout(t *testing.T) { + d := newIDE(t, 1200) + got := d.sizes() + if got[0] != 190 || got[3] != 280 { + t.Fatalf("fixed panes %v, want 190 and 280", got) + } + if sum := got[0] + got[1] + got[2] + got[3] + 3*SplitterHandleW; sum != 1200 { + t.Fatalf("panes and dividers fill %d of 1200", sum) + } + if r := float64(got[1]) / float64(got[2]); r < 1.19 || r > 1.21 { + t.Fatalf("editor:preview is %v, want 6:5", r) + } + // the panes sit left to right with a divider between + x := 0 + for i, w := range []Widget{d.fw, d.ew, d.pw, d.hw} { + if b := w.Bounds(); b.X != x || b.W != got[i] || b.H != 400 { + t.Fatalf("pane %d at %+v, want x=%d w=%d", i, b, x, got[i]) + } + x += got[i] + SplitterHandleW + } + if len(d.resized) != 0 { + t.Fatalf("a layout is not a gesture: OnResized got %v", d.resized) + } +} + +func TestSplitterDragMovesTheFixedPaneAndTheNeighbourAbsorbs(t *testing.T) { + d := newIDE(t, 1200) + before := d.sizes() + d.drag(t, 0, 50) + got := d.sizes() + if got[0] != before[0]+50 || got[1] != before[1]-50 || got[2] != before[2] || got[3] != before[3] { + t.Fatalf("after dragging the Files divider by 50: %v, was %v", got, before) + } + if d.files.Get() != 240 { + t.Fatalf("Files size observable is %d, want 240", d.files.Get()) + } + if len(d.resized) != 1 || len(d.resized[0]) != 2 || d.resized[0][0] != 0 || d.resized[0][1] != 1 { + t.Fatalf("OnResized should report panes 0 and 1 once, got %v", d.resized) + } + // the divider after the preview moves Help, taking from the preview + d.drag(t, 2, -40) + got2 := d.sizes() + if got2[3] != got[3]+40 || got2[2] != got[2]-40 || got2[1] != got[1] { + t.Fatalf("after dragging the Help divider by -40: %v, was %v", got2, got) + } + if d.help.Get() != 320 { + t.Fatalf("Help size observable is %d, want 320", d.help.Get()) + } +} + +func TestSplitterFlexiblePairChangesTheRatioNotTheTotal(t *testing.T) { + d := newIDE(t, 1200) + before := d.sizes() + d.drag(t, 1, -100) + got := d.sizes() + if got[1] != before[1]-100 || got[2] != before[2]+100 { + t.Fatalf("editor/preview %v then %v after -100", before, got) + } + if got[0] != before[0] || got[3] != before[3] { + t.Fatalf("a flexible pair must leave the fixed panes alone: %v then %v", before, got) + } + // the ratio, not the pixels, is kept when the window grows + d.s.SetBounds(Rect{W: 1500, H: 400}) + g2 := d.sizes() + r1 := float64(got[1]) / float64(got[2]) + r2 := float64(g2[1]) / float64(g2[2]) + if r2 < r1-0.02 || r2 > r1+0.02 { + t.Fatalf("the editor:preview ratio %v became %v when the window grew", r1, r2) + } + if g2[0] != 190 || g2[3] != 280 { + t.Fatalf("fixed panes changed with the window: %v", g2) + } + if len(d.resized) != 1 { + t.Fatalf("a window resize is not a gesture; OnResized called %d times", len(d.resized)) + } +} + +func TestSplitterBoundsStopTheDivider(t *testing.T) { + d := newIDE(t, 1200) + d.drag(t, 0, 2000) + // the editor next to it gives way down to its Min 200 and no further: the + // divider stops there, under Files' Max + if got := d.sizes(); got[1] != 200 || got[0] != 190+382-200 || got[2] != 318 { + t.Fatalf("Files must stop where the editor reaches its Min: %v", got) + } + d = newIDE(t, 2000) + d.drag(t, 0, 2000) + if got := d.sizes(); got[0] != 480 { + t.Fatalf("Files must stop at its Max 480 when there is room: %v", got) + } + d = newIDE(t, 1200) + d.drag(t, 2, 2000) // Help shrinks to its Min + if got := d.sizes(); got[3] != 180 { + t.Fatalf("Help must stop at its Min 180: %v", got) + } + d = newIDE(t, 1200) + d.drag(t, 1, -2000) // the editor is a flexible pane with a Min + if got := d.sizes(); got[1] != 200 { + t.Fatalf("the editor must stop at its Min 200: %v", got) + } +} + +func TestSplitterCollapseByDragAndByDoubleClick(t *testing.T) { + d := newIDE(t, 1200) + before := d.sizes() + d.drag(t, 0, -150) // below half the Min: collapses + if !d.s.Collapsed(0) || d.files.Get() != 0 || d.s.PaneSize(0) != 0 { + t.Fatalf("Files should be collapsed: size %d, observable %d", d.s.PaneSize(0), d.files.Get()) + } + if b := d.fw.Bounds(); b.W != 0 { + t.Fatalf("a collapsed pane has no room: %+v", b) + } + if got := d.sizes(); got[1] != before[1]+before[0] { + t.Fatalf("the editor should take the Files room: %v, was %v", got, before) + } + if _, ok := d.s.HandleRect(0); !ok { + t.Fatal("the divider of a collapsed pane stays, to bring it back") + } + // a double-click on it brings the pane back to what it was + d.now = d.now.Add(time.Second) + r, _ := d.s.HandleRect(0) + x := r.X + r.W/2 + d.s.OnEvent(Event{Kind: EventClick, X: x, Y: 10}) + d.s.OnEvent(Event{Kind: EventMouseUp, X: x, Y: 10}) + d.now = d.now.Add(100 * time.Millisecond) + d.s.OnEvent(Event{Kind: EventClick, X: x, Y: 10}) + d.s.OnEvent(Event{Kind: EventMouseUp, X: x, Y: 10}) + if d.s.Collapsed(0) || d.files.Get() != 190 { + t.Fatalf("a double-click should restore Files to 190: collapsed=%v size=%d", d.s.Collapsed(0), d.files.Get()) + } + // and a double-click again collapses it + r, _ = d.s.HandleRect(0) + d.s.OnEvent(Event{Kind: EventClick, X: r.X + 1, Y: 10, Code: "double"}) + if !d.s.Collapsed(0) { + t.Fatal("an event tagged double should collapse Files") + } + // dragging out of the collapsed state restores a size within the bounds + d.drag(t, 0, 160) + if d.s.Collapsed(0) || d.files.Get() < 120 { + t.Fatalf("dragging a collapsed pane out gives it at least its Min: %d", d.files.Get()) + } +} + +func TestSplitterNonCollapsiblePaneStopsAtItsMin(t *testing.T) { + d := newIDE(t, 1200) + d.drag(t, 2, 1000) + if d.s.Collapsed(3) || d.help.Get() != 180 { + t.Fatalf("Help is not collapsible: %d", d.help.Get()) + } + r, _ := d.s.HandleRect(2) + d.s.OnEvent(Event{Kind: EventClick, X: r.X + 1, Y: 10, Code: "double"}) + if d.s.Collapsed(3) || d.help.Get() != 180 { + t.Fatal("a double-click does nothing to a pane that cannot collapse") + } +} + +func TestSplitterKeyboard(t *testing.T) { + d := newIDE(t, 1200) + if !d.s.FocusHandle(0) || d.s.FocusedHandle() != 0 { + t.Fatal("divider 0 should take the focus") + } + key := func(code string, shift bool) { + d.s.OnEvent(Event{Kind: EventKeyDown, Code: code, Shift: shift}) + } + key("ArrowRight", false) + if d.files.Get() != 190+SplitterStep { + t.Fatalf("ArrowRight: Files is %d, want %d", d.files.Get(), 190+SplitterStep) + } + key("ArrowLeft", false) + key("ArrowLeft", true) + if d.files.Get() != 190-4*SplitterStep { + t.Fatalf("Shift+ArrowLeft moves four steps: Files is %d", d.files.Get()) + } + key("End", false) + if d.files.Get() != 190+382-200 || d.s.PaneSize(1) != 200 { + t.Fatalf("End: Files is %d, want the most the editor can give (372)", d.files.Get()) + } + key("Home", false) + if !d.s.Collapsed(0) { + t.Fatalf("Home takes a collapsible pane to nothing: %d", d.files.Get()) + } + key("Enter", false) + if d.s.Collapsed(0) || d.files.Get() != 372 { + t.Fatalf("Enter brings it back to the size it had: %d", d.files.Get()) + } + key("Enter", false) + if !d.s.Collapsed(0) { + t.Fatal("Enter collapses an open pane") + } + if n := len(d.resized); n != 7 { + t.Fatalf("every key that moved something is a gesture: %d, want 7", n) + } + before := len(d.resized) + key("a", false) + key("ArrowUp", false) // not this splitter's axis + if len(d.resized) != before { + t.Fatal("other keys are not for the divider") + } +} + +func TestSplitterTabMovesBetweenDividersAndPanes(t *testing.T) { + d := newIDE(t, 1200) + d.s.FocusHandle(0) + d.s.OnEvent(Event{Kind: EventKeyDown, Code: "Tab"}) + if d.s.FocusedHandle() != 1 { + t.Fatalf("Tab should go to the next divider, got %d", d.s.FocusedHandle()) + } + d.s.OnEvent(Event{Kind: EventKeyDown, Code: "Tab", Shift: true}) + if d.s.FocusedHandle() != 0 { + t.Fatalf("Shift+Tab should come back, got %d", d.s.FocusedHandle()) + } + d.s.Blur() + if d.s.FocusedHandle() != -1 { + t.Fatal("Blur takes the focus off") + } + r, _ := d.s.HandleRect(1) + d.s.OnEvent(Event{Kind: EventClick, X: r.X + 2, Y: 20}) + d.s.OnEvent(Event{Kind: EventMouseUp, X: r.X + 2, Y: 20}) + if d.s.FocusedHandle() != 1 { + t.Fatalf("a press on a divider focuses it, got %d", d.s.FocusedHandle()) + } +} + +func TestSplitterPaneSwitchedOffTakesNoRoomAndNoDivider(t *testing.T) { + d := newIDE(t, 1200) + d.filesOn.Set(false) + if b := d.fw.Bounds(); b.W != 0 || b.H != 0 { + t.Fatalf("a hidden pane has empty bounds: %+v", b) + } + if got := d.sizes(); got[0] != 0 || got[1]+got[2]+got[3]+2*SplitterHandleW != 1200 { + t.Fatalf("the other panes fill the width: %v", got) + } + if _, ok := d.s.HandleRect(2); ok { + t.Fatal("with Files off there are two dividers fewer... only 0..1 are in use") + } + if r, ok := d.s.HandleRect(0); !ok || r.X != d.ew.Bounds().X+d.ew.Bounds().W { + t.Fatalf("divider 0 should now sit after the editor: %+v %v", r, ok) + } + if n := len(d.s.Children()); n != 3 { + t.Fatalf("Children are the panes that are on: %d", n) + } + d.filesOn.Set(true) + if d.files.Get() != 190 || d.s.PaneSize(0) != 190 { + t.Fatal("switching a pane back on gives it the size it had") + } + // a hidden pane hears nothing + d.helpOn.Set(false) + if d.s.PaneSize(3) != 0 { + t.Fatal("Help is off") + } +} + +func TestSplitterObservablesAreTheModel(t *testing.T) { + d := newIDE(t, 1200) + d.files.Set(300) + if d.s.PaneSize(0) != 300 || d.fw.Bounds().W != 300 { + t.Fatalf("setting the Size observable relays out: %d", d.s.PaneSize(0)) + } + d.files.Set(9999) // out of range: held at the Max + if d.s.PaneSize(0) != 480 { + t.Fatalf("a size beyond Max is held at it: %d", d.s.PaneSize(0)) + } + d.files.Set(190) + d.editor.Set(1) + d.preview.Set(1) + if a, b := d.s.PaneSize(1), d.s.PaneSize(2); a-b > 1 || b-a > 1 { + t.Fatalf("equal weights share equally: %d and %d", a, b) + } + if len(d.resized) != 0 { + t.Fatal("writing the model is not a gesture") + } +} + +func TestSplitterSmallWindowSqueezesFixedPanesFirst(t *testing.T) { + d := newIDE(t, 760) + got := d.sizes() + if got[1] < 200 || got[2] < 200 { + t.Fatalf("the flexible panes keep their Min while the fixed ones give way: %v", got) + } + if got[0] < 120 || got[3] < 180 { + t.Fatalf("the fixed panes are squeezed to their Min at most: %v", got) + } + if sum := got[0] + got[1] + got[2] + got[3] + 3*SplitterHandleW; sum != 760 { + t.Fatalf("the panes fill 760: %d", sum) + } + if d.files.Get() != 190 || d.help.Get() != 280 { + t.Fatal("a squeeze is not stored: the sizes come back with the room") + } + d.s.SetBounds(Rect{W: 1200, H: 400}) + if got := d.sizes(); got[0] != 190 || got[3] != 280 { + t.Fatalf("the room came back: %v", got) + } + // even a window too small for the Mins is filled, not overrun + d.s.SetBounds(Rect{W: 300, H: 400}) + if sum := d.sizes(); sum[0]+sum[1]+sum[2]+sum[3]+3*SplitterHandleW > 300 { + t.Fatalf("a tiny window must not be overrun: %v", sum) + } +} + +func TestSplitterVertical(t *testing.T) { + top, bottom := NewLabel("top"), NewLabel("bottom") + h := mvvm.NewObservable(100) + s := NewSplitter(true, + &SplitPane{Widget: top, Name: "Top", Weight: mvvm.NewObservable(1), Min: 50}, + &SplitPane{Widget: bottom, Name: "Bottom", Size: h, Min: 40, Max: 300}) + s.SetBounds(Rect{X: 5, Y: 7, W: 300, H: 500}) + if b := bottom.Bounds(); b.H != 100 || b.W != 300 || b.Y != 7+500-100 { + t.Fatalf("bottom at %+v", b) + } + r, ok := s.HandleRect(0) + if !ok || r.W != 300 || r.H != SplitterHandleW || r.Y != top.Bounds().Y+top.Bounds().H { + t.Fatalf("horizontal bar at %+v", r) + } + if c := s.Cursor(10, r.Y-7+2); c != "row-resize" { + t.Fatalf("cursor over a horizontal bar is %q", c) + } + s.FocusHandle(0) + s.OnEvent(Event{Kind: EventKeyDown, Code: "ArrowUp"}) + if h.Get() != 100+SplitterStep { + t.Fatalf("ArrowUp grows the pane below: %d", h.Get()) + } + s.OnEvent(Event{Kind: EventKeyDown, Code: "ArrowLeft"}) + if h.Get() != 100+SplitterStep { + t.Fatal("a vertical splitter ignores the horizontal arrows") + } + // a drag in the splitter's own coordinates, whatever its origin + r, _ = s.HandleRect(0) // the keys moved it + s.OnEvent(Event{Kind: EventClick, X: 10, Y: r.Y - 7 + 2}) + s.OnEvent(Event{Kind: EventMouseDrag, X: 10, Y: r.Y - 7 + 2 + 30}) + s.OnEvent(Event{Kind: EventMouseUp, X: 10, Y: r.Y - 7 + 2 + 30}) + if h.Get() != 100+SplitterStep-30 { + t.Fatalf("dragging down by 30 shrinks the pane below: %d", h.Get()) + } +} + +func TestSplitterCursorAndHover(t *testing.T) { + d := newIDE(t, 1200) + r, _ := d.s.HandleRect(0) + if c := d.s.Cursor(r.X+2, 30); c != "col-resize" { + t.Fatalf("cursor over a divider: %q", c) + } + if c := d.s.Cursor(r.X-30, 30); c != "" { + t.Fatalf("cursor over a pane: %q", c) + } + d.s.OnEvent(Event{Kind: EventClick, X: r.X + 2, Y: 30}) + if c := d.s.Cursor(r.X+500, 30); c != "col-resize" { + t.Fatalf("while a divider is held the cursor stays: %q", c) + } + if !d.s.Dragging().Get() { + t.Fatal("Dragging should be true while held") + } + d.s.OnEvent(Event{Kind: EventMouseUp, X: r.X + 2, Y: 30}) + if d.s.Dragging().Get() { + t.Fatal("Dragging should end on release") + } +} + +func TestSplitterAccessibleSeparator(t *testing.T) { + d := newIDE(t, 1200) + a, ok := d.s.Handle(0).(Accessible) + if !ok { + t.Fatal("a divider is Accessible") + } + info := a.A11y() + if info.Role != RoleSeparator || info.Name != "Resize Files" || !info.HasRange { + t.Fatalf("separator info %+v", info) + } + if info.Min != 0 || info.Max != 480 || info.Now != 190 { + t.Fatalf("aria-valuemin/max/now are 0/480/190 for a collapsible Files at 190: %+v", info) + } + if f, ok := d.s.Handle(0).(Focusable); !ok || f == nil { + t.Fatal("a divider is Focusable") + } +} + +func TestSplitterContrastIsAtLeastThreeToOneInBothThemes(t *testing.T) { + for name, th := range map[string]*Theme{"light": DefaultLight(), "dark": DefaultDark()} { + ink, active := SplitterColors(th) + for what, c := range map[string]RGBA{"line": ink, "accent": active} { + for gname, g := range map[string]RGBA{"Background": th.Background, "Surface": th.Surface} { + if r := ContrastRatio(c, g); r < 3 { + t.Errorf("%s theme: the divider's %s is %.2f:1 against %s, want 3:1", name, what, r, gname) + } + } + } + } + if r := ContrastRatio(RGB(0, 0, 0), RGB(255, 255, 255)); r < 20.9 || r > 21.1 { + t.Fatalf("black on white is 21:1, got %v", r) + } +} + +func TestSplitterDrawsDividersAndRing(t *testing.T) { + d := newIDE(t, 1200) + th := DefaultLight() + paint := func() []byte { + buf := make([]byte, 1200*400*4) + d.s.Draw(painter.NewPixelPainter(buf, 1200, 400), th) + return buf + } + plain := paint() + d.s.FocusHandle(0) + focused := paint() + same := true + for i := range plain { + if plain[i] != focused[i] { + same = false + break + } + } + if same { + t.Fatal("a focused divider must draw a ring") + } + r, _ := d.s.HandleRect(0) + ink, _ := SplitterColors(th) + px := (r.H/2*1200 + r.X + r.W/2) * 4 + // the grip is centred in the divider, in the ink colour at rest + d.s.Blur() + rest := paint() + if rest[px] != ink.R || rest[px+1] != ink.G || rest[px+2] != ink.B { + t.Fatalf("the divider's centre is %v, want the ink %v", rest[px:px+3], ink) + } +} From f53cea5c94ae98dc4bf6f14802795c862930bf9d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 23 Oct 2026 18:53:46 +0200 Subject: [PATCH 2/2] Splitter.HandleAt: the divider under a surface point Co-Authored-By: Claude Sonnet 5.5 --- splitter.go | 4 ++++ splitter_more_test.go | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/splitter.go b/splitter.go index 0e75aee..45a2e7d 100644 --- a/splitter.go +++ b/splitter.go @@ -526,6 +526,10 @@ func (s *Splitter) handleAt(x, y int) int { return -1 } +// HandleAt is the divider at surface point (x, y), or -1: a host that keeps the +// keyboard itself clears the dividers' focus (Blur) on a press anywhere else. +func (s *Splitter) HandleAt(x, y int) int { return s.handleAt(x, y) } + // HandleRect is divider j's rectangle (surface coordinates); ok is false while // it is off (fewer than j+2 panes are on). func (s *Splitter) HandleRect(j int) (Rect, bool) { diff --git a/splitter_more_test.go b/splitter_more_test.go index 8465f83..f7df0de 100644 --- a/splitter_more_test.go +++ b/splitter_more_test.go @@ -241,6 +241,14 @@ func TestSplitterWeightsWithNoFlexiblePaneLeftToWrite(t *testing.T) { } } +func TestSplitterHandleAt(t *testing.T) { + d := newIDE(t, 1200) + r, _ := d.s.HandleRect(1) + if d.s.HandleAt(r.X+1, 5) != 1 || d.s.HandleAt(r.X-20, 5) != -1 { + t.Fatal("HandleAt finds the divider under a surface point") + } +} + func TestSplitterCollapsesAPaneAfterItsDivider(t *testing.T) { d := newIDE(t, 1200) d.helpPane.Collapsible = true