diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 291e372..3a6b258 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/brianvoe/gofakeit/v6" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/commandctrl" "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/services/tables" "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" "log" "os" @@ -55,8 +60,31 @@ func main() { }) 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 //cf, err := os.Create("trace.out") @@ -91,3 +119,18 @@ type msgLoopback struct { func (m *msgLoopback) Send(msg tea.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 + }, + } +} diff --git a/internal/dynamo-browse/ui/teamodels/frame/frame.go b/internal/dynamo-browse/ui/teamodels/frame/frame.go new file mode 100644 index 0000000..c51928a --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/frame/frame.go @@ -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) +} diff --git a/internal/dynamo-browse/ui/teamodels/layout/fullscreen.go b/internal/dynamo-browse/ui/teamodels/layout/fullscreen.go new file mode 100644 index 0000000..849ac97 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/layout/fullscreen.go @@ -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() +} diff --git a/internal/dynamo-browse/ui/teamodels/layout/model.go b/internal/dynamo-browse/ui/teamodels/layout/model.go new file mode 100644 index 0000000..a958a47 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/layout/model.go @@ -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 +} diff --git a/internal/dynamo-browse/ui/teamodels/layout/vbox.go b/internal/dynamo-browse/ui/teamodels/layout/vbox.go new file mode 100644 index 0000000..1dac2c2 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/layout/vbox.go @@ -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 +} diff --git a/internal/dynamo-browse/ui/teamodels/modal/events.go b/internal/dynamo-browse/ui/teamodels/modal/events.go new file mode 100644 index 0000000..a3f16b1 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/modal/events.go @@ -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{} +} diff --git a/internal/dynamo-browse/ui/teamodels/modal/model.go b/internal/dynamo-browse/ui/teamodels/modal/model.go new file mode 100644 index 0000000..243aaf7 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/modal/model.go @@ -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() +} diff --git a/internal/dynamo-browse/ui/teamodels/modalevents.go b/internal/dynamo-browse/ui/teamodels/modalevents.go new file mode 100644 index 0000000..3a2c293 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/modalevents.go @@ -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{} diff --git a/internal/dynamo-browse/ui/teamodels/testmodel.go b/internal/dynamo-browse/ui/teamodels/testmodel.go new file mode 100644 index 0000000..5400aae --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/testmodel.go @@ -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 +} diff --git a/internal/dynamo-browse/ui/teamodels/utils/minmax.go b/internal/dynamo-browse/ui/teamodels/utils/minmax.go new file mode 100644 index 0000000..720c39f --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/utils/minmax.go @@ -0,0 +1,8 @@ +package utils + +func Max(x, y int) int { + if x > y { + return x + } + return y +} diff --git a/internal/dynamo-browse/ui/teamodels/utils/utils.go b/internal/dynamo-browse/ui/teamodels/utils/utils.go new file mode 100644 index 0000000..91b0f06 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/utils/utils.go @@ -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...) + } +}