From 7b4f9d5a4f02c0dc5dbb6df3d4cccc77367e95b7 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 29 Dec 2025 12:47:49 -0600 Subject: [PATCH] feat: implement state management and input handling for tier system --- internal/state/state.go | 413 +++++++++++++++++++++++++++++++++++ internal/state/state_test.go | 263 ++++++++++++++++++++++ 2 files changed, 676 insertions(+) create mode 100644 internal/state/state.go create mode 100644 internal/state/state_test.go diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..4e8f9e2 --- /dev/null +++ b/internal/state/state.go @@ -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 + } +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..384b461 --- /dev/null +++ b/internal/state/state_test.go @@ -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") + } +}