diff --git a/.gitignore b/.gitignore index 66fd13c..a6fdc84 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,8 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +# IDE +.idea + # Dependency directories (remove the comment below to include it) # vendor/ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0432e18 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/nakabonne/ali + +go 1.15 + +require ( + github.com/mum4k/termdash v0.12.2 + github.com/spf13/pflag v1.0.5 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4f5cb59 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mum4k/termdash v0.12.2 h1:S2frz71OrXUKIVVZ3snYBEzyYlUNRTu0ElV6d5Pf6gI= +github.com/mum4k/termdash v0.12.2/go.mod h1:haerPCSO0U8pehROAecmuOHDF+2UXw2KaCTxdWooDFE= +github.com/nsf/termbox-go v0.0.0-20200204031403-4d2b513ad8be h1:yzmWtPyxEUIKdZg4RcPq64MfS8NA6A5fNOJgYhpR9EQ= +github.com/nsf/termbox-go v0.0.0-20200204031403-4d2b513ad8be/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/gui/gui.go b/gui/gui.go new file mode 100644 index 0000000..5f6668f --- /dev/null +++ b/gui/gui.go @@ -0,0 +1,69 @@ +package gui + +import ( + "context" + "fmt" + + "github.com/mum4k/termdash" + "github.com/mum4k/termdash/container" + "github.com/mum4k/termdash/container/grid" + "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/linestyle" + "github.com/mum4k/termdash/terminal/termbox" + "github.com/mum4k/termdash/terminal/terminalapi" +) + +const rootID = "root" + +func Run() error { + ctx, cancel := context.WithCancel(context.Background()) + + t, err := termbox.New(termbox.ColorMode(terminalapi.ColorMode256)) + if err != nil { + return fmt.Errorf("failed to generate terminal interface: %w", err) + } + defer t.Close() + + c, err := container.New(t, container.ID(rootID)) + if err != nil { + return fmt.Errorf("failed to generate container: %w", err) + } + + w, err := newWidgets(ctx, c) + if err != nil { + return fmt.Errorf("failed to generate widgets: %w", err) + } + gridOpts, err := gridLayout(w) + if err != nil { + return fmt.Errorf("failed to build grid layout: %w", err) + } + if err := c.Update(rootID, gridOpts...); err != nil { + return fmt.Errorf("failed to update container: %w", err) + } + + quitter := func(k *terminalapi.Keyboard) { + if k.Key == keyboard.KeyEsc || k.Key == keyboard.KeyCtrlC { + cancel() + } + } + return termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(redrawInterval)) +} + +func gridLayout(w *widgets) ([]container.Option, error) { + rows := []grid.Element{ + grid.RowHeightPerc(99, + grid.Widget(w.plotChart, + container.Border(linestyle.Light), + container.BorderTitle("Plot"), + ), + ), + } + col := grid.ColWidthPerc(99, rows...) + + builder := grid.New() + builder.Add( + col, + ) + + return builder.Build() +} diff --git a/gui/widgets.go b/gui/widgets.go new file mode 100644 index 0000000..378bf4f --- /dev/null +++ b/gui/widgets.go @@ -0,0 +1,92 @@ +package gui + +import ( + "context" + "math" + "time" + + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/container" + "github.com/mum4k/termdash/widgets/button" + "github.com/mum4k/termdash/widgets/linechart" + "github.com/mum4k/termdash/widgets/textinput" +) + +// redrawInterval is how often termdash redraws the screen. +const ( + redrawInterval = 250 * time.Millisecond +) + +type widgets struct { + URLInput *textinput.TextInput + attackButton *button.Button + plotChart *linechart.LineChart +} + +func newWidgets(ctx context.Context, c *container.Container) (*widgets, error) { + l, err := newLineChart(ctx) + if err != nil { + return nil, err + } + + return &widgets{ + URLInput: nil, + attackButton: nil, + plotChart: l, + }, nil +} + +// newLineChart returns a line plotChart that displays a heartbeat-like progression. +func newLineChart(ctx context.Context) (*linechart.LineChart, error) { + var inputs []float64 + for i := 0; i < 100; i++ { + v := math.Pow(math.Sin(float64(i)), 63) * math.Sin(float64(i)+1.5) * 8 + inputs = append(inputs, v) + } + + lc, err := linechart.New( + linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), + linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), + linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)), + ) + if err != nil { + return nil, err + } + step := 0 + go periodic(ctx, redrawInterval/3, func() error { + step = (step + 1) % len(inputs) + return lc.Series("heartbeat", rotateFloats(inputs, step), + linechart.SeriesCellOpts(cell.FgColor(cell.ColorNumber(87))), + linechart.SeriesXLabels(map[int]string{ + 0: "zero", + }), + ) + }) + return lc, nil +} + +// periodic executes the provided closure periodically every interval. +// Exits when the context expires. +func periodic(ctx context.Context, interval time.Duration, fn func() error) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := fn(); err != nil { + panic(err) + } + case <-ctx.Done(): + return + } + } +} + +// rotateFloats returns a new slice with inputs rotated by step. +// I.e. for a step of one: +// inputs[0] -> inputs[len(inputs)-1] +// inputs[1] -> inputs[0] +// And so on. +func rotateFloats(inputs []float64, step int) []float64 { + return append(inputs[step:], inputs[:step]...) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cef9a9f --- /dev/null +++ b/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "runtime" + + flag "github.com/spf13/pflag" + + "github.com/nakabonne/ali/gui" +) + +var ( + flagSet = flag.NewFlagSet("ali", flag.ContinueOnError) + + usage = func() { + fmt.Fprintln(os.Stderr, "usage: ali [ ...]") + flagSet.PrintDefaults() + } + // Automatically populated by goreleaser during build + version = "unversioned" + commit = "?" + date = "?" +) + +type cli struct { + debug bool + version bool + stdout io.Writer + stderr io.Writer +} + +func main() { + c := &cli{ + stdout: os.Stdout, + stderr: os.Stderr, + } + flagSet.BoolVarP(&c.version, "version", "v", false, "print the current version") + flagSet.BoolVar(&c.debug, "debug", false, "run in debug mode") + flagSet.Usage = usage + if err := flagSet.Parse(os.Args[1:]); err != nil { + if !errors.Is(err, flag.ErrHelp) { + fmt.Fprintln(c.stderr, err) + } + return + } + + os.Exit(c.run()) +} +func (c *cli) run() int { + if c.version { + fmt.Fprintf(c.stderr, "version=%s, commit=%s, buildDate=%s, os=%s, arch=%s\n", version, commit, date, runtime.GOOS, runtime.GOARCH) + return 0 + } + + if err := gui.Run(); err != nil { + fmt.Fprintf(c.stderr, "failed to start application: %s", err.Error()) + return 1 + } + + return 0 +}