feat: add main application logic and model implementation with tests
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/StevanFreeborn/term-tier/internal/handlers"
|
||||
"github.com/StevanFreeborn/term-tier/internal/state"
|
||||
"github.com/StevanFreeborn/term-tier/internal/styles"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
ModeNav Mode = iota
|
||||
ModeInput
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
State state.State
|
||||
KeyMsgHandlers []handlers.KeyMsgHandler
|
||||
WinSizeMsgHandlers []handlers.WinSizeMsgHandler
|
||||
}
|
||||
|
||||
type option func(m *Model)
|
||||
|
||||
func New(state state.State, opts ...option) Model {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 50
|
||||
ti.Width = 30
|
||||
|
||||
m := Model{
|
||||
State: state,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func WithKeyMsgHandlers(handlers []handlers.KeyMsgHandler) option {
|
||||
return func(m *Model) {
|
||||
m.KeyMsgHandlers = handlers
|
||||
}
|
||||
}
|
||||
|
||||
func WithWinSizeMsgHandlers(handlers []handlers.WinSizeMsgHandler) option {
|
||||
return func(m *Model) {
|
||||
m.WinSizeMsgHandlers = handlers
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
for _, handler := range m.WinSizeMsgHandlers {
|
||||
if handler.Match(m.State, msg) {
|
||||
return m, handler.Handle(m.State, msg)
|
||||
}
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
for _, handler := range m.KeyMsgHandlers {
|
||||
if handler.Match(m.State, msg) {
|
||||
return m, handler.Handle(m.State, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.State.Mode() == state.InputMode {
|
||||
return fmt.Sprintf(
|
||||
"\n %s\n\n%s\n\n (Enter to Confirm, Esc to Cancel)",
|
||||
m.State.InputTitle(),
|
||||
styles.InputBoxStyle.Render(m.State.Input().View()),
|
||||
)
|
||||
}
|
||||
|
||||
if m.State.Mode() == state.HelpMode {
|
||||
helpText := " HELP MENU\n\n"
|
||||
|
||||
for _, handler := range m.KeyMsgHandlers {
|
||||
if desc := handler.Description(); desc != "" {
|
||||
keys := handler.KeysHandled()
|
||||
helpText += fmt.Sprintf(" %s: %s\n", keys, desc)
|
||||
}
|
||||
}
|
||||
|
||||
helpText += "\n (Press ? to close this menu)"
|
||||
|
||||
box := styles.HelpBoxStyle.Render(helpText)
|
||||
|
||||
return lipgloss.Place(
|
||||
m.State.WindowWidth(),
|
||||
m.State.WindowHeight(),
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
box,
|
||||
)
|
||||
}
|
||||
|
||||
s := "[?] Help ️ [q] Quit\n\n"
|
||||
|
||||
availableWidth := max(m.State.WindowWidth()-15, 20)
|
||||
|
||||
for tierIndex, tier := range m.State.Tiers() {
|
||||
var rows []string
|
||||
var currentRowItems []string
|
||||
currentWidth := 0
|
||||
|
||||
for itemIndex, item := range tier.Items {
|
||||
renderStyle := styles.ItemStyle
|
||||
isFocused := tierIndex == m.State.CursorRow() && itemIndex == m.State.CursorCol()
|
||||
|
||||
if isFocused {
|
||||
if m.State.Dragging() {
|
||||
renderStyle = styles.DraggingItemStyle
|
||||
} else {
|
||||
renderStyle = styles.SelectedItemStyle
|
||||
}
|
||||
}
|
||||
|
||||
renderedItem := renderStyle.Render(string(item))
|
||||
itemWidth := lipgloss.Width(renderedItem)
|
||||
|
||||
if currentWidth+itemWidth > availableWidth && len(currentRowItems) > 0 {
|
||||
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, currentRowItems...))
|
||||
currentRowItems = []string{renderedItem}
|
||||
currentWidth = itemWidth
|
||||
} else {
|
||||
currentRowItems = append(currentRowItems, renderedItem)
|
||||
currentWidth += itemWidth
|
||||
}
|
||||
}
|
||||
|
||||
if len(tier.Items) == 0 && tierIndex == m.State.CursorRow() {
|
||||
ghost := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" [ ]")
|
||||
currentRowItems = append(currentRowItems, ghost)
|
||||
}
|
||||
|
||||
if len(currentRowItems) > 0 {
|
||||
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, currentRowItems...))
|
||||
}
|
||||
|
||||
itemBlock := lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
|
||||
blockHeight := lipgloss.Height(itemBlock)
|
||||
|
||||
label := styles.TierLabelStyle.
|
||||
Background(tier.Color).
|
||||
Height(blockHeight).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render(tier.Name)
|
||||
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Center, label, itemBlock)
|
||||
|
||||
s += row + "\n\n"
|
||||
}
|
||||
|
||||
if m.State.StatusMsg() != "" {
|
||||
s += styles.StatusStyle.Render(" STATUS: " + m.State.StatusMsg())
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/StevanFreeborn/term-tier/internal/core"
|
||||
"github.com/StevanFreeborn/term-tier/internal/handlers"
|
||||
"github.com/StevanFreeborn/term-tier/internal/model"
|
||||
"github.com/StevanFreeborn/term-tier/internal/state"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/exp/teatest"
|
||||
)
|
||||
|
||||
func TestModel_Update_DelegatesKeyMsg(t *testing.T) {
|
||||
s := state.New(state.WithMode(state.NavigationMode))
|
||||
|
||||
mockHandler := &MockKeyHandler{shouldMatch: true}
|
||||
|
||||
m := model.New(s, model.WithKeyMsgHandlers([]handlers.KeyMsgHandler{mockHandler}))
|
||||
|
||||
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(300, 100))
|
||||
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
|
||||
|
||||
tm.WaitFinished(t)
|
||||
|
||||
if !mockHandler.called {
|
||||
t.Error("Expected KeyMsgHandler to be called, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_Update_DelegatesWinSizeMsg(t *testing.T) {
|
||||
s := state.New(state.WithWindowWidth(100))
|
||||
mockHandler := &MockWinHandler{shouldMatch: true}
|
||||
|
||||
m := model.New(s, model.WithWinSizeMsgHandlers([]handlers.WinSizeMsgHandler{mockHandler}))
|
||||
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
|
||||
|
||||
tm.Send(tea.WindowSizeMsg{Width: 120, Height: 80})
|
||||
tm.WaitFinished(t)
|
||||
|
||||
if !mockHandler.called {
|
||||
t.Error("Expected WinSizeMsgHandler to be called")
|
||||
}
|
||||
|
||||
if s.WindowWidth() != 120 {
|
||||
t.Errorf("Expected state to update WindowWidth to 120, got %d", s.WindowWidth())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_View_NavMode(t *testing.T) {
|
||||
s := state.New(
|
||||
state.WithMode(state.NavigationMode),
|
||||
state.WithTiers([]core.Tier{
|
||||
{Name: "S Tier", Color: lipgloss.Color("#FFFFFF"), Items: []core.Item{"Go", "Rust"}},
|
||||
}),
|
||||
state.WithCursor(0, 0),
|
||||
state.WithWindowWidth(100),
|
||||
state.WithMode(state.NavigationMode),
|
||||
)
|
||||
|
||||
m := model.New(s)
|
||||
|
||||
output := m.View()
|
||||
|
||||
if !bytes.Contains([]byte(output), []byte("S Tier")) {
|
||||
t.Error("View output missing tier label 'S Tier'")
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("Go")) {
|
||||
t.Error("View output missing item 'Go'")
|
||||
}
|
||||
if bytes.Contains([]byte(output), []byte("Enter to Confirm")) {
|
||||
t.Error("View output contains Input Mode text while in Nav Mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_View_InputMode(t *testing.T) {
|
||||
ti := textinput.New()
|
||||
ti.SetValue("Test Value")
|
||||
|
||||
s := state.New(
|
||||
state.WithMode(state.InputMode),
|
||||
state.WithInput(ti, "Create New Item"),
|
||||
)
|
||||
|
||||
m := model.New(s)
|
||||
output := m.View()
|
||||
|
||||
if !bytes.Contains([]byte(output), []byte("Create New Item")) {
|
||||
t.Error("View output missing Input Title")
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("Test Value")) {
|
||||
t.Error("View output missing input value")
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("Esc to Cancel")) {
|
||||
t.Error("View output missing input help text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_Init(t *testing.T) {
|
||||
s := state.New()
|
||||
m := model.New(s)
|
||||
|
||||
cmd := m.Init()
|
||||
|
||||
if cmd == nil {
|
||||
t.Error("Init should return a command (blink), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
type MockKeyHandler struct {
|
||||
called bool
|
||||
shouldMatch bool
|
||||
}
|
||||
|
||||
func (m *MockKeyHandler) KeysHandled() []string {
|
||||
return []string{"q"}
|
||||
}
|
||||
|
||||
func (m *MockKeyHandler) Description() string {
|
||||
return "Mock Quit Handler"
|
||||
}
|
||||
|
||||
func (m *MockKeyHandler) Match(s state.State, msg tea.KeyMsg) bool {
|
||||
return m.shouldMatch
|
||||
}
|
||||
|
||||
func (m *MockKeyHandler) Handle(s state.State, msg tea.KeyMsg) tea.Cmd {
|
||||
m.called = true
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
type MockWinHandler struct {
|
||||
called bool
|
||||
shouldMatch bool
|
||||
targetWidth int
|
||||
}
|
||||
|
||||
func (m *MockWinHandler) Match(s state.State, msg tea.WindowSizeMsg) bool {
|
||||
return m.shouldMatch
|
||||
}
|
||||
|
||||
func (m *MockWinHandler) Handle(s state.State, msg tea.WindowSizeMsg) tea.Cmd {
|
||||
m.called = true
|
||||
s.SetWindowSize(msg.Width, msg.Height)
|
||||
return tea.Quit
|
||||
}
|
||||
Reference in New Issue
Block a user