diff --git a/internal/config/config.go b/internal/config/config.go index c9b3d93..17ccc84 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,6 +83,7 @@ type TagsConfig struct { type StateConfig struct { Name string `toml:"name"` Color string `toml:"color"` + Done bool `toml:"done,omitempty"` } // StatesConfig holds TODO state configurations @@ -163,7 +164,7 @@ func DefaultConfig() *Config { {Name: "TODO", Color: "202"}, {Name: "PROG", Color: "220"}, {Name: "BLOCK", Color: "196"}, - {Name: "DONE", Color: "34"}, + {Name: "DONE", Color: "34", Done: true}, }, DefaultNewTaskState: "TODO", }, @@ -393,6 +394,21 @@ func (c *Config) fillDefaults() { // Note: We don't fill DefaultNewTaskState if States.States is non-empty because // an empty string is a valid intentional value meaning "no default state". + // Back-compat: if states exist but none is flagged done (config predates the + // `done` field), treat the last state as done to preserve prior behavior. + if len(c.States.States) > 0 { + anyDone := false + for _, s := range c.States.States { + if s.Done { + anyDone = true + break + } + } + if !anyDone { + c.States.States[len(c.States.States)-1].Done = true + } + } + // Fill UI if zero values if c.UI.HelpTextWidth == 0 { c.UI.HelpTextWidth = defaults.UI.HelpTextWidth @@ -459,27 +475,36 @@ func (c *Config) UpdateTagColor(name, color string) { } } +// findState returns a pointer to the state with the given name, or nil if no +// such state is configured. The pointer aliases the backing slice, so callers +// may mutate the state in place. +func (c *Config) findState(name string) *StateConfig { + for i := range c.States.States { + if c.States.States[i].Name == name { + return &c.States.States[i] + } + } + return nil +} + // GetStateColor returns the color for a given state name func (c *Config) GetStateColor(stateName string) string { - for _, state := range c.States.States { - if state.Name == stateName { - return state.Color - } + if state := c.findState(stateName); state != nil { + return state.Color } // Return a default color if state not found return "99" } -// AddState adds a new state to the configuration -func (c *Config) AddState(name, color string) { - // Check if state already exists - for i, state := range c.States.States { - if state.Name == name { - c.States.States[i].Color = color - return - } +// AddState adds (or upgrades) a state in the configuration, marking it as a +// done state when done is true. +func (c *Config) AddState(name, color string, done bool) { + if state := c.findState(name); state != nil { + state.Color = color + state.Done = done + return } - c.States.States = append(c.States.States, StateConfig{Name: name, Color: color}) + c.States.States = append(c.States.States, StateConfig{Name: name, Color: color, Done: done}) } // RemoveState removes a state from the configuration @@ -494,11 +519,8 @@ func (c *Config) RemoveState(name string) { // UpdateStateColor updates the color of an existing state func (c *Config) UpdateStateColor(name, color string) { - for i, state := range c.States.States { - if state.Name == name { - c.States.States[i].Color = color - return - } + if state := c.findState(name); state != nil { + state.Color = color } } @@ -511,6 +533,17 @@ func (c *Config) GetStateNames() []string { return names } +// IsDoneState reports whether the named state is configured as a done state. +func (c *Config) IsDoneState(name string) bool { + if name == "" { + return false + } + if state := c.findState(name); state != nil { + return state.Done + } + return false +} + // UpdateKeybinding updates a keybinding in the configuration func (c *Config) UpdateKeybinding(action string, keys []string) error { // Use reflection would be complex, so we handle specific cases diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..6cb3111 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,73 @@ +package config + +import "testing" + +func TestIsDoneState(t *testing.T) { + c := &Config{States: StatesConfig{States: []StateConfig{ + {Name: "TODO", Color: "202"}, + {Name: "DONE", Color: "34", Done: true}, + {Name: "CANCELLED", Color: "240", Done: true}, + }}} + + cases := map[string]bool{"TODO": false, "DONE": true, "CANCELLED": true, "": false, "NOPE": false} + for name, want := range cases { + if got := c.IsDoneState(name); got != want { + t.Errorf("IsDoneState(%q) = %v, want %v", name, got, want) + } + } +} + +func TestFillDefaultsMarksLastStateDoneWhenNoneFlagged(t *testing.T) { + c := &Config{States: StatesConfig{States: []StateConfig{ + {Name: "TODO", Color: "202"}, + {Name: "DONE", Color: "34"}, + }}} + c.fillDefaults() + + if c.States.States[len(c.States.States)-1].Name != "DONE" { + t.Fatalf("states reordered unexpectedly") + } + if !c.States.States[len(c.States.States)-1].Done { + t.Error("expected last state (DONE) to be flagged Done after migration") + } + if c.States.States[0].Done { + t.Error("expected first state (TODO) to remain not-done") + } +} + +func TestFillDefaultsLeavesExplicitDoneFlagsAlone(t *testing.T) { + c := &Config{States: StatesConfig{States: []StateConfig{ + {Name: "DONE", Color: "34", Done: true}, + {Name: "TODO", Color: "202"}, + }}} + c.fillDefaults() + + if c.States.States[0].Name != "DONE" || !c.States.States[0].Done { + t.Error("explicit done flag on DONE should be preserved") + } + if c.States.States[1].Done { + t.Error("last state (TODO) must NOT be auto-flagged when a done state already exists") + } +} + +func TestAddDoneState(t *testing.T) { + c := &Config{} + c.AddState("CANCELLED", "240", true) + + if len(c.States.States) != 1 { + t.Fatalf("expected 1 state, got %d", len(c.States.States)) + } + s := c.States.States[0] + if s.Name != "CANCELLED" || s.Color != "240" || !s.Done { + t.Fatalf("unexpected state: %+v", s) + } + + // Adding an existing name upgrades it to done. + c.AddState("REVIEW", "99", false) + c.AddState("REVIEW", "99", true) + for _, st := range c.States.States { + if st.Name == "REVIEW" && !st.Done { + t.Fatal("expected REVIEW upgraded to done") + } + } +} diff --git a/internal/model/item.go b/internal/model/item.go index f926152..f571717 100644 --- a/internal/model/item.go +++ b/internal/model/item.go @@ -41,22 +41,6 @@ func (item *Item) ToggleFold() { item.Folded = !item.Folded } -// CycleState cycles through todo states -func (item *Item) CycleState() { - switch item.State { - case StateNone: - item.State = StateTODO - case StateTODO: - item.State = StatePROG - case StatePROG: - item.State = StateBLOCK - case StateBLOCK: - item.State = StateDONE - case StateDONE: - item.State = StateNone - } -} - // ClockIn starts a new clock entry func (item *Item) ClockIn() bool { // Check if already clocked in diff --git a/internal/model/state.go b/internal/model/state.go index ba6dc7e..b4c33db 100644 --- a/internal/model/state.go +++ b/internal/model/state.go @@ -4,9 +4,7 @@ package model type TodoState string const ( - StateTODO TodoState = "TODO" - StatePROG TodoState = "PROG" - StateBLOCK TodoState = "BLOCK" - StateDONE TodoState = "DONE" - StateNone TodoState = "" + // StateNone is the empty (no TODO keyword) state. Concrete TODO states are + // user-configurable (see config.States), so they are not hardcoded here. + StateNone TodoState = "" ) diff --git a/internal/ui/app.go b/internal/ui/app.go index 2de51e1..eee1a75 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -56,7 +56,8 @@ type uiModel struct { settingsSection settingsSection // Current settings section/tab captureCursor int // Store cursor position when entering capture mode datepicker datepicker.Model - dateTextFocused bool // within set-date modes: text field focused vs. calendar + dateTextFocused bool // within set-date modes: text field focused vs. calendar + stateForm stateForm // backing state for the add/edit state form } func InitialModel(orgFile *model.OrgFile, cfg *config.Config, captureMode bool, captureText string) uiModel { diff --git a/internal/ui/modes.go b/internal/ui/modes.go index b100a3c..a7eb151 100644 --- a/internal/ui/modes.go +++ b/internal/ui/modes.go @@ -39,8 +39,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSettings(msg) case modeSettingsAddTag: return m.updateSettingsAddTag(msg) - case modeSettingsAddState: - return m.updateSettingsAddState(msg) + case modeSettingsStateForm: + return m.updateSettingsStateForm(msg) case modeTagEdit: return m.updateTagEdit(msg) case modeRename: @@ -103,7 +103,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(items) > 0 && m.cursor < len(items) { m.cycleStateBackward(items[m.cursor]) // Auto clock out when changing to DONE - if items[m.cursor].State == model.StateDONE && items[m.cursor].IsClockedIn() { + if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() { items[m.cursor].ClockOut() } m.setStatus("State changed") @@ -114,8 +114,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(items) > 0 && m.cursor < len(items) { m.cycleStateForward(items[m.cursor]) // Auto clock out when changing to last state (typically DONE) - stateNames := m.config.GetStateNames() - if len(stateNames) > 0 && string(items[m.cursor].State) == stateNames[len(stateNames)-1] && items[m.cursor].IsClockedIn() { + if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() { items[m.cursor].ClockOut() } m.setStatus("State changed") @@ -138,8 +137,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(items) > 0 && m.cursor < len(items) { m.cycleStateForward(items[m.cursor]) // Auto clock out when changing to last state (typically DONE) - stateNames := m.config.GetStateNames() - if len(stateNames) > 0 && string(items[m.cursor].State) == stateNames[len(stateNames)-1] && items[m.cursor].IsClockedIn() { + if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() { items[m.cursor].ClockOut() } m.setStatus("State changed") @@ -870,6 +868,30 @@ func (m uiModel) updateSetEffort(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// stripClosedNotes removes any note line that is a CLOSED: planning entry. +func stripClosedNotes(notes []string) []string { + var filtered []string + for _, note := range notes { + if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") { + filtered = append(filtered, note) + } + } + return filtered +} + +// applyClosedTransition stamps or clears the CLOSED timestamp based on whether +// the item entered or left the set of done states. +func applyClosedTransition(item *model.Item, wasDone, isDone bool, now time.Time) { + switch { + case isDone && !wasDone: + item.Closed = &now + item.Notes = stripClosedNotes(item.Notes) + case wasDone && !isDone: + item.Closed = nil + item.Notes = stripClosedNotes(item.Notes) + } +} + func (m *uiModel) cycleStateForward(item *model.Item) { stateNames := m.config.GetStateNames() if len(stateNames) == 0 { @@ -879,7 +901,6 @@ func (m *uiModel) cycleStateForward(item *model.Item) { // Find current state index currentIndex := -1 currentState := string(item.State) - lastStateIndex := len(stateNames) - 1 // Handle empty state if currentState == "" { @@ -911,34 +932,8 @@ func (m *uiModel) cycleStateForward(item *model.Item) { // Update the item state item.State = model.TodoState(newState) - // Manage CLOSED timestamp - wasInDoneState := (oldState == stateNames[lastStateIndex]) - isInDoneState := (newState == stateNames[lastStateIndex]) - - if isInDoneState && !wasInDoneState { - // Moving TO done state - add CLOSED timestamp - now := time.Now() - item.Closed = &now - // Remove any existing CLOSED line from notes - var filteredNotes []string - for _, note := range item.Notes { - if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") { - filteredNotes = append(filteredNotes, note) - } - } - item.Notes = filteredNotes - } else if wasInDoneState && !isInDoneState { - // Moving FROM done state - remove CLOSED timestamp - item.Closed = nil - // Remove any existing CLOSED line from notes - var filteredNotes []string - for _, note := range item.Notes { - if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") { - filteredNotes = append(filteredNotes, note) - } - } - item.Notes = filteredNotes - } + // Manage CLOSED timestamp based on the configured done-state set. + applyClosedTransition(item, m.config.IsDoneState(oldState), m.config.IsDoneState(newState), time.Now()) } func (m *uiModel) cycleStateBackward(item *model.Item) { @@ -950,7 +945,6 @@ func (m *uiModel) cycleStateBackward(item *model.Item) { // Find current state index currentIndex := -1 currentState := string(item.State) - lastStateIndex := len(stateNames) - 1 // Handle empty state if currentState == "" { @@ -980,34 +974,8 @@ func (m *uiModel) cycleStateBackward(item *model.Item) { // Update the item state item.State = model.TodoState(newState) - // Manage CLOSED timestamp - wasInDoneState := (oldState == stateNames[lastStateIndex]) - isInDoneState := (newState == stateNames[lastStateIndex]) - - if isInDoneState && !wasInDoneState { - // Moving TO done state - add CLOSED timestamp - now := time.Now() - item.Closed = &now - // Remove any existing CLOSED line from notes - var filteredNotes []string - for _, note := range item.Notes { - if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") { - filteredNotes = append(filteredNotes, note) - } - } - item.Notes = filteredNotes - } else if wasInDoneState && !isInDoneState { - // Moving FROM done state - remove CLOSED timestamp - item.Closed = nil - // Remove any existing CLOSED line from notes - var filteredNotes []string - for _, note := range item.Notes { - if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") { - filteredNotes = append(filteredNotes, note) - } - } - item.Notes = filteredNotes - } + // Manage CLOSED timestamp based on the configured done-state set. + applyClosedTransition(item, m.config.IsDoneState(oldState), m.config.IsDoneState(newState), time.Now()) } func (m *uiModel) deleteItem(item *model.Item) { diff --git a/internal/ui/modes_test.go b/internal/ui/modes_test.go new file mode 100644 index 0000000..61648ff --- /dev/null +++ b/internal/ui/modes_test.go @@ -0,0 +1,51 @@ +package ui + +import ( + "testing" + "time" + + "github.com/rwejlgaard/org/internal/model" +) + +func TestApplyClosedTransitionEntersDone(t *testing.T) { + now := time.Date(2026, 7, 21, 14, 30, 0, 0, time.UTC) + item := &model.Item{Notes: []string{"a note", "CLOSED: [old stamp]"}} + + applyClosedTransition(item, false, true, now) + + if item.Closed == nil || !item.Closed.Equal(now) { + t.Fatalf("expected Closed set to now, got %v", item.Closed) + } + if len(item.Notes) != 1 || item.Notes[0] != "a note" { + t.Fatalf("expected stale CLOSED note stripped, got %v", item.Notes) + } +} + +func TestApplyClosedTransitionLeavesDone(t *testing.T) { + now := time.Now() + stamp := now + item := &model.Item{Closed: &stamp, Notes: []string{" CLOSED: [x]", "keep"}} + + applyClosedTransition(item, true, false, now) + + if item.Closed != nil { + t.Fatalf("expected Closed cleared, got %v", item.Closed) + } + if len(item.Notes) != 1 || item.Notes[0] != "keep" { + t.Fatalf("expected CLOSED note stripped, got %v", item.Notes) + } +} + +func TestApplyClosedTransitionDoneToDoneUnchanged(t *testing.T) { + orig := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) + item := &model.Item{Closed: &orig, Notes: []string{"keep"}} + + applyClosedTransition(item, true, true, time.Now()) + + if item.Closed == nil || !item.Closed.Equal(orig) { + t.Fatalf("done->done must keep original Closed, got %v", item.Closed) + } + if len(item.Notes) != 1 || item.Notes[0] != "keep" { + t.Fatalf("done->done must not touch notes, got %v", item.Notes) + } +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 528b7d2..dafdc32 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -107,7 +107,8 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) { case settingsSectionTags: m.addNewTag() case settingsSectionStates: - m.addNewState() + // Rows: [0]=default state, [1..N]=states, [N+1]=add state. + m.startStateForm(false, 0) case settingsSectionKeybindings: // Cannot add keybindings yet } @@ -136,7 +137,7 @@ func (m *uiModel) getSettingsItemCount() int { case settingsSectionTags: return len(m.config.Tags.Tags) + 1 // +1 for "Add new tag" option case settingsSectionStates: - return len(m.config.States.States) + 2 // +1 for "Default new task state" setting, +1 for "Add new state" option + return len(m.config.States.States) + 2 // default state + "Add new state" case settingsSectionKeybindings: return len(m.config.GetAllKeybindings()) default: @@ -232,12 +233,11 @@ func (m *uiModel) startSettingsEdit() { // Adjust for the default state setting offset stateIndex := m.settingsCursor - 1 if stateIndex >= len(m.config.States.States) { + // On the "Add new state" row: open the form in add mode. + m.startStateForm(false, 0) return } - state := m.config.States.States[stateIndex] - m.textinput.SetValue(state.Name + "," + state.Color) - m.textinput.Placeholder = "name,color (e.g., TODO,202)" - m.textinput.Focus() + m.startStateForm(true, stateIndex) case settingsSectionKeybindings: // Edit keybinding @@ -328,23 +328,6 @@ func (m *uiModel) saveSettingsEdit() { return } - // Adjust for the default state setting offset - stateIndex := m.settingsCursor - 1 - if stateIndex >= len(m.config.States.States) { - return - } - // Parse "name,color" format - parts := strings.Split(m.textinput.Value(), ",") - if len(parts) >= 2 { - state := &m.config.States.States[stateIndex] - state.Name = strings.TrimSpace(parts[0]) - state.Color = strings.TrimSpace(parts[1]) - m.setStatus(fmt.Sprintf("Updated state '%s' (saved)", state.Name)) - } else { - m.setStatus("Invalid format. Use: name,color") - return - } - case settingsSectionKeybindings: // Save keybinding keybindings := m.config.GetAllKeybindings() @@ -472,17 +455,6 @@ func (m *uiModel) addNewTag() { m.mode = modeSettingsAddTag } -// addNewState adds a new state -func (m *uiModel) addNewState() { - m.textinput.SetValue("") - m.textinput.Placeholder = "Enter state name" - m.textinput.Focus() - m.textinput.Blur() // Will be refocused when user types - - // Prompt for state name first, then color - m.mode = modeSettingsAddState -} - // viewSettings renders the settings view func (m *uiModel) viewSettings() string { var content strings.Builder @@ -741,6 +713,9 @@ func (m *uiModel) viewSettingsStates() string { stateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(state.Color)) line += stateStyle.Render(state.Name) line += fmt.Sprintf(" (color: %s)", state.Color) + if state.Done { + line += m.styles.statusStyle.Render(" ✓done") + } content.WriteString(line + "\n") } @@ -844,8 +819,8 @@ func (m *uiModel) viewSettingsKeybindings() string { // modeSettingsAddTag is a special mode for adding tags const modeSettingsAddTag viewMode = 100 -// modeSettingsAddState is a special mode for adding states -const modeSettingsAddState viewMode = 101 +// modeSettingsStateForm is the add/edit state form mode. +const modeSettingsStateForm viewMode = 102 // updateSettingsAddTag handles the add tag flow func (m *uiModel) updateSettingsAddTag(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -898,57 +873,6 @@ func (m *uiModel) viewSettingsAddTag() string { return content.String() } -// updateSettingsAddState handles the add state flow -func (m *uiModel) updateSettingsAddState(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if !m.textinput.Focused() { - m.textinput.Focus() - } - - switch { - case msg.Type == tea.KeyEsc: - m.textinput.Blur() - m.mode = modeSettings - return m, nil - - case msg.Type == tea.KeyEnter: - stateName := m.textinput.Value() - if stateName != "" { - // Default color - m.config.AddState(stateName, "99") - // Auto-save - if err := m.config.Save(); err != nil { - m.setStatus(fmt.Sprintf("Error saving: %v", err)) - } else { - m.setStatus(fmt.Sprintf("Added state '%s' (saved)", stateName)) - } - } - m.textinput.Blur() - m.mode = modeSettings - return m, nil - - default: - var cmd tea.Cmd - m.textinput, cmd = m.textinput.Update(msg) - return m, cmd - } - } - - return m, nil -} - -// viewSettingsAddState renders the add state view -func (m *uiModel) viewSettingsAddState() string { - var content strings.Builder - - content.WriteString(m.styles.titleStyle.Render("Add New State") + "\n\n") - content.WriteString(m.textinput.View() + "\n\n") - content.WriteString(m.styles.statusStyle.Render("Enter state name • Press Enter to add • ESC to cancel") + "\n") - - return content.String() -} - // moveSettingsItemUp moves the current settings item up and auto-saves func (m *uiModel) moveSettingsItemUp() { switch m.settingsSection { diff --git a/internal/ui/stateform.go b/internal/ui/stateform.go new file mode 100644 index 0000000..7835b6e --- /dev/null +++ b/internal/ui/stateform.go @@ -0,0 +1,214 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// stateForm backs the add/edit state form (modeSettingsStateForm). +// field: 0 = name, 1 = color, 2 = done. +type stateForm struct { + editing bool + index int // index into config.States.States when editing + name textinput.Model + color textinput.Model + done bool + field int +} + +// startStateForm initializes the form and switches into the form mode. +// editing=false adds a new state; editing=true edits config.States.States[index]. +func (m *uiModel) startStateForm(editing bool, index int) { + name := textinput.New() + name.Placeholder = "STATE NAME" + name.CharLimit = 32 + name.Width = 30 + + color := textinput.New() + color.Placeholder = "0-255" + color.CharLimit = 3 + color.Width = 12 + + sf := stateForm{editing: editing, index: index, name: name, color: color} + if editing { + st := m.config.States.States[index] + sf.name.SetValue(st.Name) + sf.color.SetValue(st.Color) + sf.done = st.Done + sf.field = 1 // name is read-only when editing + } else { + sf.color.SetValue("99") + sf.field = 0 + } + + m.stateForm = sf + m.focusStateFormField() + m.mode = modeSettingsStateForm +} + +// stateFormMinField is the lowest navigable field: name (0) when adding, +// color (1) when editing (name is read-only). +func (m *uiModel) stateFormMinField() int { + if m.stateForm.editing { + return 1 + } + return 0 +} + +// moveStateFormField shifts the focused field by delta, clamped to [min, 2]. +func (m *uiModel) moveStateFormField(delta int) { + f := m.stateForm.field + delta + if min := m.stateFormMinField(); f < min { + f = min + } + if f > 2 { + f = 2 + } + m.stateForm.field = f + m.focusStateFormField() +} + +// focusStateFormField focuses the active text field (none for the Done field). +func (m *uiModel) focusStateFormField() { + m.stateForm.name.Blur() + m.stateForm.color.Blur() + switch m.stateForm.field { + case 0: + m.stateForm.name.Focus() + case 1: + m.stateForm.color.Focus() + } +} + +// applyStateForm commits the form to config. It mutates config only (no Save). +// Returns false (without mutating) if a new state's name is empty. +func (m *uiModel) applyStateForm() bool { + sf := &m.stateForm + color := strings.TrimSpace(sf.color.Value()) + + if sf.editing { + st := &m.config.States.States[sf.index] + if color != "" { + st.Color = color + } + st.Done = sf.done + m.setStatus(fmt.Sprintf("Updated state '%s' (saved)", st.Name)) + return true + } + + name := strings.ToUpper(strings.TrimSpace(sf.name.Value())) + if name == "" { + m.setStatus("State name required") + return false + } + if color == "" { + color = "99" + } + m.config.AddState(name, color, sf.done) + m.setStatus(fmt.Sprintf("Added state '%s' (saved)", name)) + return true +} + +// updateSettingsStateForm handles key input while the state form is open. +func (m *uiModel) updateSettingsStateForm(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch keyMsg.Type { + case tea.KeyEsc: + m.mode = modeSettings + return m, nil + + case tea.KeyEnter: + if m.applyStateForm() { + if err := m.config.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err)) + } else { + m.keys = newKeyMapFromConfig(m.config) + m.styles = newStyleMapFromConfig(m.config) + } + m.mode = modeSettings + } + return m, nil + + case tea.KeyTab, tea.KeyDown: + m.moveStateFormField(1) + return m, nil + + case tea.KeyShiftTab, tea.KeyUp: + m.moveStateFormField(-1) + return m, nil + + case tea.KeySpace: + if m.stateForm.field == 2 { + m.stateForm.done = !m.stateForm.done + return m, nil + } + } + + // Route printable input to the focused text field. + var cmd tea.Cmd + switch m.stateForm.field { + case 0: + m.stateForm.name, cmd = m.stateForm.name.Update(msg) + case 1: + m.stateForm.color, cmd = m.stateForm.color.Update(msg) + } + return m, cmd +} + +// viewSettingsStateForm renders the add/edit state form. +func (m *uiModel) viewSettingsStateForm() string { + var b strings.Builder + + title := "Add New State" + if m.stateForm.editing { + title = "Edit State: " + m.config.States.States[m.stateForm.index].Name + } + b.WriteString(m.styles.titleStyle.Render(title) + "\n\n") + + cursor := func(field int) string { + if m.stateForm.field == field { + return "▶ " + } + return " " + } + + // Name row: editable input when adding, read-only label when editing. + if m.stateForm.editing { + b.WriteString(" Name " + m.stateForm.name.Value() + m.styles.statusStyle.Render(" (fixed)") + "\n") + } else { + b.WriteString(cursor(0) + "Name " + m.stateForm.name.View() + "\n") + } + + // Color row with a live swatch. + colorVal := strings.TrimSpace(m.stateForm.color.Value()) + swatch := "" + if colorVal != "" { + label := m.stateForm.name.Value() + if label == "" { + label = "sample" + } + sw := lipgloss.NewStyle().Foreground(lipgloss.Color(colorVal)) + swatch = " " + sw.Render("●"+label) + } + b.WriteString(cursor(1) + "Color " + m.stateForm.color.View() + swatch + "\n") + + // Done row: a checkbox. + box := "[ ] no" + if m.stateForm.done { + box = "[✓] yes" + } + b.WriteString(cursor(2) + "Done " + box + "\n\n") + + b.WriteString(m.styles.statusStyle.Render( + "tab/↑↓: move field • space: toggle done • enter: save • esc: cancel") + "\n") + + return b.String() +} diff --git a/internal/ui/stateform_test.go b/internal/ui/stateform_test.go new file mode 100644 index 0000000..1030349 --- /dev/null +++ b/internal/ui/stateform_test.go @@ -0,0 +1,106 @@ +package ui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/rwejlgaard/org/internal/config" +) + +func newTestModel(states []config.StateConfig) *uiModel { + return &uiModel{ + config: &config.Config{States: config.StatesConfig{States: states}}, + } +} + +func inputWith(val string) textinput.Model { + ti := textinput.New() + ti.SetValue(val) + return ti +} + +func TestApplyStateFormAddsPlainState(t *testing.T) { + m := newTestModel(nil) + m.stateForm = stateForm{editing: false, name: inputWith("review"), color: inputWith("202"), done: false} + + if !m.applyStateForm() { + t.Fatalf("expected commit to succeed") + } + if len(m.config.States.States) != 1 { + t.Fatalf("expected 1 state, got %d", len(m.config.States.States)) + } + s := m.config.States.States[0] + if s.Name != "REVIEW" || s.Color != "202" || s.Done { + t.Fatalf("got %+v, want {REVIEW 202 false}", s) + } +} + +func TestApplyStateFormAddsDoneState(t *testing.T) { + m := newTestModel(nil) + m.stateForm = stateForm{editing: false, name: inputWith("cancelled"), color: inputWith("240"), done: true} + + m.applyStateForm() + + s := m.config.States.States[0] + if !s.Done { + t.Fatalf("expected Done=true, got %+v", s) + } +} + +func TestApplyStateFormEditUpdatesColorAndDone(t *testing.T) { + m := newTestModel([]config.StateConfig{{Name: "TODO", Color: "202"}}) + m.stateForm = stateForm{editing: true, index: 0, name: inputWith("TODO"), color: inputWith("50"), done: true} + + if !m.applyStateForm() { + t.Fatalf("expected commit to succeed") + } + s := m.config.States.States[0] + if s.Name != "TODO" || s.Color != "50" || !s.Done { + t.Fatalf("got %+v, want {TODO 50 true}", s) + } + if len(m.config.States.States) != 1 { + t.Fatalf("edit must not add a state, got %d", len(m.config.States.States)) + } +} + +func TestApplyStateFormEmptyNameRejected(t *testing.T) { + m := newTestModel(nil) + m.stateForm = stateForm{editing: false, name: inputWith(" "), color: inputWith("202")} + + if m.applyStateForm() { + t.Fatalf("expected commit to fail on empty name") + } + if len(m.config.States.States) != 0 { + t.Fatalf("expected no state added, got %d", len(m.config.States.States)) + } +} + +func TestMoveStateFormFieldClampsForEdit(t *testing.T) { + m := newTestModel([]config.StateConfig{{Name: "TODO", Color: "202"}}) + m.startStateForm(true, 0) + + if m.stateForm.field != 1 { + t.Fatalf("edit form should start on color (field 1), got %d", m.stateForm.field) + } + m.moveStateFormField(-5) + if m.stateForm.field != 1 { + t.Fatalf("edit form must not move above color, got %d", m.stateForm.field) + } + m.moveStateFormField(5) + if m.stateForm.field != 2 { + t.Fatalf("field should clamp to 2 (done), got %d", m.stateForm.field) + } +} + +func TestMoveStateFormFieldAddStartsAtName(t *testing.T) { + m := newTestModel(nil) + m.startStateForm(false, 0) + + if m.stateForm.field != 0 { + t.Fatalf("add form should start on name (field 0), got %d", m.stateForm.field) + } + m.moveStateFormField(-5) + if m.stateForm.field != 0 { + t.Fatalf("add form must not move above name, got %d", m.stateForm.field) + } +} diff --git a/internal/ui/views.go b/internal/ui/views.go index 101d087..7976546 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -90,8 +90,8 @@ func (m uiModel) View() string { return m.viewSettings() case modeSettingsAddTag: return m.viewSettingsAddTag() - case modeSettingsAddState: - return m.viewSettingsAddState() + case modeSettingsStateForm: + return m.viewSettingsStateForm() case modeTagEdit: return m.viewTagEdit() case modeRename: