diff --git a/cmd/termtier/main.go b/cmd/termtier/main.go new file mode 100644 index 0000000..bc01395 --- /dev/null +++ b/cmd/termtier/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/StevanFreeborn/term-tier/internal/handlers" + "github.com/StevanFreeborn/term-tier/internal/model" + "github.com/StevanFreeborn/term-tier/internal/persistence" + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("termtier %s\n", version) + fmt.Printf("build date: %s\n", date) + fmt.Printf("commit: %s\n", commit) + os.Exit(0) + } + + saver := persistence.NewFileSaver() + + s := state.New() + + keyMsgHandlers := handlers.GetAllKeyHandlers(saver) + winSizeMsgHandlers := handlers.GetAllWinSizeHandlers() + + m := model.New( + s, + model.WithKeyMsgHandlers(keyMsgHandlers), + model.WithWinSizeMsgHandlers(winSizeMsgHandlers), + ) + + p := tea.NewProgram(m, tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v", err) + os.Exit(1) + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..5fb3bb8 --- /dev/null +++ b/internal/model/model.go @@ -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 +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..491de2b --- /dev/null +++ b/internal/model/model_test.go @@ -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 +}