turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

dialogs.go · 485 lines · 15.6 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 4h ago1package app
2
3import (
4 "os"
5 "path/filepath"
6 "sort"
7 "strings"
8
9 "codeberg.org/turbo-editors/turbo-core/ui"
10)
11
12// dialogWidth and dialogHeight are the sizes the editor's dialogs come in.
13const (
14 fileDialogWidth = 56
15 fileDialogHeight = 18
16 smallDialogWidth = 48
17)
18
19// parentEntry is the line at the top of every listing, which browses upwards.
20var parentEntry = ".." + string(filepath.Separator)
21
22// FileDialog is the Open and Save As box: a name to type and a directory to
23// browse.
24type FileDialog struct {
25 dialog *ui.Dialog
26 name *ui.InputLine
27 list *ui.ListBox
28 title string
29 directory string
30 entries []string // what the list shows, parallel to its own items
31}
32
33// Dialog returns the modal dialog to show.
34func (f *FileDialog) Dialog() *ui.Dialog { return f.dialog }
35
36// Path returns the file the user settled on, made absolute.
37func (f *FileDialog) Path() string {
38 name := strings.TrimSpace(f.name.Text())
39 if name == "" {
40 return ""
41 }
42 if filepath.IsAbs(name) {
43 return filepath.Clean(name)
44 }
45 return filepath.Join(f.directory, name)
46}
47
48// NewFileDialog builds an Open or Save As box, starting in the directory of
49// startPath and with its base name filled in.
50func NewFileDialog(title, startPath string, screen ui.Rect) *FileDialog {
51 directory, name := splitStartPath(startPath)
52
53 f := &FileDialog{
54 dialog: ui.NewDialog(title),
55 name: ui.NewInputLine(name),
56 list: ui.NewListBox(nil),
57 title: title,
58 directory: directory,
59 }
60 f.dialog.SetBounds(ui.Rect{W: fileDialogWidth, H: fileDialogHeight}.CenteredIn(screen))
61 f.layout()
62 f.readDirectory()
63 return f
64}
65
66// splitStartPath turns the path a dialog opens on into a directory and a name.
67func splitStartPath(startPath string) (directory, name string) {
68 if startPath == "" {
69 if working, err := os.Getwd(); err == nil {
70 return working, ""
71 }
72 return ".", ""
73 }
74
75 absolute, err := filepath.Abs(startPath)
76 if err != nil {
77 absolute = startPath
78 }
79 if info, err := os.Stat(absolute); err == nil && info.IsDir() {
80 return absolute, ""
81 }
82 return filepath.Dir(absolute), filepath.Base(absolute)
83}
84
85// layout places the dialog's controls and wires them together.
86func (f *FileDialog) layout() {
87 label := ui.NewLabel("Name:")
88 label.SetBounds(f.dialog.Layout(3, 2, 10, 1))
89
90 f.name.SetBounds(f.dialog.Layout(3, 3, fileDialogWidth-6, 1))
91 f.list.SetBounds(f.dialog.Layout(3, 5, fileDialogWidth-6, fileDialogHeight-9))
92 f.list.OnChoose = func(int) { f.choose() }
93 f.list.OnSelect = f.showSelection
94
95 ok := ui.NewButton("~O~K", func() { f.confirm() })
96 ok.Default = true
97 ok.SetBounds(f.dialog.Layout(fileDialogWidth-26, fileDialogHeight-3, ui.ButtonWidth("~O~K"), 1))
98
99 cancel := ui.NewButton("~C~ancel", func() { f.dialog.Close(ui.ResultCancel) })
100 cancel.SetBounds(f.dialog.Layout(fileDialogWidth-16, fileDialogHeight-3, ui.ButtonWidth("~C~ancel"), 1))
101
102 f.dialog.Add(label)
103 f.dialog.Add(f.name)
104 f.dialog.Add(f.list)
105 f.dialog.Add(ok)
106 f.dialog.Add(cancel)
107}
108
109// showSelection puts the highlighted entry's name into the field, so the field
110// always says what OK is going to act on.
111//
112// The parent entry is the exception: ".." is not a name anyone would type into
113// a field labelled "Name:", so it clears the field instead and lets confirm
114// fall back to the highlight.
115//
116// This runs on every change of the highlight, including the one SetItems makes
117// when the list is refilled — which is why entering a directory clears the
118// field rather than leaving the name that was in it.
119func (f *FileDialog) showSelection(index int) {
120 if index < 0 || index >= len(f.entries) {
121 return
122 }
123
124 entry := f.entries[index]
125 if entry == parentEntry {
126 f.name.SetText("")
127 return
128 }
129 f.name.SetText(strings.TrimSuffix(entry, string(filepath.Separator)))
130}
131
132// confirm is what the OK button does: act on the name in the field, or, when
133// the field is empty, on whatever the list has highlighted.
134//
135// A name that turns out to be a directory browses into it rather than closing,
136// which is what a double click on it would do.
137//
138// The fallback matters more than it looks. Highlighting a file in the list is
139// how people pick one, and before the field was wired to the highlight, OK on
140// a freshly opened dialog did nothing whatsoever — the field was empty, so
141// there was no path to act on and the button appeared broken.
142func (f *FileDialog) confirm() {
143 path := f.Path()
144 if path == "" {
145 f.choose()
146 return
147 }
148 if info, err := os.Stat(path); err == nil && info.IsDir() {
149 f.enter(path)
150 return
151 }
152 f.dialog.Close(ui.ResultOK)
153}
154
155// choose acts on the highlighted list entry: browse into a directory, or put a
156// file's name into the field.
157func (f *FileDialog) choose() {
158 index := f.list.Selected()
159 if index < 0 || index >= len(f.entries) {
160 return
161 }
162
163 entry := f.entries[index]
164 target := filepath.Join(f.directory, strings.TrimSuffix(entry, string(filepath.Separator)))
165 if strings.HasSuffix(entry, string(filepath.Separator)) {
166 f.enter(target)
167 return
168 }
169 f.name.SetText(entry)
170 f.dialog.Close(ui.ResultOK)
171}
172
173// enter browses into a directory.
174func (f *FileDialog) enter(directory string) {
175 f.directory = filepath.Clean(directory)
176 f.name.SetText("")
177 f.readDirectory()
178}
179
180// readDirectory refills the list from the current directory. A directory that
181// cannot be read shows as empty rather than as an error: the user can still
182// type a path by hand.
183func (f *FileDialog) readDirectory() {
184 f.dialog.SetTitle(f.title + " — " + shortenPath(f.directory, fileDialogWidth-len(f.title)-8))
185
186 entries, err := os.ReadDir(f.directory)
187 if err != nil {
188 f.entries = []string{parentEntry}
189 f.list.SetItems(f.entries)
190 return
191 }
192
193 f.entries = directoryEntries(entries)
194 f.list.SetItems(f.entries)
195}
196
197// directoryEntries returns the names to list: the parent, then the
198// directories, then the files, each group sorted.
199func directoryEntries(entries []os.DirEntry) []string {
200 var directories, files []string
201 for _, entry := range entries {
202 if strings.HasPrefix(entry.Name(), ".") {
203 continue // hidden files would bury the source in dot-directories
204 }
205 if entry.IsDir() {
206 directories = append(directories, entry.Name()+string(filepath.Separator))
207 } else {
208 files = append(files, entry.Name())
209 }
210 }
211
212 sort.Strings(directories)
213 sort.Strings(files)
214 return append(append([]string{parentEntry}, directories...), files...)
215}
216
217// NewOutputDialog builds a box showing a command's output, with a single Close
218// button.
219//
220// The body is a ListBox, so scrolling with the arrows, Page Up and Down, Home,
221// End and the wheel all come for nothing — a command's output is exactly the
222// kind of thing that does not fit, and a message dialog cannot scroll.
223//
224// It is sized to the screen rather than to the text, because the text grows
225// while the command runs and a dialog that resized under the reader would be
226// unusable.
227//
228// dialog, list := NewOutputDialog("go test ./... — running", nil, a.screenRect())
229func NewOutputDialog(title string, lines []string, screen ui.Rect) (*ui.Dialog, *ui.ListBox) {
230 width := max(min(screen.W-8, outputDialogMaxWidth), outputDialogMinWidth)
231 height := max(min(screen.H-6, outputDialogMaxHeight), outputDialogMinHeight)
232
233 dialog := ui.NewDialog(title)
234 dialog.SetBounds(ui.Rect{W: width, H: height}.CenteredIn(screen))
235
236 list := ui.NewListBox(lines)
237 list.SetBounds(dialog.Layout(3, 2, width-6, height-6))
238
239 close := ui.NewButton("~C~lose", func() { dialog.Close(ui.ResultOK) })
240 close.Default = true
241 close.SetBounds(dialog.Layout((width-ui.ButtonWidth("~C~lose"))/2, height-3, ui.ButtonWidth("~C~lose"), 1))
242
243 dialog.Add(list)
244 dialog.Add(close)
245 return dialog, list
246}
247
248// The size an output dialog is held between: big enough for a stack trace to
249// be worth reading, small enough that the editor is still visible behind it.
250const (
251 outputDialogMinWidth = 40
252 outputDialogMaxWidth = 100
253 outputDialogMinHeight = 8
254 outputDialogMaxHeight = 24
255)
256
257// NewMessageDialog builds a box showing a message with a single OK button.
258func NewMessageDialog(title, message string, screen ui.Rect) *ui.Dialog {
259 lines := strings.Split(message, "\n")
260 height := len(lines) + 6
261
262 dialog := ui.NewDialog(title)
263 dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen))
264
265 for i, line := range lines {
266 label := ui.NewLabel(line)
267 label.SetBounds(dialog.Layout(3, 2+i, smallDialogWidth-6, 1))
268 dialog.Add(label)
269 }
270
271 ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) })
272 ok.Default = true
273 ok.SetBounds(dialog.Layout((smallDialogWidth-ui.ButtonWidth("~O~K"))/2, height-3, ui.ButtonWidth("~O~K"), 1))
274 dialog.Add(ok)
275
276 return dialog
277}
278
279// NewConfirmDialog builds a Yes / No / Cancel question, which is what an
280// unsaved file gets asked on the way out.
281func NewConfirmDialog(title, message string, screen ui.Rect) *ui.Dialog {
282 const height = 8
283 dialog := ui.NewDialog(title)
284 dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen))
285
286 label := ui.NewLabel(message)
287 label.SetBounds(dialog.Layout(3, 2, smallDialogWidth-6, 1))
288 dialog.Add(label)
289
290 yes := ui.NewButton("~Y~es", func() { dialog.Close(ui.ResultOK) })
291 yes.Default = true
292 yes.SetBounds(dialog.Layout(6, height-3, ui.ButtonWidth("~Y~es"), 1))
293
294 no := ui.NewButton("~N~o", func() { dialog.Close(ui.ResultNo) })
295 no.SetBounds(dialog.Layout(18, height-3, ui.ButtonWidth("~N~o"), 1))
296
297 cancel := ui.NewButton("~C~ancel", func() { dialog.Close(ui.ResultCancel) })
298 cancel.SetBounds(dialog.Layout(29, height-3, ui.ButtonWidth("~C~ancel"), 1))
299
300 dialog.Add(yes)
301 dialog.Add(no)
302 dialog.Add(cancel)
303 return dialog
304}
305
306// NewPromptDialog builds a box asking for one line of text.
307func NewPromptDialog(title, label, initial string, screen ui.Rect) (*ui.Dialog, *ui.InputLine) {
308 const height = 8
309 dialog := ui.NewDialog(title)
310 dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen))
311
312 caption := ui.NewLabel(label)
313 caption.SetBounds(dialog.Layout(3, 2, smallDialogWidth-6, 1))
314 field := ui.NewInputLine(initial)
315 field.SetBounds(dialog.Layout(3, 3, smallDialogWidth-6, 1))
316
317 ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) })
318 ok.Default = true
319 ok.SetBounds(dialog.Layout(smallDialogWidth-26, height-3, ui.ButtonWidth("~O~K"), 1))
320 cancel := ui.NewButton("~C~ancel", func() { dialog.Close(ui.ResultCancel) })
321 cancel.SetBounds(dialog.Layout(smallDialogWidth-16, height-3, ui.ButtonWidth("~C~ancel"), 1))
322
323 dialog.Add(caption)
324 dialog.Add(field)
325 dialog.Add(ok)
326 dialog.Add(cancel)
327 return dialog, field
328}
329
330// FindDialog is the Search box: what to look for, and how.
331type FindDialog struct {
332 dialog *ui.Dialog
333 needle *ui.InputLine
334 matchCase *ui.CheckBox
335}
336
337// Dialog returns the modal dialog to show.
338func (f *FindDialog) Dialog() *ui.Dialog { return f.dialog }
339
340// Needle returns the text to search for.
341func (f *FindDialog) Needle() string { return f.needle.Text() }
342
343// MatchCase reports whether the search should respect case.
344func (f *FindDialog) MatchCase() bool { return f.matchCase.Checked() }
345
346// NewFindDialog builds the Search box.
347func NewFindDialog(initial string, matchCase bool, screen ui.Rect) *FindDialog {
348 const height = 10
349 dialog := ui.NewDialog("Find")
350 dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen))
351
352 f := &FindDialog{
353 dialog: dialog,
354 needle: ui.NewInputLine(initial),
355 matchCase: ui.NewCheckBox("~C~ase sensitive", matchCase),
356 }
357
358 caption := ui.NewLabel("Text to find:")
359 caption.SetBounds(dialog.Layout(3, 2, smallDialogWidth-6, 1))
360 f.needle.SetBounds(dialog.Layout(3, 3, smallDialogWidth-6, 1))
361 f.matchCase.SetBounds(dialog.Layout(3, 5, smallDialogWidth-6, 1))
362
363 ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) })
364 ok.Default = true
365 ok.SetBounds(dialog.Layout(smallDialogWidth-26, height-3, ui.ButtonWidth("~O~K"), 1))
366 cancel := ui.NewButton("Ca~n~cel", func() { dialog.Close(ui.ResultCancel) })
367 cancel.SetBounds(dialog.Layout(smallDialogWidth-16, height-3, ui.ButtonWidth("Ca~n~cel"), 1))
368
369 dialog.Add(caption)
370 dialog.Add(f.needle)
371 dialog.Add(f.matchCase)
372 dialog.Add(ok)
373 dialog.Add(cancel)
374 return f
375}
376
377// NewChoiceDialog builds a box offering a list to pick from, which is how a
378// theme and a window are chosen.
379func NewChoiceDialog(title string, items []string, selected int, screen ui.Rect) (*ui.Dialog, *ui.ListBox) {
380 const width = 44
381 height := min(len(items), 12) + 6
382
383 dialog := ui.NewDialog(title)
384 dialog.SetBounds(ui.Rect{W: width, H: height}.CenteredIn(screen))
385
386 list := ui.NewListBox(items)
387 list.SetBounds(dialog.Layout(3, 2, width-6, height-6))
388 list.Select(selected)
389 list.OnChoose = func(int) { dialog.Close(ui.ResultOK) }
390
391 ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) })
392 ok.Default = true
393 ok.SetBounds(dialog.Layout(width-26, height-3, ui.ButtonWidth("~O~K"), 1))
394 cancel := ui.NewButton("~C~ancel", func() { dialog.Close(ui.ResultCancel) })
395 cancel.SetBounds(dialog.Layout(width-16, height-3, ui.ButtonWidth("~C~ancel"), 1))
396
397 dialog.Add(list)
398 dialog.Add(ok)
399 dialog.Add(cancel)
400 return dialog, list
401}
402
403// shortenPath trims a directory from the left so it fits in width cells, since
404// a title bar has far less room than a path can need.
405func shortenPath(path string, width int) string {
406 runes := []rune(path)
407 if width < 4 || len(runes) <= width {
408 return path
409 }
410 return "…" + string(runes[len(runes)-width+1:])
411}
412
413// ParametersDialog asks for the values a tool's command needs before it runs.
414//
415// One field per placeholder, in the order they appear in the command, with
416// whatever was typed last time already in them.
417type ParametersDialog struct {
418 dialog *ui.Dialog
419 labels []string
420 fields []*ui.InputLine
421}
422
423// parameterChrome is the rows a parameters dialog spends on everything that is
424// not a label-and-field pair: the frame, the gaps, and the buttons.
425const parameterChrome = 6
426
427// MaxParameterFields returns how many values a dialog can ask for on a screen
428// of the given height.
429//
430// It is exported because the caller has to know before it opens one: a tool
431// asking for more than fit would give a dialog whose OK button is off the
432// bottom of the screen, which is worse than being told.
433//
434// if len(placeholders) > app.MaxParameterFields(screen.H) {
435// // say so instead of opening it
436// }
437func MaxParameterFields(screenHeight int) int {
438 return max((screenHeight-parameterChrome)/2, 1)
439}
440
441// NewParametersDialog builds the box.
442//
443// initial holds what to put in each field, by label; a label with nothing
444// remembered starts empty.
445//
446// dialog := app.NewParametersDialog("Init module", []string{"module path"}, nil, screen)
447func NewParametersDialog(title string, labels []string, initial map[string]string, screen ui.Rect) *ParametersDialog {
448 height := len(labels)*2 + parameterChrome
449 dialog := ui.NewDialog(title)
450 dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen))
451
452 p := &ParametersDialog{dialog: dialog, labels: labels}
453 for i, label := range labels {
454 caption := ui.NewLabel(label + ":")
455 caption.SetBounds(dialog.Layout(3, 2+i*2, smallDialogWidth-6, 1))
456 field := ui.NewInputLine(initial[label])
457 field.SetBounds(dialog.Layout(3, 3+i*2, smallDialogWidth-6, 1))
458
459 dialog.Add(caption)
460 dialog.Add(field)
461 p.fields = append(p.fields, field)
462 }
463
464 ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) })
465 ok.Default = true
466 ok.SetBounds(dialog.Layout(smallDialogWidth-26, height-3, ui.ButtonWidth("~O~K"), 1))
467 cancel := ui.NewButton("~C~ancel", func() { dialog.Close(ui.ResultCancel) })
468 cancel.SetBounds(dialog.Layout(smallDialogWidth-16, height-3, ui.ButtonWidth("~C~ancel"), 1))
469
470 dialog.Add(ok)
471 dialog.Add(cancel)
472 return p
473}
474
475// Dialog returns the modal dialog to show.
476func (p *ParametersDialog) Dialog() *ui.Dialog { return p.dialog }
477
478// Values returns what was typed, by label.
479func (p *ParametersDialog) Values() map[string]string {
480 values := make(map[string]string, len(p.fields))
481 for i, field := range p.fields {
482 values[p.labels[i]] = field.Text()
483 }
484 return values
485}