feat: implement state management and input handling for tier system
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user