1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
|
package filetree
import (
"rickub.com/turbo-editors/turbo-core/ui"
)
// The markers drawn before a directory, and the indentation of one level.
//
// A file gets a marker's worth of blank so that names line up whatever their
// neighbours are, which is what makes a column of names readable at a glance.
const (
openMarker = "▼ "
closedMarker = "▶ "
fileMarker = " "
indent = " "
)
// wheelStep is how many rows one notch of the mouse wheel moves.
const wheelStep = 3
// View shows a project's files in a window and opens the one that is chosen.
//
// It is a ui.Widget, so it goes into an ordinary window and gets the desktop's
// moving, resizing and maximising for free.
//
// view, err := filetree.NewView(".")
// if err != nil {
// return err
// }
// view.OnOpen = func(path string) { editor.Open(path) }
// window := ui.NewWindow(view.Title(), view)
type View struct {
ui.FocusBox
tree *Tree
selected int
top int
// OnOpen is called with the absolute path of a file that was chosen. A
// directory never reaches it: choosing one opens or closes the branch.
OnOpen func(path string)
}
// NewView reads a directory and returns the widget showing it.
//
// The error is the one New gives: a root that is not a directory, or cannot be
// read at all, leaves the caller with nothing to show.
func NewView(root string) (*View, error) {
tree, err := New(root)
if err != nil {
return nil, err
}
return &View{tree: tree}, nil
}
// Title returns what the window holding this view should be called: the name
// of the directory the tree is rooted at.
func (v *View) Title() string { return v.tree.Root().Name() }
// Root returns the absolute path the tree is rooted at, which is how a caller
// can tell whether it already has a tree on this project.
func (v *View) Root() string { return v.tree.Root().Path() }
// Refresh re-reads the project and keeps the highlight on the same entry when
// it is still there.
//
// view.Refresh() // after a build, or when a file is saved
func (v *View) Refresh() {
wanted := v.selectedPath()
v.tree.Refresh()
v.selectPath(wanted)
}
// selectedPath returns the path of the highlighted row, or "" when there is
// no row to be on.
func (v *View) selectedPath() string {
rows := v.tree.Rows()
if v.selected < 0 || v.selected >= len(rows) {
return ""
}
return rows[v.selected].Node.Path()
}
// selectPath puts the highlight back on a path, or on the nearest row that
// still exists when it has gone.
func (v *View) selectPath(path string) {
rows := v.tree.Rows()
for i, row := range rows {
if row.Node.Path() == path {
v.selected = i
return
}
}
v.selected = min(max(v.selected, 0), max(len(rows)-1, 0))
}
// Selected returns the highlighted node, or nil when the project is empty.
func (v *View) Selected() *Node {
rows := v.tree.Rows()
if v.selected < 0 || v.selected >= len(rows) {
return nil
}
return rows[v.selected].Node
}
// Choose acts on the highlighted row: a directory opens or closes, and a file
// is handed to OnOpen.
func (v *View) Choose() {
node := v.Selected()
if node == nil {
return
}
if node.IsDir() {
node.Toggle()
v.keepSelectionInRange()
return
}
if v.OnOpen != nil {
v.OnOpen(node.Path())
}
}
// keepSelectionInRange holds the highlight inside the rows there are, which
// collapsing a branch above it can otherwise leave past the end.
func (v *View) keepSelectionInRange() {
v.selected = min(max(v.selected, 0), max(len(v.tree.Rows())-1, 0))
}
|