mirror of
https://github.com/RWejlgaard/org.git
synced 2026-09-01 17:05:34 +00:00
feat: add configurable done flag to TODO states (#21)
* 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:
parent
25dd231073
commit
18fc84646f
11 changed files with 547 additions and 195 deletions
|
|
@ -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
|
||||
|
|
|
|||
73
internal/config/config_test.go
Normal file
73
internal/config/config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
51
internal/ui/modes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
214
internal/ui/stateform.go
Normal 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()
|
||||
}
|
||||
106
internal/ui/stateform_test.go
Normal file
106
internal/ui/stateform_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue