diff --git a/tui/appMenu.go b/tui/appMenu.go index 0d316ff6..a5441222 100644 --- a/tui/appMenu.go +++ b/tui/appMenu.go @@ -75,9 +75,16 @@ func (m AppMenu) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": switch m.list.SelectedItem().(AppMenuItem).id { case attributeMenu: - attributeList := InitAttributeList() - am, cmd := attributeList.Update(WindowMsg()) - return am, cmd + item := AttributeItem{ + id: "8a6755f2-efa8-4758-b893-af9a488e0bea", + namespace: "demo.com", + name: "relto", + rule: "hierarchical", + description: "The relto attribute is used to describe the relationship of the resource to the country of origin.", + values: []string{"USA", "GBR"}, + } + al, cmd := InitAttributeList([]list.Item{item}, 0) + return al, cmd } } } diff --git a/tui/attributeList.go b/tui/attributeList.go index 2e415171..f6e5b875 100644 --- a/tui/attributeList.go +++ b/tui/attributeList.go @@ -1,12 +1,9 @@ package tui import ( - "fmt" - "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/wordwrap" "github.com/opentdf/tructl/tui/constants" ) @@ -16,7 +13,7 @@ type AttributeList struct { } type AttributeItem struct { - id int + id string namespace string name string description string @@ -36,24 +33,17 @@ func (m AttributeItem) Description() string { return m.description } -func InitAttributeList() AttributeList { +func InitAttributeList(items []list.Item, selectIdx int) (tea.Model, tea.Cmd) { // TODO: fetch items from API m := AttributeList{} m.list = list.New([]list.Item{}, list.NewDefaultDelegate(), constants.WindowSize.Width, constants.WindowSize.Height) + if selectIdx > 0 { + m.list.Select(selectIdx) + } m.list.Title = "Attributes" - m.list.SetItems([]list.Item{ - AttributeItem{ - id: 1, - namespace: "demo.com", - name: "relto", - rule: "hierarchical", - description: "The relto attribute is used to describe the relationship of the resource to the country of origin.", - values: []string{"USA", "GBR"}, - }, - }) - - return m + m.list.SetItems(items) + return m.Update(WindowMsg()) } func (m AttributeList) Init() tea.Cmd { @@ -66,7 +56,7 @@ func StyleAttr(attr string) string { Render(attr) } -func CreateFormat(num int) string { +func CreateViewFormat(num int) string { var format string for i := 0; i < num; i++ { format += "%s %s\n" @@ -90,26 +80,25 @@ func (m AttributeList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { am, _ := InitAppMenu() am.list.Select(1) return am.Update(WindowMsg()) + case "down", "j": + if m.list.Index() < len(m.list.Items())-1 { + m.list.Select(m.list.Index() + 1) + } + case "up", "k": + if m.list.Index() > 0 { + m.list.Select(m.list.Index() - 1) + } case "c": - // show the add attribute form - // InitAttributeCreateView() - return m, nil - case "enter": - item := m.list.Items()[0].(AttributeItem) - attr_keys := []string{"Name", "Namespace", "Rule", "Description", "Values"} - content := fmt.Sprintf( - CreateFormat(len(attr_keys)), - StyleAttr(attr_keys[0]), item.name, - StyleAttr(attr_keys[1]), item.namespace, - StyleAttr(attr_keys[2]), item.rule, - StyleAttr(attr_keys[3]), item.description, - StyleAttr(attr_keys[4]), item.values, - ) - wrapped := wordwrap.String(content, m.width) - am := AttributeView{} - am.title = "Attribute" - am.content = wrapped - return am.Update(WindowMsg()) + return InitAttributeView(m.list.Items(), len(m.list.Items())) + case "enter", "e": + return InitAttributeView(m.list.Items(), m.list.Index()) + case "ctrl+d": + m.list.RemoveItem(m.list.Index()) + newIndex := m.list.Index() - 1 + if newIndex < 0 { + newIndex = 0 + } + m.list.Select(newIndex) } } return m, nil diff --git a/tui/attributeView.go b/tui/attributeView.go index 2761d219..4e4ac39c 100644 --- a/tui/attributeView.go +++ b/tui/attributeView.go @@ -4,21 +4,38 @@ import ( "fmt" "strings" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/wordwrap" + "github.com/opentdf/tructl/tui/constants" +) + +const ( + id = iota + name + namespace + rule + description + values +) + +const ( + hotPink = lipgloss.Color("#FF06B7") + darkGray = lipgloss.Color("#767676") + cyan = lipgloss.Color("#00FFFF") ) -// You generally won't need this unless you're processing stuff with -// complicated ANSI escape sequences. Turn it on if you notice flickering. -// -// Also keep in mind that high performance rendering only works for programs -// that use the full size of the terminal. We're enabling that below with -// tea.EnterAltScreen(). -// Setting this to true is causing issues and preventing items from rendering const useHighPerformanceRenderer = false +var ( + inputStyle = lipgloss.NewStyle().Foreground(constants.Magenta) + continueStyle = lipgloss.NewStyle().Foreground(cyan) +) + var ( titleStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() @@ -33,12 +50,23 @@ var ( }() ) +// type TextWrapper struct{} + +// func View(m TextWrapper) {} +// func Value(m TextWrapper) {} + type AttributeView struct { - width, height int - content string + inputs []interface{} + focused int + err error + keys []string title string ready bool viewport viewport.Model + width, height int + list []list.Item + idx int + editMode bool } func SetupViewport(m AttributeView, msg tea.WindowSizeMsg) (AttributeView, []tea.Cmd) { @@ -49,6 +77,7 @@ func SetupViewport(m AttributeView, msg tea.WindowSizeMsg) (AttributeView, []tea footerHeight := lipgloss.Height(m.CreateFooter()) verticalMarginHeight := headerHeight + footerHeight m.width = msg.Width + if !m.ready { // Since this program is using the full size of the viewport we // need to wait until we've received the window dimensions before @@ -80,46 +109,223 @@ func SetupViewport(m AttributeView, msg tea.WindowSizeMsg) (AttributeView, []tea return m, cmds } +func InitAttributeView(items []list.Item, idx int) (tea.Model, tea.Cmd) { + attr_keys := []string{"Id", "Name", "Namespace", "Rule", "Description", "Values"} + var inputs []interface{} + title := "Attribute]" + item := AttributeItem{} + if idx >= len(items) { + title = "[Create " + title + } else { + title = "[Edit " + title + item = items[idx].(AttributeItem) + } + + ti0 := textinput.New() + ti0.Focus() + ti0.SetValue(item.id) + inputs = append(inputs, ti0) + + ti1 := textinput.New() + ti1.SetValue(item.name) + inputs = append(inputs, ti1) + + ti2 := textinput.New() + ti2.SetValue(item.namespace) + inputs = append(inputs, ti2) + + ti3 := textinput.New() + ti3.SetValue(item.rule) + inputs = append(inputs, ti3) + + ti4 := textarea.New() + ti4.ShowLineNumbers = false + ti4.SetValue(item.description) + inputs = append(inputs, ti4) + + ti5 := textinput.New() + ti5.SetValue(strings.Join(item.values, ",")) + inputs = append(inputs, ti5) + + m := AttributeView{ + keys: attr_keys, + inputs: inputs, + focused: 0, + err: nil, + title: title, + list: items, + idx: idx, + editMode: idx >= len(items), + } + return m.Update(WindowMsg()) +} + func (m AttributeView) Init() tea.Cmd { - return nil + return textinput.Blink } -func (m AttributeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var ( - cmd tea.Cmd - cmds []tea.Cmd - ) +func (m AttributeView) IsNew() bool { + return m.idx >= len(m.list) +} + +func (m AttributeView) ChangeMode() AttributeView { + m.editMode = m.IsNew() || !m.editMode + return m +} +func (m AttributeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd // = make([]tea.Cmd, len(m.inputs)) + var editing bool + item := AttributeItem{ + id: m.inputs[id].(textinput.Model).Value(), + name: m.inputs[name].(textinput.Model).Value(), + namespace: m.inputs[namespace].(textinput.Model).Value(), + rule: m.inputs[rule].(textinput.Model).Value(), + description: m.inputs[description].(textarea.Model).Value(), + values: strings.Split(m.inputs[values].(textinput.Model).Value(), ","), + } switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "backspace": - attributeList := InitAttributeList() - return attributeList.Update(WindowMsg()) + switch msg.Type { + case tea.KeyShiftLeft: + listIdx := m.idx + if m.IsNew() { + listIdx -= 1 + } + return InitAttributeList(m.list, listIdx) + case tea.KeyShiftRight: + if !m.IsNew() { + m.list[m.idx] = list.Item(item) + } else { + m.list = append(m.list, list.Item(item)) + } + + return InitAttributeList(m.list, m.idx) + case tea.KeyEnter: + m.nextInput() + case tea.KeyCtrlC, tea.KeyEsc: + if m.editMode { + m.editMode = false + } else { + return m, tea.Quit + } + case tea.KeyShiftTab, tea.KeyCtrlP, tea.KeyUp: + m.prevInput() + case tea.KeyTab, tea.KeyCtrlN, tea.KeyDown: + m.nextInput() + } + if msg.String() == "i" && !m.editMode { + editing = true + m = m.ChangeMode() + var cmd tea.Cmd + if m.focused == description { + tempArea := m.inputs[m.focused].(textarea.Model) + cmd = tempArea.Cursor.SetMode(0) + m.inputs[m.focused] = tempArea + } else { + tempInput := m.inputs[m.focused].(textinput.Model) + cmd = tempInput.Cursor.SetMode(0) + m.inputs[m.focused] = tempInput + } + return m, cmd + } + for i := range m.inputs { + if i == description { + tempInput := m.inputs[i].(textarea.Model) + tempInput.Blur() + m.inputs[i] = tempInput + } else { + tempArea := m.inputs[i].(textinput.Model) + tempArea.Blur() + m.inputs[i] = tempArea + } + } + if m.focused == description { + tempArea := m.inputs[m.focused].(textarea.Model) + tempArea.Focus() + m.inputs[m.focused] = tempArea + } else { + tempInput := m.inputs[m.focused].(textinput.Model) + tempInput.Focus() + m.inputs[m.focused] = tempInput } case tea.WindowSizeMsg: m, cmds = SetupViewport(m, msg) + // We handle errors just like any other message + case errMsg: + m.err = msg + return m, nil } - // Handle keyboard and mouse events in the viewport + var cmd tea.Cmd + if m.editMode || m.IsNew() && !editing { + for i := range m.inputs { + if i == description { + m.inputs[i], cmd = m.inputs[i].(textarea.Model).Update(msg) + } else { + m.inputs[i], cmd = m.inputs[i].(textinput.Model).Update(msg) + } + cmds = append(cmds, cmd) + } + } m.viewport, cmd = m.viewport.Update(msg) cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) } +func CreateEditFormat(num int) string { + var format string + prefix := "\n%s" + postfix := "%s\n" + var middle string + for i := 0; i < num; i++ { + if i == description { + middle = "\n" + } + format += prefix + middle + postfix + } + return format +} + func (m AttributeView) View() string { + content := fmt.Sprintf(CreateEditFormat(len(m.inputs)), + inputStyle.Width(len(m.keys[id])).Render(m.keys[id]), + m.inputs[id].(textinput.Model).View(), + inputStyle.Width(len(m.keys[name])).Render(m.keys[name]), + m.inputs[name].(textinput.Model).View(), + inputStyle.Width(len(m.keys[namespace])).Render(m.keys[namespace]), + m.inputs[namespace].(textinput.Model).View(), + inputStyle.Width(len(m.keys[rule])).Render(m.keys[rule]), + m.inputs[rule].(textinput.Model).View(), + inputStyle.Width(len(m.keys[description])).Render(m.keys[description]), + m.inputs[description].(textarea.Model).View(), + inputStyle.Width(len(m.keys[values])).Render(m.keys[values]), + m.inputs[values].(textinput.Model).View(), + ) + if !m.ready { return "\n Initializing..." } - wrapped := wordwrap.String(m.content, m.width) + wrapped := wordwrap.String(content, m.width) m.viewport.SetContent(wrapped) return fmt.Sprintf("%s\n%s\n%s", m.CreateHeader(), m.viewport.View(), m.CreateFooter()) } +// nextInput focuses the next input field +func (m *AttributeView) nextInput() { + m.focused = (m.focused + 1) % len(m.inputs) +} + +// prevInput focuses the previous input field +func (m *AttributeView) prevInput() { + m.focused-- + // Wrap around + if m.focused < 0 { + m.focused = len(m.inputs) - 1 + } +} + func CreateLine(width int, text string) string { return strings.Repeat("─", max(0, width-lipgloss.Width(text))) } @@ -131,7 +337,13 @@ func (m AttributeView) CreateHeader() string { } func (m AttributeView) CreateFooter() string { - info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) + var prefix string + if m.editMode || m.IsNew() { + prefix = "discard: shift + left arrow | save: shift + right arrow" + } else { + prefix = "enter edit mode: i | go back: shift + left arrow" + } + info := infoStyle.Render(fmt.Sprintf(prefix+" | scroll: %3.f%%", m.viewport.ScrollPercent()*100)) line := CreateLine(m.viewport.Width, info) return lipgloss.JoinHorizontal(lipgloss.Center, line, info) } diff --git a/tui/common.go b/tui/common.go index 2fc7234e..7d7dcb05 100644 --- a/tui/common.go +++ b/tui/common.go @@ -26,7 +26,7 @@ func StartTea() error { } m, _ := InitAppMenu() - constants.P = tea.NewProgram(m, tea.WithAltScreen()) + constants.P = tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := constants.P.Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1)