feat: implement state management and input handling for tier system

This commit is contained in:
Stevan Freeborn
2025-12-29 12:47:49 -06:00
parent 70e900cf4c
commit 7b4f9d5a4f
2 changed files with 676 additions and 0 deletions
+413
View File
@@ -0,0 +1,413 @@
package state
import (
"fmt"
"github.com/StevanFreeborn/term-tier/internal/core"
"github.com/StevanFreeborn/term-tier/internal/styles"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type Mode int
const (
NavigationMode Mode = iota
InputMode
HelpMode
)
type State interface {
Mode() Mode
Tiers() []core.Tier
CursorRow() int
CursorCol() int
Dragging() bool
SetStatus(msg string)
StatusMsg() string
WindowWidth() int
WindowHeight() int
SetWindowSize(width int, height int)
InputTitle() string
Input() textinput.Model
UpdateInput(msg tea.Msg) tea.Cmd
StartInputMode(title string, callback func(string, State) tea.Cmd) tea.Cmd
ExitInputMode()
SubmitInput() tea.Cmd
DeleteItem() tea.Cmd
ClampCursor()
AddItem() tea.Cmd
AddItemToPool(item core.Item)
UpdateItem() tea.Cmd
UpdateCurrentItem(newItem core.Item)
SelectItem()
MoveItem()
MoveRight()
MoveUp()
MoveDown()
MoveLeft()
Save() core.SaveState
Load(data core.SaveState)
ToggleHelp()
}
type state struct {
tiers []core.Tier
cursorRow int
cursorCol int
dragging bool
SourceRow int
SourceCol int
dragBuffer core.Item
windowWidth int
windowHeight int
mode Mode
input textinput.Model
InputCallback func(string, State) tea.Cmd
inputTitle string
statusMsg string
}
type option func(*state)
func New(opts ...option) State {
ti := textinput.New()
ti.CharLimit = 50
ti.Width = 30
state := &state{
tiers: []core.Tier{
{Name: "S Tier", Color: styles.TierColors[0], Items: []core.Item{}},
{Name: "A Tier", Color: styles.TierColors[1], Items: []core.Item{}},
{Name: "B Tier", Color: styles.TierColors[2], Items: []core.Item{}},
{Name: "C Tier", Color: styles.TierColors[3], Items: []core.Item{}},
{Name: "D Tier", Color: styles.TierColors[4], Items: []core.Item{}},
{Name: "Pool", Color: styles.TierColors[5], Items: []core.Item{}},
},
cursorRow: 5,
cursorCol: 0,
mode: NavigationMode,
input: ti,
statusMsg: "Ready.",
}
for _, opt := range opts {
opt(state)
}
return state
}
func WithTiers(tiers []core.Tier) option {
return func(s *state) {
s.tiers = tiers
}
}
func WithCursor(row, col int) option {
return func(s *state) {
s.cursorRow = row
s.cursorCol = col
}
}
func WithMode(mode Mode) option {
return func(s *state) {
s.mode = mode
}
}
func WithInput(input textinput.Model, title string) option {
return func(s *state) {
s.input = input
s.inputTitle = title
}
}
func WithWindowWidth(width int) option {
return func(s *state) {
s.windowWidth = width
}
}
func (s *state) Mode() Mode {
return s.mode
}
func (s *state) InputTitle() string {
return s.inputTitle
}
func (s *state) Input() textinput.Model {
return s.input
}
func (s *state) UpdateInput(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
s.input, cmd = s.input.Update(msg)
return cmd
}
func (s *state) Tiers() []core.Tier {
return s.tiers
}
func (s *state) CursorRow() int {
return s.cursorRow
}
func (s *state) CursorCol() int {
return s.cursorCol
}
func (s *state) Dragging() bool {
return s.dragging
}
func (s *state) WindowHeight() int {
return s.windowHeight
}
func (s *state) WindowWidth() int {
return s.windowWidth
}
func (s *state) SetWindowSize(width int, height int) {
s.windowHeight = height
s.windowWidth = width
}
func (s *state) StatusMsg() string {
return s.statusMsg
}
func (s *state) SetStatus(msg string) {
s.statusMsg = msg
}
func (s *state) ToggleHelp() {
if s.mode == HelpMode {
s.mode = NavigationMode
} else {
s.mode = HelpMode
}
}
func (s *state) StartInputMode(title string, callback func(string, State) tea.Cmd) tea.Cmd {
s.mode = InputMode
s.inputTitle = title
s.InputCallback = callback
s.input.Reset()
s.input.Focus()
return textinput.Blink
}
func (s *state) ExitInputMode() {
s.mode = NavigationMode
s.input.Reset()
}
func (s *state) SubmitInput() tea.Cmd {
var cmd tea.Cmd
val := s.input.Value()
if s.InputCallback != nil {
cmd = s.InputCallback(val, s)
}
s.mode = NavigationMode
s.input.Reset()
return cmd
}
func (s *state) DeleteItem() tea.Cmd {
tier := &s.tiers[s.cursorRow]
if len(tier.Items) == 0 {
return nil
}
tier.Items = append(tier.Items[:s.cursorCol], tier.Items[s.cursorCol+1:]...)
s.ClampCursor()
s.SetStatus("Item Deleted")
return nil
}
func (s *state) ClampCursor() {
rowLen := len(s.tiers[s.cursorRow].Items)
if s.cursorCol >= rowLen {
if rowLen == 0 {
s.cursorCol = 0
return
}
s.cursorCol = rowLen - 1
}
}
func (s *state) AddItem() tea.Cmd {
return s.StartInputMode("Add new item", func(val string, s State) tea.Cmd {
if val != "" {
s.AddItemToPool(core.Item(val))
s.SetStatus("Added: " + val)
}
return nil
})
}
func (s *state) AddItemToPool(item core.Item) {
poolIndex := len(s.tiers) - 1
s.tiers[poolIndex].Items = append(s.tiers[poolIndex].Items, item)
}
func (s *state) UpdateItem() tea.Cmd {
if len(s.tiers[s.cursorRow].Items) == 0 {
return nil
}
currentItem := string(s.tiers[s.cursorRow].Items[s.cursorCol])
cmd := s.StartInputMode("Rename Item", func(val string, m State) tea.Cmd {
if val != "" {
m.UpdateCurrentItem(core.Item(val))
m.SetStatus("Renamed to: " + val)
}
return nil
})
s.input.SetValue(currentItem)
s.input.CursorEnd()
return cmd
}
func (s *state) UpdateCurrentItem(newItem core.Item) {
s.tiers[s.cursorRow].Items[s.cursorCol] = newItem
}
func (s *state) SelectItem() {
if s.dragging {
s.dragging = false
} else {
if len(s.tiers[s.cursorRow].Items) > 0 {
s.dragging = true
s.SourceRow = s.cursorRow
s.SourceCol = s.cursorCol
s.dragBuffer = s.tiers[s.cursorRow].Items[s.cursorCol]
}
}
}
func (s *state) MoveItem() {
sourceRow := s.SourceRow
sourceCol := s.SourceCol
item := s.dragBuffer
if sourceRow >= len(s.tiers) || sourceCol >= len(s.tiers[sourceRow].Items) {
return
}
oldRow := s.tiers[sourceRow].Items
s.tiers[sourceRow].Items = append(oldRow[:sourceCol], oldRow[sourceCol+1:]...)
targetRow := s.tiers[s.cursorRow].Items
if s.cursorCol > len(targetRow) {
s.cursorCol = len(targetRow)
}
s.SourceRow = s.cursorRow
s.SourceCol = s.cursorCol
newRow := make([]core.Item, len(targetRow)+1)
copy(newRow, targetRow[:s.cursorCol])
newRow[s.cursorCol] = item
copy(newRow[s.cursorCol+1:], targetRow[s.cursorCol:])
s.tiers[s.cursorRow].Items = newRow
s.SetStatus(fmt.Sprintf("Moved '%s' from %s to %s", item, s.tiers[sourceRow].Name, s.tiers[s.cursorRow].Name))
}
func (s *state) MoveRight() {
limit := len(s.tiers[s.cursorRow].Items) - 1
if s.dragging {
limit = len(s.tiers[s.cursorRow].Items)
}
if s.cursorCol < limit {
s.cursorCol++
} else if !s.dragging && len(s.tiers[s.cursorRow].Items) == 0 {
s.cursorCol = 0
}
if s.dragging {
s.MoveItem()
}
}
func (s *state) MoveUp() {
if s.cursorRow > 0 {
s.cursorRow--
s.ClampCursor()
if s.dragging {
s.MoveItem()
}
}
}
func (s *state) MoveDown() {
if s.cursorRow < len(s.tiers)-1 {
s.cursorRow++
s.ClampCursor()
if s.dragging {
s.MoveItem()
}
}
}
func (s *state) MoveLeft() {
if s.cursorCol > 0 {
s.cursorCol--
if s.dragging {
s.MoveItem()
}
}
}
func (s *state) Save() core.SaveState {
state := core.SaveState{}
for _, t := range s.tiers {
state.Tiers = append(state.Tiers, struct {
Name string `json:"name"`
Items []core.Item `json:"items"`
}{Name: t.Name, Items: t.Items})
}
return state
}
func (s *state) Load(data core.SaveState) {
if len(data.Tiers) == len(s.tiers) {
for i, savedTier := range data.Tiers {
s.tiers[i].Items = savedTier.Items
}
s.cursorRow = 0
s.cursorCol = 0
s.dragging = false
}
}
+263
View File
@@ -0,0 +1,263 @@
package state_test
import (
"testing"
"github.com/StevanFreeborn/term-tier/internal/core"
"github.com/StevanFreeborn/term-tier/internal/state"
tea "github.com/charmbracelet/bubbletea"
)
func TestNew_Defaults(t *testing.T) {
s := state.New()
if s.Mode() != state.NavigationMode {
t.Errorf("Expected default mode NavigationMode, got %v", s.Mode())
}
tiers := s.Tiers()
if len(tiers) != 6 {
t.Errorf("Expected 6 default tiers, got %d", len(tiers))
}
if tiers[0].Name != "S Tier" {
t.Errorf("Expected first tier to be 'S Tier', got '%s'", tiers[0].Name)
}
if s.CursorRow() != 5 {
t.Errorf("Expected cursor to start at row 5 (Pool), got %d", s.CursorRow())
}
if s.CursorCol() != 0 {
t.Errorf("Expected cursor to start at col 0, got %d", s.CursorCol())
}
}
func TestInputMode_Flow(t *testing.T) {
s := state.New()
triggered := false
s.StartInputMode("Title", func(v string, _ state.State) tea.Cmd {
triggered = true
return nil
})
if s.Mode() != state.InputMode {
t.Fatal("Failed to enter input mode")
}
s.SubmitInput()
if !triggered {
t.Error("SubmitInput failed to trigger callback")
}
if s.Mode() != state.NavigationMode {
t.Error("SubmitInput failed to reset mode to Navigation")
}
}
func TestAddItem(t *testing.T) {
s := state.New()
initialPoolSize := len(s.Tiers()[5].Items)
s.AddItemToPool("New Item")
newPool := s.Tiers()[5].Items
if len(newPool) != initialPoolSize+1 {
t.Errorf("Expected pool size to increase by 1, got %d", len(newPool))
}
if newPool[len(newPool)-1] != "New Item" {
t.Error("Item was not appended to the end of the pool")
}
}
func TestNavigation_Movement(t *testing.T) {
fullTiers := make([]core.Tier, 6)
fullTiers[0] = core.Tier{Name: "S", Items: []core.Item{"1", "2", "3"}}
fullTiers[1] = core.Tier{Name: "A", Items: []core.Item{"4"}}
s := state.New(state.WithTiers(fullTiers), state.WithCursor(0, 0))
s.MoveRight()
if s.CursorCol() != 1 {
t.Errorf("Expected Col 1, got %d", s.CursorCol())
}
s.MoveRight()
s.MoveRight()
if s.CursorCol() != 2 {
t.Errorf("Expected Col 2, got %d", s.CursorCol())
}
s.MoveLeft()
if s.CursorCol() != 1 {
t.Errorf("Expected Col 1 after MoveLeft, got %d", s.CursorCol())
}
s.MoveLeft()
s.MoveLeft()
if s.CursorCol() != 0 {
t.Error("Cursor went out of bounds left")
}
s.MoveRight()
s.MoveRight()
s.MoveDown()
if s.CursorRow() != 1 {
t.Errorf("Expected Row 1, got %d", s.CursorRow())
}
if s.CursorCol() != 0 {
t.Errorf("Expected Col to clamp to 0, got %d", s.CursorCol())
}
s.MoveUp()
if s.CursorRow() != 0 {
t.Error("Expected to return to Row 0")
}
}
func TestDragAndDrop(t *testing.T) {
tiers := make([]core.Tier, 6)
tiers[0] = core.Tier{Name: "0", Items: []core.Item{"A", "B"}}
tiers[1] = core.Tier{Name: "1", Items: []core.Item{"C"}}
s := state.New(state.WithTiers(tiers), state.WithCursor(0, 0))
s.SelectItem()
if !s.Dragging() {
t.Fatal("Expected dragging to be true")
}
s.MoveRight()
items := s.Tiers()[0].Items
if items[0] != "B" || items[1] != "A" {
t.Errorf("Expected swap [B, A], got %v", items)
}
s.MoveDown()
if len(s.Tiers()[0].Items) != 1 || s.Tiers()[0].Items[0] != "B" {
t.Error("Row 0 should only contain 'B'")
}
row1 := s.Tiers()[1].Items
if len(row1) != 2 {
t.Errorf("Row 1 should have 2 items, got %d", len(row1))
}
if row1[0] != "A" {
t.Errorf("Expected 'A' at index 0, got %v", row1)
}
s.SelectItem()
if s.Dragging() {
t.Error("Expected dragging to stop")
}
}
func TestDeleteItem(t *testing.T) {
tiers := make([]core.Tier, 6)
tiers[0] = core.Tier{Name: "0", Items: []core.Item{"DeleteMe", "KeepMe"}}
s := state.New(state.WithTiers(tiers), state.WithCursor(0, 0))
s.DeleteItem()
items := s.Tiers()[0].Items
if len(items) != 1 {
t.Fatalf("Expected 1 item remaining, got %d", len(items))
}
if items[0] != "KeepMe" {
t.Errorf("Expected 'KeepMe', got '%s'", items[0])
}
if s.StatusMsg() != "Item Deleted" {
t.Error("Status message not set")
}
}
func TestSaveLoad(t *testing.T) {
tiers := make([]core.Tier, 6)
tiers[0] = core.Tier{Name: "S", Items: []core.Item{"UniqueData"}}
s1 := state.New(state.WithTiers(tiers))
savedData := s1.Save()
s2 := state.New()
s2.Load(savedData)
loadedItem := s2.Tiers()[0].Items[0]
if loadedItem != "UniqueData" {
t.Errorf("Failed to persist data. Got '%s'", loadedItem)
}
}
func TestUpdateItem(t *testing.T) {
tiers := make([]core.Tier, 6)
tiers[0] = core.Tier{Name: "0", Items: []core.Item{"OldName"}}
s := state.New(state.WithTiers(tiers), state.WithCursor(0, 0))
cmd := s.UpdateItem()
if s.Mode() != state.InputMode {
t.Fatal("UpdateItem should trigger InputMode")
}
if cmd == nil {
t.Error("UpdateItem should return command")
}
if s.Input().Value() != "OldName" {
t.Errorf("Expected input to be pre-filled with 'OldName', got '%s'", s.Input().Value())
}
s.UpdateCurrentItem("NewName")
if s.Tiers()[0].Items[0] != "NewName" {
t.Errorf("Item was not renamed. Got '%s'", s.Tiers()[0].Items[0])
}
}
func TestWindowSize(t *testing.T) {
s := state.New()
s.SetWindowSize(100, 50)
if s.WindowWidth() != 100 || s.WindowHeight() != 50 {
t.Error("SetWindowSize failed")
}
}
func TestToggleHelp(t *testing.T) {
s := state.New(state.WithMode(state.NavigationMode))
s.ToggleHelp()
if s.Mode() != state.HelpMode {
t.Error("Expected HelpMode")
}
s.ToggleHelp()
if s.Mode() != state.NavigationMode {
t.Error("Expected NavigationMode")
}
}