diff --git a/internal/ui/app.go b/internal/ui/app.go index 11e8a57..b3c9fdf 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -56,8 +56,9 @@ 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 - addingDoneState bool // within add-state flow: create a done state + dateTextFocused bool // within set-date modes: text field focused vs. calendar + addingDoneState bool // within add-state flow: create a done state (legacy; removed in state-form task) + 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/settings.go b/internal/ui/settings.go index e969591..9759311 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -874,6 +874,9 @@ func (m *uiModel) viewSettingsKeybindings() string { // modeSettingsAddTag is a special mode for adding tags const modeSettingsAddTag viewMode = 100 +// modeSettingsStateForm is the add/edit state form mode. +const modeSettingsStateForm viewMode = 102 + // modeSettingsAddState is a special mode for adding states const modeSettingsAddState viewMode = 101 diff --git a/internal/ui/stateform.go b/internal/ui/stateform.go new file mode 100644 index 0000000..cd28283 --- /dev/null +++ b/internal/ui/stateform.go @@ -0,0 +1,116 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" +) + +// 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" + } + if sf.done { + m.config.AddDoneState(name, color) + } else { + m.config.AddState(name, color) + } + m.setStatus(fmt.Sprintf("Added state '%s' (saved)", name)) + return true +} 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) + } +}