mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 12:05:49 +02:00
5f99fa7521
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
152 lines
4.5 KiB
Go
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
|
|
}
|