Files
ilovetui/layout/navigation.go
T

152 lines
4.5 KiB
Go

package layout
import tea "charm.land/bubbletea/v2"
// FocusDirection is a screen-space direction for moving focus between
// panes: ctrl+h/j/k/l. Distinct from Direction (a Split's axis) because a
// split only ever has two sides, while focus needs to move one of four
// ways.
type FocusDirection int
const (
FocusLeft FocusDirection = iota
FocusRight
FocusUp
FocusDown
)
// FindNeighbor picks the leaf, among leaves, that's geometrically closest to
// from in dir - tmux's select-pane -L/-D/-U/-R, not a tree walk. A
// candidate must be strictly positioned beyond from's edge in dir and share
// some extent with it on the perpendicular axis. Ranked by, in order: edge
// gap (smaller wins), then shared perpendicular extent (larger wins - the
// real tie-breaker between two equally-close neighbors), then
// center-to-center distance on the perpendicular axis (last resort). Pure
// and independent of Model so it's testable on its own.
func FindNeighbor(leaves []LeafRect, from LeafRect, dir FocusDirection) (id string, ok bool) {
type candidate struct {
id string
gap, overlap, cross int
}
var best *candidate
for _, lr := range leaves {
if lr.ID == from.ID {
continue
}
gap, overlap, cross, ok := edgeScore(from.Rect, lr.Rect, dir)
if !ok {
continue
}
c := candidate{id: lr.ID, gap: gap, overlap: overlap, cross: cross}
if best == nil ||
c.gap < best.gap ||
(c.gap == best.gap && c.overlap > best.overlap) ||
(c.gap == best.gap && c.overlap == best.overlap && c.cross < best.cross) {
best = &c
}
}
if best == nil {
return "", false
}
return best.id, true
}
// edgeScore returns the ranking tuple used by FindNeighbor for a single
// (from, other) pair, and ok=false if other isn't a valid candidate in dir
// at all (wrong side, or zero overlap on the perpendicular axis).
func edgeScore(from, other Rect, dir FocusDirection) (gap, overlap, cross int, ok bool) {
switch dir {
case FocusLeft:
if other.X+other.W > from.X {
return 0, 0, 0, false
}
gap = from.X - (other.X + other.W)
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
case FocusRight:
if other.X < from.X+from.W {
return 0, 0, 0, false
}
gap = other.X - (from.X + from.W)
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
case FocusUp:
if other.Y+other.H > from.Y {
return 0, 0, 0, false
}
gap = from.Y - (other.Y + other.H)
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
case FocusDown:
if other.Y < from.Y+from.H {
return 0, 0, 0, false
}
gap = other.Y - (from.Y + from.H)
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
}
if overlap <= 0 {
return 0, 0, 0, false
}
return gap, overlap, cross, true
}
// spanOverlap returns the length shared by [aStart,aEnd) and [bStart,bEnd).
func spanOverlap(aStart, aEnd, bStart, bEnd int) int {
start := max(aStart, bStart)
end := min(aEnd, bEnd)
if end <= start {
return 0
}
return end - start
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// leafRect looks up id's current Rect in this Model's own flat registry.
func (m Model) leafRect(id string) (LeafRect, bool) {
for _, lr := range m.leaves {
if lr.ID == id {
return lr, true
}
}
return LeafRect{}, false
}
// MoveFocus implements Navigable. If the currently focused leaf holds a
// nested Navigable (an embedded layout.Model), it's given first refusal -
// only once it reports being at its own edge (false) does this Model try
// moving focus among its own direct children instead. Cmds produced by the
// BlurMsg/FocusMsg this triggers can't be returned directly (Navigable's
// signature is bool-only); they're queued on m.state.pendingCmd instead
// (see focusState) and drained by the next Update that actually returns a
// tea.Cmd - a lag of at most one Update cycle, never user-visible.
func (m Model) MoveFocus(dir FocusDirection) bool {
if focused, ok := findNode(m.root, m.state.id); ok {
if nav, ok := focused.model.(Navigable); ok {
if nav.MoveFocus(dir) {
focused.model = nav
return true
}
}
}
from, ok := m.leafRect(m.state.id)
if !ok {
return false
}
id, ok := FindNeighbor(m.leaves, from, dir)
if !ok {
return false
}
m.state.pendingCmd = tea.Batch(m.state.pendingCmd, m.setFocus(id))
return true
}