package app import ( "os" "path/filepath" "sort" "strings" "rickub.com/turbo-editors/turbo-core/ui" ) // dialogWidth and dialogHeight are the sizes the editor's dialogs come in. const ( fileDialogWidth = 56 fileDialogHeight = 18 smallDialogWidth = 48 ) // parentEntry is the line at the top of every listing, which browses upwards. var parentEntry = ".." + string(filepath.Separator) // FileDialog is the Open and Save As box: a name to type and a directory to // browse. type FileDialog struct { dialog *ui.Dialog name *ui.InputLine list *ui.ListBox title string directory string entries []string // what the list shows, parallel to its own items } // Dialog returns the modal dialog to show. func (f *FileDialog) Dialog() *ui.Dialog { return f.dialog } // Path returns the file the user settled on, made absolute. func (f *FileDialog) Path() string { name := strings.TrimSpace(f.name.Text()) if name == "" { return "" } if filepath.IsAbs(name) { return filepath.Clean(name) } return filepath.Join(f.directory, name) } // NewFileDialog builds an Open or Save As box, starting in the directory of // startPath and with its base name filled in. func NewFileDialog(title, startPath string, screen ui.Rect) *FileDialog { directory, name := splitStartPath(startPath) f := &FileDialog{ dialog: ui.NewDialog(title), name: ui.NewInputLine(name), list: ui.NewListBox(nil), title: title, directory: directory, } f.dialog.SetBounds(ui.Rect{W: fileDialogWidth, H: fileDialogHeight}.CenteredIn(screen)) f.layout() f.readDirectory() return f } // splitStartPath turns the path a dialog opens on into a directory and a name. func splitStartPath(startPath string) (directory, name string) { if startPath == "" { if working, err := os.Getwd(); err == nil { return working, "" } return ".", "" } absolute, err := filepath.Abs(startPath) if err != nil { absolute = startPath } if info, err := os.Stat(absolute); err == nil && info.IsDir() { return absolute, "" } return filepath.Dir(absolute), filepath.Base(absolute) } // layout places the dialog's controls and wires them together. func (f *FileDialog) layout() { label := ui.NewLabel("Name:") label.SetBounds(f.dialog.Layout(3, 2, 10, 1)) f.name.SetBounds(f.dialog.Layout(3, 3, fileDialogWidth-6, 1)) f.list.SetBounds(f.dialog.Layout(3, 5, fileDialogWidth-6, fileDialogHeight-9)) f.list.OnChoose = func(int) { f.choose() } f.list.OnSelect = f.showSelection ok := ui.NewButton("~O~K", func() { f.confirm() }) ok.Default = true ok.SetBounds(f.dialog.Layout(fileDialogWidth-26, fileDialogHeight-3, ui.ButtonWidth("~O~K"), 1)) cancel := ui.NewButton("~C~ancel", func() { f.dialog.Close(ui.ResultCancel) }) cancel.SetBounds(f.dialog.Layout(fileDialogWidth-16, fileDialogHeight-3, ui.ButtonWidth("~C~ancel"), 1)) f.dialog.Add(label) f.dialog.Add(f.name) f.dialog.Add(f.list) f.dialog.Add(ok) f.dialog.Add(cancel) } // showSelection puts the highlighted entry's name into the field, so the field // always says what OK is going to act on. // // The parent entry is the exception: ".." is not a name anyone would type into // a field labelled "Name:", so it clears the field instead and lets confirm // fall back to the highlight. // // This runs on every change of the highlight, including the one SetItems makes // when the list is refilled — which is why entering a directory clears the // field rather than leaving the name that was in it. func (f *FileDialog) showSelection(index int) { if index < 0 || index >= len(f.entries) { return } entry := f.entries[index] if entry == parentEntry { f.name.SetText("") return } f.name.SetText(strings.TrimSuffix(entry, string(filepath.Separator))) } // confirm is what the OK button does: act on the name in the field, or, when // the field is empty, on whatever the list has highlighted. // // A name that turns out to be a directory browses into it rather than closing, // which is what a double click on it would do. // // The fallback matters more than it looks. Highlighting a file in the list is // how people pick one, and before the field was wired to the highlight, OK on // a freshly opened dialog did nothing whatsoever — the field was empty, so // there was no path to act on and the button appeared broken. func (f *FileDialog) confirm() { path := f.Path() if path == "" { f.choose() return } if info, err := os.Stat(path); err == nil && info.IsDir() { f.enter(path) return } f.dialog.Close(ui.ResultOK) } // choose acts on the highlighted list entry: browse into a directory, or put a // file's name into the field. func (f *FileDialog) choose() { index := f.list.Selected() if index < 0 || index >= len(f.entries) { return } entry := f.entries[index] target := filepath.Join(f.directory, strings.TrimSuffix(entry, string(filepath.Separator))) if strings.HasSuffix(entry, string(filepath.Separator)) { f.enter(target) return } f.name.SetText(entry) f.dialog.Close(ui.ResultOK) } // enter browses into a directory. func (f *FileDialog) enter(directory string) { f.directory = filepath.Clean(directory) f.name.SetText("") f.readDirectory() } // readDirectory refills the list from the current directory. A directory that // cannot be read shows as empty rather than as an error: the user can still // type a path by hand. func (f *FileDialog) readDirectory() { f.dialog.SetTitle(f.title + " — " + shortenPath(f.directory, fileDialogWidth-len(f.title)-8)) entries, err := os.ReadDir(f.directory) if err != nil { f.entries = []string{parentEntry} f.list.SetItems(f.entries) return } f.entries = directoryEntries(entries) f.list.SetItems(f.entries) } // directoryEntries returns the names to list: the parent, then the // directories, then the files, each group sorted. func directoryEntries(entries []os.DirEntry) []string { var directories, files []string for _, entry := range entries { if strings.HasPrefix(entry.Name(), ".") { continue // hidden files would bury the source in dot-directories } if entry.IsDir() { directories = append(directories, entry.Name()+string(filepath.Separator)) } else { files = append(files, entry.Name()) } } sort.Strings(directories) sort.Strings(files) return append(append([]string{parentEntry}, directories...), files...) } // NewOutputDialog builds a box showing a command's output, with a single Close // button. // // The body is a ListBox, so scrolling with the arrows, Page Up and Down, Home, // End and the wheel all come for nothing — a command's output is exactly the // kind of thing that does not fit, and a message dialog cannot scroll. // // It is sized to the screen rather than to the text, because the text grows // while the command runs and a dialog that resized under the reader would be // unusable. // // dialog, list := NewOutputDialog("go test ./... — running", nil, a.screenRect()) func NewOutputDialog(title string, lines []string, screen ui.Rect) (*ui.Dialog, *ui.ListBox) { width := max(min(screen.W-8, outputDialogMaxWidth), outputDialogMinWidth) height := max(min(screen.H-6, outputDialogMaxHeight), outputDialogMinHeight) dialog := ui.NewDialog(title) dialog.SetBounds(ui.Rect{W: width, H: height}.CenteredIn(screen)) list := ui.NewListBox(lines) list.SetBounds(dialog.Layout(3, 2, width-6, height-6)) close := ui.NewButton("~C~lose", func() { dialog.Close(ui.ResultOK) }) close.Default = true close.SetBounds(dialog.Layout((width-ui.ButtonWidth("~C~lose"))/2, height-3, ui.ButtonWidth("~C~lose"), 1)) dialog.Add(list) dialog.Add(close) return dialog, list } // The size an output dialog is held between: big enough for a stack trace to // be worth reading, small enough that the editor is still visible behind it. const ( outputDialogMinWidth = 40 outputDialogMaxWidth = 100 outputDialogMinHeight = 8 outputDialogMaxHeight = 24 ) // NewMessageDialog builds a box showing a message with a single OK button. func NewMessageDialog(title, message string, screen ui.Rect) *ui.Dialog { lines := strings.Split(message, "\n") height := len(lines) + 6 dialog := ui.NewDialog(title) dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen)) for i, line := range lines { label := ui.NewLabel(line) label.SetBounds(dialog.Layout(3, 2+i, smallDialogWidth-6, 1)) dialog.Add(label) } ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) }) ok.Default = true ok.SetBounds(dialog.Layout((smallDialogWidth-ui.ButtonWidth("~O~K"))/2, height-3, ui.ButtonWidth("~O~K"), 1)) dialog.Add(ok) return dialog } // NewConfirmDialog builds a Yes / No / Cancel question, which is what an // unsaved file gets asked on the way out. func NewConfirmDialog(title, message string, screen ui.Rect) *ui.Dialog { const height = 8 dialog := ui.NewDialog(title) dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen)) label := ui.NewLabel(message) label.SetBounds(dialog.Layout(3, 2, smallDialogWidth-6, 1)) dialog.Add(label) yes := ui.NewButton("~Y~es", func() { dialog.Close(ui.ResultOK) }) yes.Default = true yes.SetBounds(dialog.Layout(6, height-3, ui.ButtonWidth("~Y~es"), 1)) no := ui.NewButton("~N~o", func() { dialog.Close(ui.ResultNo) }) no.SetBounds(dialog.Layout(18, height-3, ui.ButtonWidth("~N~o"), 1)) cancel := ui.NewButton("~C~ancel", func() { dialog.Close(ui.ResultCancel) }) cancel.SetBounds(dialog.Layout(29, height-3, ui.ButtonWidth("~C~ancel"), 1)) dialog.Add(yes) dialog.Add(no) dialog.Add(cancel) return dialog } // NewPromptDialog builds a box asking for one line of text. func NewPromptDialog(title, label, initial string, screen ui.Rect) (*ui.Dialog, *ui.InputLine) { const height = 8 dialog := ui.NewDialog(title) dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen)) caption := ui.NewLabel(label) caption.SetBounds(dialog.Layout(3, 2, smallDialogWidth-6, 1)) field := ui.NewInputLine(initial) field.SetBounds(dialog.Layout(3, 3, smallDialogWidth-6, 1)) ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) }) ok.Default = true ok.SetBounds(dialog.Layout(smallDialogWidth-26, height-3, ui.ButtonWidth("~O~K"), 1)) cancel := ui.NewButton("~C~ancel", func() { dialog.Close(ui.ResultCancel) }) cancel.SetBounds(dialog.Layout(smallDialogWidth-16, height-3, ui.ButtonWidth("~C~ancel"), 1)) dialog.Add(caption) dialog.Add(field) dialog.Add(ok) dialog.Add(cancel) return dialog, field } // FindDialog is the Search box: what to look for, and how. type FindDialog struct { dialog *ui.Dialog needle *ui.InputLine matchCase *ui.CheckBox } // Dialog returns the modal dialog to show. func (f *FindDialog) Dialog() *ui.Dialog { return f.dialog } // Needle returns the text to search for. func (f *FindDialog) Needle() string { return f.needle.Text() } // MatchCase reports whether the search should respect case. func (f *FindDialog) MatchCase() bool { return f.matchCase.Checked() } // NewFindDialog builds the Search box. func NewFindDialog(initial string, matchCase bool, screen ui.Rect) *FindDialog { const height = 10 dialog := ui.NewDialog("Find") dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen)) f := &FindDialog{ dialog: dialog, needle: ui.NewInputLine(initial), matchCase: ui.NewCheckBox("~C~ase sensitive", matchCase), } caption := ui.NewLabel("Text to find:") caption.SetBounds(dialog.Layout(3, 2, smallDialogWidth-6, 1)) f.needle.SetBounds(dialog.Layout(3, 3, smallDialogWidth-6, 1)) f.matchCase.SetBounds(dialog.Layout(3, 5, smallDialogWidth-6, 1)) ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) }) ok.Default = true ok.SetBounds(dialog.Layout(smallDialogWidth-26, height-3, ui.ButtonWidth("~O~K"), 1)) cancel := ui.NewButton("Ca~n~cel", func() { dialog.Close(ui.ResultCancel) }) cancel.SetBounds(dialog.Layout(smallDialogWidth-16, height-3, ui.ButtonWidth("Ca~n~cel"), 1)) dialog.Add(caption) dialog.Add(f.needle) dialog.Add(f.matchCase) dialog.Add(ok) dialog.Add(cancel) return f } // NewChoiceDialog builds a box offering a list to pick from, which is how a // theme and a window are chosen. func NewChoiceDialog(title string, items []string, selected int, screen ui.Rect) (*ui.Dialog, *ui.ListBox) { const width = 44 height := min(len(items), 12) + 6 dialog := ui.NewDialog(title) dialog.SetBounds(ui.Rect{W: width, H: height}.CenteredIn(screen)) list := ui.NewListBox(items) list.SetBounds(dialog.Layout(3, 2, width-6, height-6)) list.Select(selected) list.OnChoose = func(int) { dialog.Close(ui.ResultOK) } ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) }) ok.Default = true ok.SetBounds(dialog.Layout(width-26, height-3, ui.ButtonWidth("~O~K"), 1)) cancel := ui.NewButton("~C~ancel", func() { dialog.Close(ui.ResultCancel) }) cancel.SetBounds(dialog.Layout(width-16, height-3, ui.ButtonWidth("~C~ancel"), 1)) dialog.Add(list) dialog.Add(ok) dialog.Add(cancel) return dialog, list } // shortenPath trims a directory from the left so it fits in width cells, since // a title bar has far less room than a path can need. func shortenPath(path string, width int) string { runes := []rune(path) if width < 4 || len(runes) <= width { return path } return "…" + string(runes[len(runes)-width+1:]) } // ParametersDialog asks for the values a tool's command needs before it runs. // // One field per placeholder, in the order they appear in the command, with // whatever was typed last time already in them. type ParametersDialog struct { dialog *ui.Dialog labels []string fields []*ui.InputLine } // parameterChrome is the rows a parameters dialog spends on everything that is // not a label-and-field pair: the frame, the gaps, and the buttons. const parameterChrome = 6 // MaxParameterFields returns how many values a dialog can ask for on a screen // of the given height. // // It is exported because the caller has to know before it opens one: a tool // asking for more than fit would give a dialog whose OK button is off the // bottom of the screen, which is worse than being told. // // if len(placeholders) > app.MaxParameterFields(screen.H) { // // say so instead of opening it // } func MaxParameterFields(screenHeight int) int { return max((screenHeight-parameterChrome)/2, 1) } // NewParametersDialog builds the box. // // initial holds what to put in each field, by label; a label with nothing // remembered starts empty. // // dialog := app.NewParametersDialog("Init module", []string{"module path"}, nil, screen) func NewParametersDialog(title string, labels []string, initial map[string]string, screen ui.Rect) *ParametersDialog { height := len(labels)*2 + parameterChrome dialog := ui.NewDialog(title) dialog.SetBounds(ui.Rect{W: smallDialogWidth, H: height}.CenteredIn(screen)) p := &ParametersDialog{dialog: dialog, labels: labels} for i, label := range labels { caption := ui.NewLabel(label + ":") caption.SetBounds(dialog.Layout(3, 2+i*2, smallDialogWidth-6, 1)) field := ui.NewInputLine(initial[label]) field.SetBounds(dialog.Layout(3, 3+i*2, smallDialogWidth-6, 1)) dialog.Add(caption) dialog.Add(field) p.fields = append(p.fields, field) } ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) }) ok.Default = true ok.SetBounds(dialog.Layout(smallDialogWidth-26, height-3, ui.ButtonWidth("~O~K"), 1)) cancel := ui.NewButton("~C~ancel", func() { dialog.Close(ui.ResultCancel) }) cancel.SetBounds(dialog.Layout(smallDialogWidth-16, height-3, ui.ButtonWidth("~C~ancel"), 1)) dialog.Add(ok) dialog.Add(cancel) return p } // Dialog returns the modal dialog to show. func (p *ParametersDialog) Dialog() *ui.Dialog { return p.dialog } // Values returns what was typed, by label. func (p *ParametersDialog) Values() map[string]string { values := make(map[string]string, len(p.fields)) for i, field := range p.fields { values[p.labels[i]] = field.Text() } return values }