v0.31.0 refinements

This commit is contained in:
lixen committed 2026-10-10 12:01:54 -04:00
1 parent bfcc15613a
commit 6bd5e12a60
52 files changed
+1948 -683

No files matched your search

+255 -128
View File
@@ -1,6 +1,7 @@
package tui
import (
"fmt"
"strings"
"github.com/lixenwraith/logwisp/internal/compose"
@@ -16,23 +17,19 @@ type dialog interface {
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
// frame draws a dialog's box around an inside w by h, centered in r and
// clipped to it, with its title, and returns the inside; sized to what it
// holds, it ends on its last line
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)
return ui.Center(r, min(w+4, r.W), min(h+2, r.H)).Frame(title, a.th)
}
// inside is a dialog's widest inside on a screen w wide; one narrower than
// stacked puts labels and keys above what they name
func inside(w int) int { return max(1, min(72, w-4)) }
const stacked = 32
// chooser picks one option from a filtered list
type chooser struct {
title string
@@ -43,8 +40,8 @@ type chooser struct {
}
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)
w := inside(r.W)
a.frame(r, w, c.list.Rows(w), c.title).OptionList(c.list, a.th)
}
func (c *chooser) handle(a *app, ev terminal.Event) {
@@ -64,6 +61,11 @@ func (c *chooser) handle(a *app, ev terminal.Event) {
a.dialog = nil
c.pick(a, i)
}
case ev.Key == terminal.KeyRune && c.list.Menu:
if i, ok := c.list.Pick(ev.Rune); ok {
a.dialog = nil
c.pick(a, i)
}
default:
c.list.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
}
@@ -95,56 +97,104 @@ func (a *app) addMenu() dialog {
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
}
}
a.inspectFirst(n)
}}
}
// 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()
// typeMenu changes a source's or sink's type, in its place
func (a *app) typeMenu(n compose.Node) dialog {
p, _ := a.plugin(n)
var options []ui.Option
for _, p := range presets {
options = append(options, ui.Option{Name: p.Name, Hint: p.Summary})
for _, q := range config.Plugins() {
if q.Role == n.Role && q.Type != p.Type {
options = append(options, ui.Option{Name: q.Type, Hint: q.Summary})
}
}
options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"})
return &chooser{title: "Change " + p.Type, list: ui.NewOptionListState(options), pick: func(a *app, i int) {
m, err := a.comp.Retype(a.pi, n, options[i].Name)
if a.edit(err) {
a.choose(m)
a.inspectFirst(m)
}
}}
}
// enumMenu sets an enum key to the option chosen, the cursor on its value
func (a *app) enumMenu(l line, cur any) dialog {
var options []ui.Option
for _, e := range l.key.Enum {
hint := ""
if e == l.key.Default {
hint = "the default"
}
options = append(options, ui.Option{Name: e, Hint: hint})
}
list := ui.NewOptionListState(options)
list.Cursor = max(0, choice(l.key, cur))
return &chooser{title: label(l), list: list, pick: func(a *app, i int) {
a.edit(a.comp.Set(a.pi, l.node, l.key.Name, options[i].Name))
}}
}
// presetMenu replaces the pipeline shown from a preset, or the pipelines
// from a pasted command line; on a bare start, Esc starts empty
func (a *app) presetMenu() dialog {
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 := a.pipelineMenu(title, (*app).use)
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)
a.use(empty())
}
}
return menu
}
// pipelineMenu offers the presets and an empty pipeline, which place puts
// on screen
func (a *app) pipelineMenu(title string, place func(a *app, p config.PipelineConfig)) *chooser {
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"})
menu := &chooser{title: title, list: ui.NewOptionListState(options)}
menu.pick = func(a *app, i int) {
if i == len(presets) {
place(a, empty())
return
}
p := presets[i]
f := &form{title: "preset " + p.Name, summary: p.Summary, params: p.Params, back: func(a *app) { a.dialog = menu },
apply: func(a *app, values map[string]string) error {
c, err := compose.FromPreset(p.Name, values, config.HostIsDir)
if err == nil {
place(a, c.Pipelines[0])
}
return err
}}
if f.fill(nil); len(p.Params) == 0 {
f.submit(a)
return
}
a.dialog = f
}
return menu
}
func empty() config.PipelineConfig {
return config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}}
}
// 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
@@ -167,51 +217,102 @@ func (a *app) use(p config.PipelineConfig) {
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
// append adds a pipeline after the others, under its name or the first free
// one after it, and shows it
func (a *app) append(p config.PipelineConfig) {
var names []string
for _, q := range a.comp.Pipelines {
names = append(names, q.Name)
}
p.Name = config.FreeID(p.Name, names)
a.comp.Pipelines = append(a.comp.Pipelines, p)
a.pi, a.row, a.bar, a.changed = len(a.comp.Pipelines)-1, [3]int{}, false, true
a.check()
}
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
// form takes a few keys, their defaults as placeholders, then applies them;
// a refusal stays in the form
type form struct {
title, summary string
params []config.PresetParam
fields []*ui.TextFieldState
focus ui.Focus
scroll ui.ViewportScroll // the fields' rows
err string
apply func(a *app, values map[string]string) error
back func(a *app) // on Esc, after the form closes
}
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)
// fill gives the fields their first values
func (f *form) fill(values map[string]string) {
f.focus, f.fields = ui.Focus{Len: len(f.params)}, nil
for _, p := range f.params {
f.fields = append(f.fields, ui.NewTextFieldState(values[p.Name]))
}
note, style := f.preset.Params[f.focus.Index].Help, th.Muted
}
// draw lays the form out at the screen's width: the summary, the fields,
// their labels beside them or, when stacked, above, then the focused
// field's help or the refusal, in rows kept for the longest of them
func (f *form) draw(a *app, r ui.Region) {
th, w := a.th, inside(r.W)
labelW, tall := 0, 2
if w >= stacked {
for _, p := range f.params {
labelW = max(labelW, ui.RuneLen(p.Name)+2)
}
tall = 1
}
summary, noteRows := wrap(f.summary, w, 2), len(wrap(f.err, w, 3))
for _, p := range f.params {
noteRows = max(noteRows, len(wrap(p.Help, w, 3)))
}
top, bottom := len(summary)+min(1, len(summary)), noteRows+min(1, noteRows)
in := a.frame(r, w, top+len(f.fields)*tall+bottom, f.title)
for i, l := range summary {
in.TextStyled(0, i, l, th.Muted)
}
view := in.Sub(0, top, in.W, max(1, in.H-top-bottom))
f.scroll.SetDimensions(len(f.fields)*tall, view.H)
f.scroll.EnsureRange(f.focus.Index*tall, tall)
view.Window(len(f.fields)*tall, &f.scroll, th.Text, func(all ui.Region) {
for i, p := range f.params {
v, _ := all.Field(i*tall, labelW, 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.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)
lines := wrap(note, in.W, noteRows) // on the last rows, the box ending on text
for i, l := range lines {
in.TextStyled(0, in.H-len(lines)+i, l, style)
}
}
func (f *presetForm) handle(a *app, ev terminal.Event) {
// wrap is s wrapped to w, at most n lines, the last cut with '…'; none when
// s is empty
func wrap(s string, w, n int) []string {
if s == "" {
return nil
}
return clip(ui.WrapText(s, w), n)
}
func (f *form) 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()
a.dialog = nil
if f.back != nil {
f.back(a)
}
case is(ev, terminal.KeyEnter, ""):
f.apply(a)
f.submit(a)
case is(ev, terminal.KeyUp, ""):
f.focus.Index = max(0, f.focus.Index-1)
case is(ev, terminal.KeyDown, ""):
@@ -222,39 +323,33 @@ func (f *presetForm) handle(a *app, ev terminal.Event) {
}
}
// apply builds the preset's pipeline, on this host: a path is a directory
// when one is there
func (f *presetForm) apply(a *app) {
// submit applies the values typed, closing the form unless they are refused
func (f *form) submit(a *app) {
values := map[string]string{}
for i, p := range f.preset.Params {
for i, p := range f.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])
if err := f.apply(a, values); err != nil {
f.err, a.dialog = err.Error(), f
}
}
// 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"}}
options := []ui.Option{{Name: "run", Key: 'r', 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})
options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint})
}
return &chooser{title: "Output, once the screen closes", list: ui.NewOptionListState(options),
list := ui.NewOptionListState(options)
list.Menu = true
return &chooser{title: "Output, once the screen closes", list: list,
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
a.run()
return
}
text, err := forms[i-1].Write(a.comp)
@@ -266,88 +361,120 @@ func (a *app) outputMenu() dialog {
}}
}
// notice shows lines until any key
// notice shows text, wrapped to the screen, until a key other than one
// that scrolls it
type notice struct {
title string
lines []string
body func(w int) []string // the lines at a width
view ui.ViewportScroll
}
// textNotice is a notice of plain text, its paragraphs split at '\n'
func textNotice(title, body string) *notice {
return &notice{title: title, body: func(w int) []string { return ui.WrapText(body, w) }}
}
func (n *notice) draw(a *app, r ui.Region) {
w, lines := ui.RuneLen(n.title)+8, fit(n.lines, r.W)
lines, w := n.body(inside(r.W)), ui.RuneLen(n.title)+2
for _, l := range lines {
w = max(w, ui.RuneLen(l)+6)
w = max(w, ui.RuneLen(l))
}
in := a.frame(r, w, len(lines)+4, n.title)
for i, l := range lines {
in.TextStyled(0, 1+i, l, a.th.Text)
in := a.frame(r, min(w, inside(r.W)), len(lines), n.title)
rows := in.H
if len(lines) > in.H { // the last row says where the view is
rows = max(1, in.H-1)
}
}
// 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))...)
n.view.SetDimensions(len(lines), rows)
for y := range min(rows, len(lines)-n.view.Offset) {
in.TextStyled(0, y, lines[n.view.Offset+y], a.th.Text)
}
if rows < in.H {
where := fmt.Sprintf("j k: %d-%d of %d", n.view.Offset+1, n.view.Offset+rows, len(lines))
in.TextStyled(0, rows, ui.Truncate(where, in.W), a.th.Muted)
}
return out
}
func (n *notice) handle(a *app, ev terminal.Event) {
if ev.Type == terminal.EventKey {
switch {
case ev.Type != terminal.EventKey:
case is(ev, terminal.KeyUp, "k"):
n.view.ScrollBy(-1)
case is(ev, terminal.KeyDown, "j"):
n.view.ScrollBy(1)
case is(ev, terminal.KeyPageUp, ""):
n.view.PageUp()
case is(ev, terminal.KeyPageDown, ""):
n.view.PageDown()
default:
a.dialog = nil
}
}
// confirm asks a yes or no question
// confirm asks a yes or no question: y or n answers, as does Enter on the
// answer the arrows chose; Esc is no
type confirm struct {
question string
yes func(a *app)
state ui.ConfirmState
}
func (c *confirm) draw(a *app, r ui.Region) {
lines := fit([]string{c.question}, r.W)
w := 0
lines := ui.WrapText(c.question, inside(r.W))
w := 12 // the answers
for _, l := range lines {
w = max(w, ui.RuneLen(l)+6)
w = max(w, ui.RuneLen(l))
}
in := a.frame(r, w, len(lines)+5, "Confirm")
in := a.frame(r, w, len(lines)+1, "Confirm")
for i, l := range lines {
in.TextStyled(0, 1+i, l, a.th.Text)
in.TextStyled(0, i, l, a.th.Text)
}
in.TextStyled(0, 1+len(lines), "y yes n no", a.th.Muted)
in.Radio(0, in.H-1, []string{"yes", "no"}, map[bool]int{true: 0, false: 1}[c.state.FocusYes], a.th)
}
func (c *confirm) handle(a *app, ev terminal.Event) {
switch {
case is(ev, terminal.KeyNone, "yY"):
a.dialog = nil
if ev.Type != terminal.EventKey || !c.state.HandleKey(ev.Key, ev.Rune) {
return
}
if a.dialog = nil; c.state.Result == ui.ConfirmYes {
c.yes(a)
case is(ev, terminal.KeyEscape, "nNq"):
a.dialog = nil
}
}
// help lists the keys
// help lists the keys, each beside its description or, narrow, above it
func help(f font) dialog {
keys := [][2]string{
{"arrows, hjkl", "move"},
{f.enter, "inspect the chosen part; edit a value"},
{"arrows, hjkl", "move; up from the top row to the pipeline bar"},
{f.enter, "inspect a part; edit a value; open a list; add"},
{"tab", "next value"},
{"esc", "back"},
{"a", "add a source, sink or filter"},
{"a", "add a source, sink or filter; on the bar, a pipeline"},
{"c", "change a source's or sink's type"},
{"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"},
{"r", "run the pipelines"},
{"o", "run, or print a command line, environment or file"},
{"c", "the first problem"},
{"!", "the first problem"},
{"paste", "an lw command line replaces the pipelines"},
{"q", "quit"},
}
n := &notice{title: "Keys"}
for _, k := range keys {
n.lines = append(n.lines, ui.PadRight(k[0], 14)+k[1])
}
return n
return &notice{title: "Keys", body: func(w int) []string {
var lines []string
for _, k := range keys {
if w < stacked {
lines = append(lines, k[0])
for _, l := range ui.WrapText(k[1], w-2) {
lines = append(lines, " "+l)
}
continue
}
key := k[0]
for _, l := range ui.WrapText(k[1], w-14) {
lines, key = append(lines, ui.PadRight(key, 14)+l), ""
}
}
return lines
}}
}