feat: add main application logic and model implementation with tests
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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