Rebuilding the UI models

Rebuilding the UI model with brand new utility models for layout and dealing with model stuff.
This commit is contained in:
Leon Mika 2022-03-27 11:01:24 +11:00
parent 6ac22aad1f
commit b0909ffe4e
11 changed files with 407 additions and 2 deletions

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/brianvoe/gofakeit/v6"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/common/ui/commandctrl" "github.com/lmika/awstools/internal/common/ui/commandctrl"
"github.com/lmika/awstools/internal/common/ui/dispatcher" "github.com/lmika/awstools/internal/common/ui/dispatcher"
@ -14,6 +15,10 @@ import (
"github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo"
"github.com/lmika/awstools/internal/dynamo-browse/services/tables" "github.com/lmika/awstools/internal/dynamo-browse/services/tables"
"github.com/lmika/awstools/internal/dynamo-browse/ui" "github.com/lmika/awstools/internal/dynamo-browse/ui"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/modal"
"github.com/lmika/gopkgs/cli" "github.com/lmika/gopkgs/cli"
"log" "log"
"os" "os"
@ -55,8 +60,31 @@ func main() {
}) })
uiModel := ui.NewModel(uiDispatcher, commandController, tableReadController, tableWriteController) uiModel := ui.NewModel(uiDispatcher, commandController, tableReadController, tableWriteController)
p := tea.NewProgram(uiModel, tea.WithAltScreen())
loopback.program = p // TEMP
_ = uiModel
// END TEMP
model := layout.FullScreen(layout.NewVBox(
frame.NewFrame("This is the header", layout.Model(newTestModel("this is the top"))),
frame.NewFrame("This is another header", layout.Model(newTestModel("this is the bottom"))),
))
//frameSet := frameset.New([]frameset.Frame{
// {
// Header: "Frame 1",
// Model: newTestModel("this is model 1"),
// },
// {
// Header: "Frame 2",
// Model: newTestModel("this is model 2"),
// },
//})
//
//modal := modal.New(frameSet)
p := tea.NewProgram(model, tea.WithAltScreen())
//loopback.program = p
// TEMP -- profiling // TEMP -- profiling
//cf, err := os.Create("trace.out") //cf, err := os.Create("trace.out")
@ -91,3 +119,18 @@ type msgLoopback struct {
func (m *msgLoopback) Send(msg tea.Msg) { func (m *msgLoopback) Send(msg tea.Msg) {
m.program.Send(msg) m.program.Send(msg)
} }
func newTestModel(descr string) tea.Model {
return teamodels.TestModel{
Message: descr,
OnKeyPressed: func(k string) tea.Cmd {
log.Println("got key press: " + k)
if k == "enter" {
return modal.PushMode(newTestModel("this is mode " + gofakeit.CarModel() + " (press k to end)"))
} else if k == "k" {
return modal.PopMode
}
return nil
},
}
}

View file

@ -0,0 +1,55 @@
package frame
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils"
"strings"
)
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
)
// Frame is a frame that appears in the
type Frame struct {
header string
model layout.ResizingModel
width int
}
func NewFrame(header string, model layout.ResizingModel) Frame {
return Frame{header, model, 0}
}
func (f Frame) Init() tea.Cmd {
return f.model.Init()
}
func (f Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
newModel, cmd := f.model.Update(msg)
f.model = newModel.(layout.ResizingModel)
return f, cmd
}
func (f Frame) View() string {
return lipgloss.JoinVertical(lipgloss.Top, f.headerView(), f.model.View())
}
func (f Frame) Resize(w, h int) layout.ResizingModel {
f.width = w
headerHeight := lipgloss.Height(f.headerView())
f.model = f.model.Resize(w, h-headerHeight)
return f
}
func (f Frame) headerView() string {
titleText := f.header
title := activeHeaderStyle.Render(titleText)
line := activeHeaderStyle.Render(strings.Repeat(" ", utils.Max(0, f.width-lipgloss.Width(title))))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
}

View file

@ -0,0 +1,38 @@
package layout
import tea "github.com/charmbracelet/bubbletea"
// FullScreen returns a model which will allocate the resizing model the entire height and width of the screen.
func FullScreen(rm ResizingModel) tea.Model {
return fullScreenModel{submodel: rm}
}
type fullScreenModel struct {
w, h int
submodel ResizingModel
ready bool
}
func (f fullScreenModel) Init() tea.Cmd {
return f.submodel.Init()
}
func (f fullScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
f.ready = true
f.submodel = f.submodel.Resize(msg.Width, msg.Height)
return f, nil
}
newSubModel, cmd := f.submodel.Update(msg)
f.submodel = newSubModel.(ResizingModel)
return f, cmd
}
func (f fullScreenModel) View() string {
if !f.ready {
return ""
}
return f.submodel.View()
}

View file

@ -0,0 +1,48 @@
package layout
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils"
"strconv"
"strings"
)
// ResizingModel is a model that handles resizing events. The submodel will not get WindowSizeMessages but will
// guarantee to receive at least one resize event before the initial view.
type ResizingModel interface {
tea.Model
Resize(w, h int) ResizingModel
}
// Model takes a tea-model and displays it as a resizing model. The model will be
// displayed with all the available space provided
func Model(m tea.Model) ResizingModel {
return &teaModel{submodel: m}
}
type teaModel struct {
submodel tea.Model
w, h int
}
func (t teaModel) Init() tea.Cmd {
return t.submodel.Init()
}
func (t teaModel) Update(msg tea.Msg) (m tea.Model, cmd tea.Cmd) {
t.submodel, cmd = t.submodel.Update(msg)
return t, cmd
}
func (t teaModel) View() string {
subview := t.submodel.View() + " (h: " + strconv.Itoa(t.h) + "\n"
subviewHeight := lipgloss.Height(subview)
subviewVPad := strings.Repeat("\n", utils.Max(t.h-subviewHeight-1, 0))
return lipgloss.JoinVertical(lipgloss.Top, subview, subviewVPad)
}
func (t teaModel) Resize(w, h int) ResizingModel {
t.w, t.h = w, h
return t
}

