github.com/wtfutil/wtf@v0.43.0/app/focus_tracker.go (about)

     1  package app
     2  
     3  import (
     4  	"fmt"
     5  	"sort"
     6  
     7  	"github.com/olebedev/config"
     8  	"github.com/rivo/tview"
     9  	"github.com/wtfutil/wtf/wtf"
    10  )
    11  
    12  // FocusState is a custom type that differentiates focusable scopes
    13  type FocusState int
    14  
    15  const (
    16  	widgetFocused FocusState = iota
    17  	appBoardFocused
    18  	neverFocused
    19  )
    20  
    21  // FocusTracker is used by the app to track which onscreen widget currently has focus,
    22  // and to move focus between widgets.
    23  type FocusTracker struct {
    24  	Idx       int
    25  	IsFocused bool
    26  	Widgets   []wtf.Wtfable
    27  
    28  	config   *config.Config
    29  	tviewApp *tview.Application
    30  }
    31  
    32  // NewFocusTracker creates and returns an instance of FocusTracker
    33  func NewFocusTracker(tviewApp *tview.Application, widgets []wtf.Wtfable, config *config.Config) FocusTracker {
    34  	focusTracker := FocusTracker{
    35  		tviewApp:  tviewApp,
    36  		Idx:       -1,
    37  		IsFocused: false,
    38  		Widgets:   widgets,
    39  
    40  		config: config,
    41  	}
    42  
    43  	focusTracker.assignHotKeys()
    44  
    45  	return focusTracker
    46  }
    47  
    48  /* -------------------- Exported Functions -------------------- */
    49  
    50  // FocusOn puts the focus on the item that belongs to the focus character passed in
    51  func (tracker *FocusTracker) FocusOn(char string) bool {
    52  	if !tracker.useNavShortcuts() {
    53  		return false
    54  	}
    55  
    56  	if tracker.focusState() == appBoardFocused {
    57  		return false
    58  	}
    59  
    60  	hasFocusable := false
    61  
    62  	for idx, focusable := range tracker.focusables() {
    63  		if focusable.FocusChar() == char {
    64  			tracker.blur(tracker.Idx)
    65  			tracker.Idx = idx
    66  			tracker.focus(tracker.Idx)
    67  
    68  			hasFocusable = true
    69  			tracker.IsFocused = true
    70  			break
    71  		}
    72  	}
    73  
    74  	return hasFocusable
    75  }
    76  
    77  // Next sets the focus on the next widget in the widget list. If the current widget is
    78  // the last widget, sets focus on the first widget.
    79  func (tracker *FocusTracker) Next() {
    80  	if tracker.focusState() == appBoardFocused {
    81  		return
    82  	}
    83  
    84  	tracker.blur(tracker.Idx)
    85  	tracker.increment()
    86  	tracker.focus(tracker.Idx)
    87  
    88  	tracker.IsFocused = true
    89  }
    90  
    91  // None removes focus from the currently-focused widget.
    92  func (tracker *FocusTracker) None() {
    93  	if tracker.focusState() == appBoardFocused {
    94  		return
    95  	}
    96  
    97  	tracker.blur(tracker.Idx)
    98  }
    99  
   100  // Prev sets the focus on the previous widget in the widget list. If the current widget is
   101  // the last widget, sets focus on the last widget.
   102  func (tracker *FocusTracker) Prev() {
   103  	if tracker.focusState() == appBoardFocused {
   104  		return
   105  	}
   106  
   107  	tracker.blur(tracker.Idx)
   108  	tracker.decrement()
   109  	tracker.focus(tracker.Idx)
   110  
   111  	tracker.IsFocused = true
   112  }
   113  
   114  // Refocus forces the focus back to the currently-selected item
   115  func (tracker *FocusTracker) Refocus() {
   116  	tracker.focus(tracker.Idx)
   117  }
   118  
   119  /* -------------------- Unexported Functions -------------------- */
   120  
   121  // AssignHotKeys assigns an alphabetic keyboard character to each focusable
   122  // widget so that the widget can be brought into focus by pressing that keyboard key
   123  // Valid numbers are between 1 and 9, inclusive
   124  func (tracker *FocusTracker) assignHotKeys() {
   125  	if !tracker.useNavShortcuts() {
   126  		return
   127  	}
   128  
   129  	usedKeys := make(map[string]bool)
   130  	focusables := tracker.focusables()
   131  
   132  	// First, block out the explicitly-defined characters so they can't be automatically
   133  	// assigned to other modules
   134  	for _, focusable := range focusables {
   135  		if focusable.FocusChar() != "" {
   136  			usedKeys[focusable.FocusChar()] = true
   137  		}
   138  	}
   139  
   140  	focusNum := 1
   141  
   142  	// Range over all the modules and assign focus characters to any that are focusable
   143  	// and don't have explicitly-defined focus characters
   144  	for _, focusable := range focusables {
   145  		if focusable.FocusChar() != "" {
   146  			continue
   147  		}
   148  
   149  		if _, foundKey := usedKeys[fmt.Sprint(focusNum)]; foundKey {
   150  			for ; foundKey; _, foundKey = usedKeys[fmt.Sprint(focusNum)] {
   151  				focusNum++
   152  			}
   153  		}
   154  
   155  		// Don't allow focus characters > "9"
   156  		if focusNum >= 10 {
   157  			break
   158  		}
   159  
   160  		focusable.SetFocusChar(fmt.Sprint(focusNum))
   161  		focusNum++
   162  	}
   163  }
   164  
   165  func (tracker *FocusTracker) blur(idx int) {
   166  	widget := tracker.focusableAt(idx)
   167  	if widget == nil {
   168  		return
   169  	}
   170  
   171  	view := widget.TextView()
   172  	view.Blur()
   173  
   174  	view.SetBorderColor(
   175  		wtf.ColorFor(
   176  			widget.BorderColor(),
   177  		),
   178  	)
   179  
   180  	tracker.IsFocused = false
   181  }
   182  
   183  func (tracker *FocusTracker) decrement() {
   184  	tracker.Idx--
   185  
   186  	if tracker.Idx < 0 {
   187  		tracker.Idx = len(tracker.focusables()) - 1
   188  	}
   189  }
   190  
   191  func (tracker *FocusTracker) focus(idx int) {
   192  	widget := tracker.focusableAt(idx)
   193  	if widget == nil {
   194  		return
   195  	}
   196  
   197  	view := widget.TextView()
   198  	view.SetBorderColor(
   199  		wtf.ColorFor(
   200  			widget.CommonSettings().Colors.BorderTheme.Focused,
   201  		),
   202  	)
   203  	tracker.tviewApp.SetFocus(view)
   204  }
   205  
   206  func (tracker *FocusTracker) focusables() []wtf.Wtfable {
   207  	focusable := []wtf.Wtfable{}
   208  
   209  	for _, widget := range tracker.Widgets {
   210  		if widget.Focusable() {
   211  			focusable = append(focusable, widget)
   212  		}
   213  	}
   214  
   215  	// Sort for deterministic ordering
   216  	sort.SliceStable(focusable, func(i, j int) bool {
   217  		iTop := focusable[i].CommonSettings().Top
   218  		jTop := focusable[j].CommonSettings().Top
   219  
   220  		if iTop < jTop {
   221  			return true
   222  		}
   223  		if iTop == jTop {
   224  			return focusable[i].CommonSettings().Left < focusable[j].CommonSettings().Left
   225  		}
   226  		return false
   227  	})
   228  
   229  	return focusable
   230  }
   231  
   232  func (tracker *FocusTracker) focusableAt(idx int) wtf.Wtfable {
   233  	if idx < 0 || idx >= len(tracker.focusables()) {
   234  		return nil
   235  	}
   236  
   237  	return tracker.focusables()[idx]
   238  }
   239  
   240  func (tracker *FocusTracker) focusState() FocusState {
   241  	if tracker.Idx < 0 {
   242  		return neverFocused
   243  	}
   244  
   245  	for _, widget := range tracker.Widgets {
   246  		if widget.TextView() == tracker.tviewApp.GetFocus() {
   247  			return widgetFocused
   248  		}
   249  	}
   250  
   251  	return appBoardFocused
   252  }
   253  
   254  func (tracker *FocusTracker) increment() {
   255  	tracker.Idx++
   256  
   257  	if tracker.Idx == len(tracker.focusables()) {
   258  		tracker.Idx = 0
   259  	}
   260  }
   261  
   262  func (tracker *FocusTracker) useNavShortcuts() bool {
   263  	return tracker.config.UBool("wtf.navigation.shortcuts", true)
   264  }