diff --git a/internal/client/ui/core/core.go b/internal/client/ui/core/core.go index 070ab16..fba3ddb 100644 --- a/internal/client/ui/core/core.go +++ b/internal/client/ui/core/core.go @@ -137,7 +137,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.networkCreationPopup = &popup } - default: + case tea.KeyEscape: + if m.networkCreationPopup != nil { + m.networkCreationPopup = nil + } + + case tea.KeyEnter: + if m.networkCreationPopup != nil { + return m, m.networkCreationPopup.Select() + } + + default: if m.networkCreationPopup != nil { popup, cmd := m.networkCreationPopup.Update(msg) m.networkCreationPopup = &popup diff --git a/internal/client/ui/core/networkcreation/networkcreation.go b/internal/client/ui/core/networkcreation/networkcreation.go index c911dbb..f21bb73 100644 --- a/internal/client/ui/core/networkcreation/networkcreation.go +++ b/internal/client/ui/core/networkcreation/networkcreation.go @@ -1,6 +1,7 @@ package networkcreation import ( + "errors" "strings" "github.com/charmbracelet/bubbles/textinput" @@ -8,11 +9,18 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/kyren223/eko/internal/client/ui/colors" "github.com/kyren223/eko/internal/client/ui/field" + "github.com/kyren223/eko/internal/client/ui/layouts/flex" "github.com/kyren223/eko/pkg/assert" ) var ( - style = lipgloss.NewStyle().Border(lipgloss.ThickBorder()) + width = 48 + + style = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + Padding(1, 4). + Align(lipgloss.Center, lipgloss.Center) + headerStyle = lipgloss.NewStyle().Foreground(colors.Turquoise) fieldBlurredStyle = lipgloss.NewStyle(). @@ -23,11 +31,7 @@ var ( BorderForeground(colors.Focus). Border(lipgloss.ThickBorder()) - underlineStyle = func(s string, width int, focus bool) string { - color := colors.Gray - if focus { - color = colors.Focus - } + underlineStyle = func(s string, width int, color lipgloss.Color) string { underline := lipgloss.NewStyle().Foreground(color). Render(strings.Repeat(lipgloss.ThickBorder().Bottom, width)) return lipgloss.JoinVertical(lipgloss.Left, s, underline) @@ -35,6 +39,11 @@ var ( iconHeader = headerStyle.Bold(true).Render(" Icon: ") colorHeader = headerStyle.Bold(true).Italic(true).Render("# ") + + blurredCreate = lipgloss.NewStyle(). + Background(colors.Gray).Padding(0, 1).Render("Create Network") + focusedCreate = lipgloss.NewStyle(). + Background(colors.Blue).Padding(0, 1).Render("Create Network") ) const ( @@ -46,49 +55,73 @@ const ( NameField = iota IconField ColorField + CreateField FieldCount ) type Model struct { - name field.Model - Style lipgloss.Style precomputedIconStyle lipgloss.Style - icon textinput.Model - color textinput.Model - Width int - Height int - selected int + name field.Model + icon textinput.Model + color textinput.Model + create string + + selected int + nameWidth int } func New() Model { - name := field.New(30) + name := field.New(width) name.Header = "Network Name" name.HeaderStyle = headerStyle name.FocusedStyle = fieldFocusedStyle name.BlurredStyle = fieldBlurredStyle - name.Input.CharLimit = 32 + name.ErrorStyle = lipgloss.NewStyle().Foreground(colors.Error) + name.Input.CharLimit = width name.Focus() + name.Input.Validate = func(s string) error { + if strings.TrimSpace(s) == "" { + return errors.New("cannot be empty") + } + if strings.TrimSpace(s) != s { + return errors.New("no leading/trailing spaces") + } + return nil + } + + nameWidth := lipgloss.Width(name.View()) icon := textinput.New() icon.Prompt = "" icon.CharLimit = MaxIconLength icon.Placeholder = "ic" + icon.Validate = func(s string) error { + if len(s) == 0 { + return errors.New("err") + } + return nil + } color := textinput.New() color.Prompt = "" color.CharLimit = MaxHexDigits color.Placeholder = "000000" + color.Validate = func(s string) error { + if len(s) != 0 && len(s) != MaxHexDigits { + return errors.New("err") + } + return nil + } return Model{ - Width: 50, - Height: 10, name: name, icon: icon, color: color, - Style: style, + create: blurredCreate, - precomputedIconStyle: lipgloss.NewStyle().Width(lipgloss.Width(name.View()) / 2), + nameWidth: nameWidth, + precomputedIconStyle: lipgloss.NewStyle().Width(nameWidth / 2), } } @@ -99,17 +132,31 @@ func (m Model) Init() tea.Cmd { func (m Model) View() string { name := m.name.View() - iconInput := underlineStyle(m.icon.View(), MaxIconLength, m.selected == IconField) + color := colors.Gray + if m.icon.Err != nil { + color = colors.Error + } else if m.selected == IconField { + color = colors.Focus + } + iconInput := underlineStyle(m.icon.View(), MaxIconLength, color) iconText := m.precomputedIconStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, iconHeader, iconInput)) - colorInput := underlineStyle(m.color.View(), MaxHexDigits, m.selected == ColorField) + color = colors.Gray + if m.color.Err != nil { + color = colors.Error + } else if m.selected == ColorField { + color = colors.Focus + } + colorInput := underlineStyle(m.color.View(), MaxHexDigits, color) colorText := lipgloss.JoinHorizontal(lipgloss.Top, colorHeader, colorInput) icon := lipgloss.JoinHorizontal(lipgloss.Top, iconText, colorText) - content := lipgloss.JoinVertical(lipgloss.Left, name, "\n", icon) - popup := lipgloss.NewStyle().Width(m.Width).Height(m.Height).Render(content) - return m.Style.Render(popup) + // create := lipgloss.NewStyle().Width(m.nameWidth).Align(lipgloss.Center).Render(m.create) + create := m.create + + content := flex.NewVertical(name, icon, create).WithGap(1).View() + return style.Render(content) } func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { @@ -130,7 +177,25 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case IconField: m.icon, cmd = m.icon.Update(msg) case ColorField: + oldValue := m.color.Value() + position := m.color.Position() m.color, cmd = m.color.Update(msg) + newValue := m.color.Value() + + hex := "0123456789abcdefABCDEF" + invalid := false + for _, c := range newValue { + if !strings.ContainsRune(hex, c) { + invalid = true + break + } + } + + if invalid { + m.color.SetValue(oldValue) + m.color.SetCursor(position) + } + } return m, cmd } @@ -153,6 +218,7 @@ func (m *Model) updateFocus() tea.Cmd { m.name.Blur() m.icon.Blur() m.color.Blur() + m.create = blurredCreate switch m.selected { case NameField: return m.name.Focus() @@ -160,8 +226,27 @@ func (m *Model) updateFocus() tea.Cmd { return m.icon.Focus() case ColorField: return m.color.Focus() + case CreateField: + m.create = focusedCreate + return nil default: assert.Never("missing switch statement field in update focus", "selected", m.selected) return nil } } + +func (m *Model) Select() tea.Cmd { + if m.selected != CreateField { + return nil + } + + m.name.Input.Err = m.name.Input.Validate(m.name.Input.Value()) + m.icon.Err = m.icon.Validate(m.icon.Value()) + m.color.Err = m.color.Validate(m.color.Value()) + if m.name.Input.Err != nil || m.icon.Err != nil || m.color.Err != nil { + return nil + } + + // TODO: send api request for creating the server + return nil +} diff --git a/internal/client/ui/layouts/flex/flex.go b/internal/client/ui/layouts/flex/flex.go index 6f39ec1..def7a18 100644 --- a/internal/client/ui/layouts/flex/flex.go +++ b/internal/client/ui/layouts/flex/flex.go @@ -16,8 +16,18 @@ type Model struct { Vertical bool } -func New() Model { - return Model{} +func NewHorizontal(contents ...string) Model { + return Model{ + contents: contents, + Vertical: false, + } +} + +func NewVertical(contents ...string) Model { + return Model{ + contents: contents, + Vertical: true, + } } func (m Model) Init() tea.Cmd { @@ -54,3 +64,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m *Model) SetContents(contents ...string) { m.contents = contents } + +func (m Model) WithGap(gap int) Model { + m.Gap = gap + return m +}