View file

@ -0,0 +1,56 @@
package layout
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils"
"strings"
)
// VBox is a model which will display its children vertically.
type VBox struct {
children []ResizingModel
}
func NewVBox(children ...ResizingModel) VBox {
return VBox{children: children}
}
func (vb VBox) Init() tea.Cmd {
var cc utils.CmdCollector
for _, c := range vb.children {
cc.Collect(c, c.Init())
}
return cc.Cmd()
}
func (vb VBox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cc utils.CmdCollector
for i, c := range vb.children {
vb.children[i] = cc.Collect(c.Update(msg)).(ResizingModel)
}
return vb, cc.Cmd()
}
func (vb VBox) View() string {
sb := new(strings.Builder)
for i, c := range vb.children {
if i > 0 {
sb.WriteRune('\n')
}
sb.WriteString(c.View())
}
return sb.String()
}
func (vb VBox) Resize(w, h int) ResizingModel {
childrenHeight := h / len(vb.children)
lastChildRem := h % len(vb.children)
for i, c := range vb.children {
if i == len(vb.children)-1 {
vb.children[i] = c.Resize(w, childrenHeight+lastChildRem)
} else {
vb.children[i] = c.Resize(w, childrenHeight)
}
}
return vb
}

View file

@ -0,0 +1,19 @@
package modal
import tea "github.com/charmbracelet/bubbletea"
type newModePushed tea.Model
type modePopped struct{}
// PushMode pushes a new mode on the modal stack. The new mode will receive keyboard events.
func PushMode(newMode tea.Model) tea.Cmd {
return func() tea.Msg {
return newModePushed(newMode)
}
}
// PopMode pops the top-level mode from the modal stack. If there's no modes on the stack, this method does nothing.
func PopMode() tea.Msg {
return modePopped{}
}

View file

@ -0,0 +1,71 @@
package modal
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils"
"log"
)
// Modal is a tea model which displays modes on a stack. Only the top-level model is display and will receive
// keyboard and mouse events.
type Modal struct {
baseMode tea.Model
modeStack []tea.Model
}
func New(baseMode tea.Model) Modal {
return Modal{baseMode: baseMode}
}
func (m Modal) Init() tea.Cmd {
return nil
}
func (m *Modal) pushMode(model tea.Model) {
m.modeStack = append(m.modeStack, model)
log.Printf("pusing new mode: len = %v", len(m.modeStack))
}
func (m *Modal) popMode() {
if len(m.modeStack) > 0 {
m.modeStack = m.modeStack[:len(m.modeStack)-1]
}
}
func (m Modal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cc utils.CmdCollector
switch msg := msg.(type) {
case newModePushed:
m.pushMode(msg)
return m, nil
case modePopped:
m.popMode()
return m, nil
case tea.KeyMsg, tea.MouseMsg:
// only notify top level stack
if len(m.modeStack) > 0 {
m.modeStack[len(m.modeStack)-1] = cc.Collect(m.modeStack[len(m.modeStack)-1].Update(msg))
} else {
m.baseMode = cc.Collect(m.baseMode.Update(msg))
}
default:
// notify all modes of other events
// TODO: is this right?
m.baseMode = cc.Collect(m.baseMode.Update(msg))
for i, s := range m.modeStack {
m.modeStack[i] = cc.Collect(s.Update(msg))
}
}
return m, cc.Cmd()
}
func (m Modal) View() string {
// only show top level mode
if len(m.modeStack) > 0 {
log.Printf("viewing mode stack: len = %v", len(m.modeStack))
return m.modeStack[len(m.modeStack)-1].View()
}
return m.baseMode.View()
}

View file

@ -0,0 +1,9 @@
package teamodels
import tea "github.com/charmbracelet/bubbletea"
// NewModePushed pushes a new mode on the modal stack
type NewModePushed tea.Model
// ModePopped pops a mode from the modal stack
type ModePopped struct{}

View file

@ -0,0 +1,33 @@
package teamodels
import (
tea "github.com/charmbracelet/bubbletea"
)
// TestModel is a model used for testing
type TestModel struct {
Message string
OnKeyPressed func(k string) tea.Cmd
}
func (t TestModel) Init() tea.Cmd {
return nil
}
func (t TestModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return t, tea.Quit
default:
return t, t.OnKeyPressed(msg.String())
}
}
return t, nil
}
func (t TestModel) View() string {
return t.Message
}

View file

@ -0,0 +1,8 @@
package utils
func Max(x, y int) int {
if x > y {
return x
}
return y
}

View file

@ -0,0 +1,25 @@
package utils
import tea "github.com/charmbracelet/bubbletea"
type CmdCollector struct {
cmds []tea.Cmd
}
func (c *CmdCollector) Collect(m tea.Model, cmd tea.Cmd) tea.Model {
if cmd != nil {
c.cmds = append(c.cmds, cmd)
}
return m
}
func (c CmdCollector) Cmd() tea.Cmd {
switch len(c.cmds) {
case 0:
return nil
case 1:
return c.cmds[0]
default:
return tea.Batch(c.cmds...)
}
}