feat: add configurable done flag to TODO states (#21)
Some checks failed
Release / Create GitHub Release (push) Has been cancelled
Release / Build binaries (push) Has been cancelled

* feat: add configurable done flag to TODO states

* feat: stamp CLOSED for any configured done state

* feat: add DONE state option and done marker to settings

* chore: remove unused Item.CycleState

* chore: drop vestigial state constants, mention DONE add in settings help

* feat: add state form model and commit logic

* feat: wire state form update/view into settings dispatch

* feat: replace state add/edit with unified form, toggle done after creation

* consolidate AddState and AddDoneState

* add findState() helper
This commit is contained in:
Taybin Rutkin 2026-07-22 14:49:33 -04:00 committed by GitHub
parent 25dd231073
commit 18fc84646f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 547 additions and 195 deletions

View file

@ -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

View file

@ -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")
}
}
}

View file

@ -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

View file

@ -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 = ""
)

View file

@ -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 {

View file

@ -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) {

51
internal/ui/modes_test.go Normal file
View file

@ -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)
}
}

View file

@ -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 {

214
internal/ui/stateform.go Normal file
View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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: