feat: add main application logic and model implementation with tests

This commit is contained in:
Stevan Freeborn
2025-12-29 13:46:48 -06:00
parent 65dd64a4ab
commit 1b8dfeb456
3 changed files with 376 additions and 0 deletions
+51
View File
@@ -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)
}
}
+175
View File
@@ -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
}
+150
View File
@@ -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
}