Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s
354 lines
10 KiB
Go
354 lines
10 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/lixenwraith/logwisp/internal/compose"
|
|
"github.com/lixenwraith/logwisp/internal/config"
|
|
|
|
"github.com/lixenwraith/terminal"
|
|
ui "github.com/lixenwraith/terminal/tui"
|
|
)
|
|
|
|
// dialog is a box over the screen that takes every event while open
|
|
type dialog interface {
|
|
draw(a *app, r ui.Region)
|
|
handle(a *app, ev terminal.Event)
|
|
}
|
|
|
|
// frame draws a dialog's box, w by h at most, centered in r with its title,
|
|
// and returns the inside
|
|
func (a *app) frame(r ui.Region, w, h int, title string) ui.Region {
|
|
b := ui.Center(r, min(w, r.W), min(h, r.H))
|
|
b.FillStyle(a.th.Text)
|
|
wire := a.font.wire
|
|
across := strings.Repeat(string(wire[10]), max(0, b.W-2))
|
|
b.TextStyled(0, 0, string(wire[6])+across+string(wire[12]), a.th.Border)
|
|
b.TextStyled(0, b.H-1, string(wire[3])+across+string(wire[9]), a.th.Border)
|
|
for y := 1; y < b.H-1; y++ {
|
|
b.TextStyled(0, y, string(wire[5]), a.th.Border)
|
|
b.TextStyled(b.W-1, y, string(wire[5]), a.th.Border)
|
|
}
|
|
b.TextStyled(2, 0, " "+ui.Truncate(title, b.W-6)+" ", a.th.Accent)
|
|
return b.Sub(2, 1, b.W-4, b.H-2)
|
|
}
|
|
|
|
// chooser picks one option from a filtered list
|
|
type chooser struct {
|
|
title string
|
|
list *ui.OptionListState
|
|
pick func(a *app, i int) // the option chosen, by its index in the list
|
|
back func(a *app) // on Esc, after the chooser closes
|
|
paste func(a *app, text string) // a paste, in place of filtering; nil filters
|
|
}
|
|
|
|
func (c *chooser) draw(a *app, r ui.Region) {
|
|
in := a.frame(r, 76, len(c.list.Options)+4, c.title)
|
|
in.OptionList(c.list, a.th)
|
|
}
|
|
|
|
func (c *chooser) handle(a *app, ev terminal.Event) {
|
|
switch {
|
|
case ev.Type == terminal.EventPaste && c.paste != nil:
|
|
c.paste(a, ev.Text)
|
|
case ev.Type == terminal.EventPaste:
|
|
c.list.Paste(ev.Text)
|
|
case ev.Type != terminal.EventKey:
|
|
case is(ev, terminal.KeyEscape, ""):
|
|
a.dialog = nil
|
|
if c.back != nil {
|
|
c.back(a)
|
|
}
|
|
case is(ev, terminal.KeyEnter, ""):
|
|
if i, ok := c.list.Chosen(); ok {
|
|
a.dialog = nil
|
|
c.pick(a, i)
|
|
}
|
|
default:
|
|
c.list.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
|
|
}
|
|
}
|
|
|
|
// filterHints describe the filter types the flow column adds
|
|
var filterHints = map[string]string{"include": "pass only entries that match", "exclude": "drop entries that match"}
|
|
|
|
// addMenu adds to the chosen column: a source or sink from the catalogue, or
|
|
// a filter, which opens it in the inspector
|
|
func (a *app) addMenu() dialog {
|
|
role := columns[a.col].role
|
|
var options []ui.Option
|
|
if role == "flow" {
|
|
role = "filters"
|
|
for _, typ := range []string{"include", "exclude"} {
|
|
options = append(options, ui.Option{Name: typ, Hint: filterHints[typ]})
|
|
}
|
|
}
|
|
for _, p := range config.Plugins() {
|
|
if p.Role == role {
|
|
options = append(options, ui.Option{Name: p.Type, Hint: p.Summary})
|
|
}
|
|
}
|
|
return &chooser{title: "Add a " + strings.TrimSuffix(role, "s"), list: ui.NewOptionListState(options),
|
|
pick: func(a *app, i int) {
|
|
n, err := a.comp.Add(a.pi, role, options[i].Name)
|
|
if !a.edit(err) {
|
|
return
|
|
}
|
|
a.choose(n)
|
|
a.inspect, a.insp.cursor = true, 0
|
|
for j, l := range a.lines() {
|
|
if l.group != "" && l.node == n {
|
|
a.insp.cursor = j
|
|
}
|
|
}
|
|
}}
|
|
}
|
|
|
|
// presetMenu starts the pipeline over from a preset, or the pipelines from a
|
|
// pasted command line; on a bare start, Esc starts empty
|
|
func (a *app) presetMenu() dialog {
|
|
presets := config.Presets()
|
|
var options []ui.Option
|
|
for _, p := range presets {
|
|
options = append(options, ui.Option{Name: p.Name, Hint: p.Summary})
|
|
}
|
|
options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"})
|
|
title := "Start from a preset, or paste an lw command line"
|
|
if len(a.comp.Pipelines) > 0 {
|
|
title = "Replace pipeline " + a.pipeline().Name + " with a preset"
|
|
}
|
|
empty := func(a *app) {
|
|
a.use(config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}})
|
|
}
|
|
menu := &chooser{title: title, list: ui.NewOptionListState(options)}
|
|
menu.paste = func(a *app, text string) {
|
|
a.dialog = nil
|
|
if a.paste(text); len(a.comp.Pipelines) == 0 {
|
|
a.dialog = menu // refused: the status says why
|
|
}
|
|
}
|
|
menu.pick = func(a *app, i int) {
|
|
switch {
|
|
case i == len(presets):
|
|
empty(a)
|
|
case len(presets[i].Params) == 0:
|
|
(&presetForm{preset: presets[i]}).apply(a)
|
|
default:
|
|
a.dialog = newPresetForm(presets[i])
|
|
}
|
|
}
|
|
menu.back = func(a *app) {
|
|
if len(a.comp.Pipelines) == 0 {
|
|
empty(a)
|
|
}
|
|
}
|
|
return menu
|
|
}
|
|
|
|
// use puts a pipeline in place of the one shown, under its name, asking
|
|
// first when that one has sources or sinks; the first pipeline keeps its own,
|
|
// and its parts are work quit asks about
|
|
func (a *app) use(p config.PipelineConfig) {
|
|
if len(a.comp.Pipelines) == 0 {
|
|
a.comp.Pipelines, a.changed = []config.PipelineConfig{p}, len(p.PluginSources)+len(p.PluginSinks) > 0
|
|
a.check()
|
|
return
|
|
}
|
|
old := a.pipeline()
|
|
p.Name = old.Name
|
|
replace := func(a *app) {
|
|
a.comp.Pipelines[a.pi], a.row, a.inspect, a.changed = p, [3]int{}, false, true
|
|
a.check()
|
|
}
|
|
if len(old.PluginSources)+len(old.PluginSinks) == 0 {
|
|
replace(a)
|
|
return
|
|
}
|
|
a.dialog = &confirm{question: "Replace pipeline " + old.Name + "?", yes: replace}
|
|
}
|
|
|
|
// presetForm takes a preset's keys, its defaults as placeholders
|
|
type presetForm struct {
|
|
preset config.Preset
|
|
fields []*ui.TextFieldState
|
|
focus ui.Focus
|
|
scroll int // the first field shown
|
|
err string
|
|
}
|
|
|
|
func newPresetForm(p config.Preset) *presetForm {
|
|
f := &presetForm{preset: p, focus: ui.Focus{Len: len(p.Params)}}
|
|
for range p.Params {
|
|
f.fields = append(f.fields, ui.NewTextFieldState(""))
|
|
}
|
|
return f
|
|
}
|
|
|
|
func (f *presetForm) draw(a *app, r ui.Region) {
|
|
th := a.th
|
|
in := a.frame(r, 76, len(f.fields)+6, "preset "+f.preset.Name)
|
|
in.TextStyled(0, 0, ui.Truncate(f.preset.Summary, in.W), th.Muted)
|
|
rows := max(1, in.H-3) // between the summary and the note
|
|
f.scroll = max(0, min(f.scroll, f.focus.Index, len(f.fields)-rows), f.focus.Index-rows+1)
|
|
for i := f.scroll; i < len(f.fields) && i-f.scroll < rows; i++ {
|
|
p := f.preset.Params[i]
|
|
v, _ := in.Field(2+i-f.scroll, 14, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th)
|
|
v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th)
|
|
}
|
|
note, style := f.preset.Params[f.focus.Index].Help, th.Muted
|
|
if f.err != "" {
|
|
note, style = f.err, th.Error
|
|
}
|
|
in.TextStyled(0, in.H-1, ui.Truncate(note, in.W), style)
|
|
}
|
|
|
|
func (f *presetForm) handle(a *app, ev terminal.Event) {
|
|
field := f.fields[f.focus.Index]
|
|
switch {
|
|
case ev.Type == terminal.EventPaste:
|
|
field.Paste(ev.Text)
|
|
case ev.Type != terminal.EventKey:
|
|
case is(ev, terminal.KeyEscape, ""):
|
|
a.dialog = a.presetMenu()
|
|
case is(ev, terminal.KeyEnter, ""):
|
|
f.apply(a)
|
|
case is(ev, terminal.KeyUp, ""):
|
|
f.focus.Index = max(0, f.focus.Index-1)
|
|
case is(ev, terminal.KeyDown, ""):
|
|
f.focus.Index = min(f.focus.Len-1, f.focus.Index+1)
|
|
case f.focus.HandleKey(ev.Key, ev.Modifiers):
|
|
default:
|
|
field.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
|
|
}
|
|
}
|
|
|
|
// apply builds the preset's pipeline, on this host: a path is a directory
|
|
// when one is there
|
|
func (f *presetForm) apply(a *app) {
|
|
values := map[string]string{}
|
|
for i, p := range f.preset.Params {
|
|
if v := f.fields[i].Value(); v != "" {
|
|
values[p.Name] = v
|
|
}
|
|
}
|
|
c, err := compose.FromPreset(f.preset.Name, values, config.HostIsDir)
|
|
if err != nil {
|
|
f.err = err.Error()
|
|
return
|
|
}
|
|
a.dialog = nil
|
|
a.use(c.Pipelines[0])
|
|
}
|
|
|
|
// outputMenu is what o offers: run, or print one of the forms
|
|
func (a *app) outputMenu() dialog {
|
|
forms := compose.Forms()
|
|
options := []ui.Option{{Name: "run", Hint: "start the pipelines now, with lw's other settings"}}
|
|
for _, f := range forms {
|
|
options = append(options, ui.Option{Name: f.Name, Hint: "print " + f.Hint})
|
|
}
|
|
return &chooser{title: "Output, once the screen closes", list: ui.NewOptionListState(options),
|
|
pick: func(a *app, i int) {
|
|
if i == 0 {
|
|
if a.problem != nil {
|
|
a.status = "not valid yet: c shows the problem"
|
|
return
|
|
}
|
|
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
|
|
return
|
|
}
|
|
text, err := forms[i-1].Write(a.comp)
|
|
if err != nil {
|
|
a.status = err.Error()
|
|
return
|
|
}
|
|
a.result, a.done = Result{Exit: Print, Output: text}, true
|
|
}}
|
|
}
|
|
|
|
// notice shows lines until any key
|
|
type notice struct {
|
|
title string
|
|
lines []string
|
|
}
|
|
|
|
func (n *notice) draw(a *app, r ui.Region) {
|
|
w, lines := ui.RuneLen(n.title)+8, fit(n.lines, r.W)
|
|
for _, l := range lines {
|
|
w = max(w, ui.RuneLen(l)+6)
|
|
}
|
|
in := a.frame(r, w, len(lines)+4, n.title)
|
|
for i, l := range lines {
|
|
in.TextStyled(0, 1+i, l, a.th.Text)
|
|
}
|
|
}
|
|
|
|
// fit wraps lines to a dialog's inside on a screen w wide
|
|
func fit(lines []string, w int) []string {
|
|
var out []string
|
|
for _, l := range lines {
|
|
out = append(out, ui.WrapText(l, max(1, w-6))...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (n *notice) handle(a *app, ev terminal.Event) {
|
|
if ev.Type == terminal.EventKey {
|
|
a.dialog = nil
|
|
}
|
|
}
|
|
|
|
// confirm asks a yes or no question
|
|
type confirm struct {
|
|
question string
|
|
yes func(a *app)
|
|
}
|
|
|
|
func (c *confirm) draw(a *app, r ui.Region) {
|
|
lines := fit([]string{c.question}, r.W)
|
|
w := 0
|
|
for _, l := range lines {
|
|
w = max(w, ui.RuneLen(l)+6)
|
|
}
|
|
in := a.frame(r, w, len(lines)+5, "Confirm")
|
|
for i, l := range lines {
|
|
in.TextStyled(0, 1+i, l, a.th.Text)
|
|
}
|
|
in.TextStyled(0, 1+len(lines), "y yes n no", a.th.Muted)
|
|
}
|
|
|
|
func (c *confirm) handle(a *app, ev terminal.Event) {
|
|
switch {
|
|
case is(ev, terminal.KeyNone, "yY"):
|
|
a.dialog = nil
|
|
c.yes(a)
|
|
case is(ev, terminal.KeyEscape, "nNq"):
|
|
a.dialog = nil
|
|
}
|
|
}
|
|
|
|
// help lists the keys
|
|
func help(f font) dialog {
|
|
keys := [][2]string{
|
|
{"arrows, hjkl", "move"},
|
|
{f.enter, "inspect the chosen part; edit a value"},
|
|
{"tab", "next value"},
|
|
{"esc", "back"},
|
|
{"a", "add a source, sink or filter"},
|
|
{"d", "delete it; a value back to its default"},
|
|
{f.space, "a flow stage or a switch on or off"},
|
|
{"J K", "move a filter down or up"},
|
|
{"[ ]", "previous or next pipeline"},
|
|
{"p", "start over from a preset"},
|
|
{"o", "run, or print a command line, environment or file"},
|
|
{"c", "the first problem"},
|
|
{"paste", "an lw command line replaces the pipelines"},
|
|
{"q", "quit"},
|
|
}
|
|
n := ¬ice{title: "Keys"}
|
|
for _, k := range keys {
|
|
n.lines = append(n.lines, ui.PadRight(k[0], 14)+k[1])
|
|
}
|
|
return n
|
|
}
|