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 {
|
type StateConfig struct {
|
||||||
Name string `toml:"name"`
|
Name string `toml:"name"`
|
||||||
Color string `toml:"color"`
|
Color string `toml:"color"`
|
||||||
|
Done bool `toml:"done,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatesConfig holds TODO state configurations
|
// StatesConfig holds TODO state configurations
|
||||||
|
|
@ -163,7 +164,7 @@ func DefaultConfig() *Config {
|
||||||
{Name: "TODO", Color: "202"},
|
{Name: "TODO", Color: "202"},
|
||||||
{Name: "PROG", Color: "220"},
|
{Name: "PROG", Color: "220"},
|
||||||
{Name: "BLOCK", Color: "196"},
|
{Name: "BLOCK", Color: "196"},
|
||||||
{Name: "DONE", Color: "34"},
|
{Name: "DONE", Color: "34", Done: true},
|
||||||
},
|
},
|
||||||
DefaultNewTaskState: "TODO",
|
DefaultNewTaskState: "TODO",
|
||||||
},
|
},
|
||||||
|
|
@ -393,6 +394,21 @@ func (c *Config) fillDefaults() {
|
||||||
// Note: We don't fill DefaultNewTaskState if States.States is non-empty because
|
// 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".
|
// 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
|
// Fill UI if zero values
|
||||||
if c.UI.HelpTextWidth == 0 {
|
if c.UI.HelpTextWidth == 0 {
|
||||||
c.UI.HelpTextWidth = defaults.UI.HelpTextWidth
|
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
|
// GetStateColor returns the color for a given state name
|
||||||
func (c *Config) GetStateColor(stateName string) string {
|
func (c *Config) GetStateColor(stateName string) string {
|
||||||
for _, state := range c.States.States {
|
if state := c.findState(stateName); state != nil {
|
||||||
if state.Name == stateName {
|
return state.Color
|
||||||
return state.Color
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Return a default color if state not found
|
// Return a default color if state not found
|
||||||
return "99"
|
return "99"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddState adds a new state to the configuration
|
// AddState adds (or upgrades) a state in the configuration, marking it as a
|
||||||
func (c *Config) AddState(name, color string) {
|
// done state when done is true.
|
||||||
// Check if state already exists
|
func (c *Config) AddState(name, color string, done bool) {
|
||||||
for i, state := range c.States.States {
|
if state := c.findState(name); state != nil {
|
||||||
if state.Name == name {
|
state.Color = color
|
||||||
c.States.States[i].Color = color
|
state.Done = done
|
||||||
return
|
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
|
// 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
|
// UpdateStateColor updates the color of an existing state
|
||||||
func (c *Config) UpdateStateColor(name, color string) {
|
func (c *Config) UpdateStateColor(name, color string) {
|
||||||
for i, state := range c.States.States {
|
if state := c.findState(name); state != nil {
|
||||||
if state.Name == name {
|
state.Color = color
|
||||||
c.States.States[i].Color = color
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -511,6 +533,17 @@ func (c *Config) GetStateNames() []string {
|
||||||
return names
|
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
|
// UpdateKeybinding updates a keybinding in the configuration
|
||||||
func (c *Config) UpdateKeybinding(action string, keys []string) error {
|
func (c *Config) UpdateKeybinding(action string, keys []string) error {
|
||||||
// Use reflection would be complex, so we handle specific cases
|
// 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
|
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
|
// ClockIn starts a new clock entry
|
||||||
func (item *Item) ClockIn() bool {
|
func (item *Item) ClockIn() bool {
|
||||||
// Check if already clocked in
|
// Check if already clocked in
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ package model
|
||||||
type TodoState string
|
type TodoState string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StateTODO TodoState = "TODO"
|
// StateNone is the empty (no TODO keyword) state. Concrete TODO states are
|
||||||
StatePROG TodoState = "PROG"
|
// user-configurable (see config.States), so they are not hardcoded here.
|
||||||
StateBLOCK TodoState = "BLOCK"
|
StateNone TodoState = ""
|
||||||
StateDONE TodoState = "DONE"
|
|
||||||
StateNone TodoState = ""
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ type uiModel struct {
|
||||||
settingsSection settingsSection // Current settings section/tab
|
settingsSection settingsSection // Current settings section/tab
|
||||||
captureCursor int // Store cursor position when entering capture mode
|
captureCursor int // Store cursor position when entering capture mode
|
||||||
datepicker datepicker.Model
|
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 {
|
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)
|
return m.updateSettings(msg)
|
||||||
case modeSettingsAddTag:
|
case modeSettingsAddTag:
|
||||||
return m.updateSettingsAddTag(msg)
|
return m.updateSettingsAddTag(msg)
|
||||||
case modeSettingsAddState:
|
case modeSettingsStateForm:
|
||||||
return m.updateSettingsAddState(msg)
|
return m.updateSettingsStateForm(msg)
|
||||||
case modeTagEdit:
|
case modeTagEdit:
|
||||||
return m.updateTagEdit(msg)
|
return m.updateTagEdit(msg)
|
||||||
case modeRename:
|
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) {
|
if len(items) > 0 && m.cursor < len(items) {
|
||||||
m.cycleStateBackward(items[m.cursor])
|
m.cycleStateBackward(items[m.cursor])
|
||||||
// Auto clock out when changing to DONE
|
// 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()
|
items[m.cursor].ClockOut()
|
||||||
}
|
}
|
||||||
m.setStatus("State changed")
|
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) {
|
if len(items) > 0 && m.cursor < len(items) {
|
||||||
m.cycleStateForward(items[m.cursor])
|
m.cycleStateForward(items[m.cursor])
|
||||||
// Auto clock out when changing to last state (typically DONE)
|
// Auto clock out when changing to last state (typically DONE)
|
||||||
stateNames := m.config.GetStateNames()
|
if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() {
|
||||||
if len(stateNames) > 0 && string(items[m.cursor].State) == stateNames[len(stateNames)-1] && items[m.cursor].IsClockedIn() {
|
|
||||||
items[m.cursor].ClockOut()
|
items[m.cursor].ClockOut()
|
||||||
}
|
}
|
||||||
m.setStatus("State changed")
|
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) {
|
if len(items) > 0 && m.cursor < len(items) {
|
||||||
m.cycleStateForward(items[m.cursor])
|
m.cycleStateForward(items[m.cursor])
|
||||||
// Auto clock out when changing to last state (typically DONE)
|
// Auto clock out when changing to last state (typically DONE)
|
||||||
stateNames := m.config.GetStateNames()
|
if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() {
|
||||||
if len(stateNames) > 0 && string(items[m.cursor].State) == stateNames[len(stateNames)-1] && items[m.cursor].IsClockedIn() {
|
|
||||||
items[m.cursor].ClockOut()
|
items[m.cursor].ClockOut()
|
||||||
}
|
}
|
||||||
m.setStatus("State changed")
|
m.setStatus("State changed")
|
||||||
|
|
@ -870,6 +868,30 @@ func (m uiModel) updateSetEffort(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, 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) {
|
func (m *uiModel) cycleStateForward(item *model.Item) {
|
||||||
stateNames := m.config.GetStateNames()
|
stateNames := m.config.GetStateNames()
|
||||||
if len(stateNames) == 0 {
|
if len(stateNames) == 0 {
|
||||||
|
|
@ -879,7 +901,6 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
|
||||||
// Find current state index
|
// Find current state index
|
||||||
currentIndex := -1
|
currentIndex := -1
|
||||||
currentState := string(item.State)
|
currentState := string(item.State)
|
||||||
lastStateIndex := len(stateNames) - 1
|
|
||||||
|
|
||||||
// Handle empty state
|
// Handle empty state
|
||||||
if currentState == "" {
|
if currentState == "" {
|
||||||
|
|
@ -911,34 +932,8 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
|
||||||
// Update the item state
|
// Update the item state
|
||||||
item.State = model.TodoState(newState)
|
item.State = model.TodoState(newState)
|
||||||
|
|
||||||
// Manage CLOSED timestamp
|
// Manage CLOSED timestamp based on the configured done-state set.
|
||||||
wasInDoneState := (oldState == stateNames[lastStateIndex])
|
applyClosedTransition(item, m.config.IsDoneState(oldState), m.config.IsDoneState(newState), time.Now())
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) cycleStateBackward(item *model.Item) {
|
func (m *uiModel) cycleStateBackward(item *model.Item) {
|
||||||
|
|
@ -950,7 +945,6 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
|
||||||
// Find current state index
|
// Find current state index
|
||||||
currentIndex := -1
|
currentIndex := -1
|
||||||
currentState := string(item.State)
|
currentState := string(item.State)
|
||||||
lastStateIndex := len(stateNames) - 1
|
|
||||||
|
|
||||||
// Handle empty state
|
// Handle empty state
|
||||||
if currentState == "" {
|
if currentState == "" {
|
||||||
|
|
@ -980,34 +974,8 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
|
||||||
// Update the item state
|
// Update the item state
|
||||||
item.State = model.TodoState(newState)
|
item.State = model.TodoState(newState)
|
||||||
|
|
||||||
// Manage CLOSED timestamp
|
// Manage CLOSED timestamp based on the configured done-state set.
|
||||||
wasInDoneState := (oldState == stateNames[lastStateIndex])
|
applyClosedTransition(item, m.config.IsDoneState(oldState), m.config.IsDoneState(newState), time.Now())
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) deleteItem(item *model.Item) {
|
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:
|
case settingsSectionTags:
|
||||||
m.addNewTag()
|
m.addNewTag()
|
||||||
case settingsSectionStates:
|
case settingsSectionStates:
|
||||||
m.addNewState()
|
// Rows: [0]=default state, [1..N]=states, [N+1]=add state.
|
||||||
|
m.startStateForm(false, 0)
|
||||||
case settingsSectionKeybindings:
|
case settingsSectionKeybindings:
|
||||||
// Cannot add keybindings yet
|
// Cannot add keybindings yet
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +137,7 @@ func (m *uiModel) getSettingsItemCount() int {
|
||||||
case settingsSectionTags:
|
case settingsSectionTags:
|
||||||
return len(m.config.Tags.Tags) + 1 // +1 for "Add new tag" option
|
return len(m.config.Tags.Tags) + 1 // +1 for "Add new tag" option
|
||||||
case settingsSectionStates:
|
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:
|
case settingsSectionKeybindings:
|
||||||
return len(m.config.GetAllKeybindings())
|
return len(m.config.GetAllKeybindings())
|
||||||
default:
|
default:
|
||||||
|
|
@ -232,12 +233,11 @@ func (m *uiModel) startSettingsEdit() {
|
||||||
// Adjust for the default state setting offset
|
// Adjust for the default state setting offset
|
||||||
stateIndex := m.settingsCursor - 1
|
stateIndex := m.settingsCursor - 1
|
||||||
if stateIndex >= len(m.config.States.States) {
|
if stateIndex >= len(m.config.States.States) {
|
||||||
|
// On the "Add new state" row: open the form in add mode.
|
||||||
|
m.startStateForm(false, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state := m.config.States.States[stateIndex]
|
m.startStateForm(true, stateIndex)
|
||||||
m.textinput.SetValue(state.Name + "," + state.Color)
|
|
||||||
m.textinput.Placeholder = "name,color (e.g., TODO,202)"
|
|
||||||
m.textinput.Focus()
|
|
||||||
|
|
||||||
case settingsSectionKeybindings:
|
case settingsSectionKeybindings:
|
||||||
// Edit keybinding
|
// Edit keybinding
|
||||||
|
|
@ -328,23 +328,6 @@ func (m *uiModel) saveSettingsEdit() {
|
||||||
return
|
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:
|
case settingsSectionKeybindings:
|
||||||
// Save keybinding
|
// Save keybinding
|
||||||
keybindings := m.config.GetAllKeybindings()
|
keybindings := m.config.GetAllKeybindings()
|
||||||
|
|
@ -472,17 +455,6 @@ func (m *uiModel) addNewTag() {
|
||||||
m.mode = modeSettingsAddTag
|
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
|
// viewSettings renders the settings view
|
||||||
func (m *uiModel) viewSettings() string {
|
func (m *uiModel) viewSettings() string {
|
||||||
var content strings.Builder
|
var content strings.Builder
|
||||||
|
|
@ -741,6 +713,9 @@ func (m *uiModel) viewSettingsStates() string {
|
||||||
stateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(state.Color))
|
stateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(state.Color))
|
||||||
line += stateStyle.Render(state.Name)
|
line += stateStyle.Render(state.Name)
|
||||||
line += fmt.Sprintf(" (color: %s)", state.Color)
|
line += fmt.Sprintf(" (color: %s)", state.Color)
|
||||||
|
if state.Done {
|
||||||
|
line += m.styles.statusStyle.Render(" ✓done")
|
||||||
|
}
|
||||||
|
|
||||||
content.WriteString(line + "\n")
|
content.WriteString(line + "\n")
|
||||||
}
|
}
|
||||||
|
|
@ -844,8 +819,8 @@ func (m *uiModel) viewSettingsKeybindings() string {
|
||||||
// modeSettingsAddTag is a special mode for adding tags
|
// modeSettingsAddTag is a special mode for adding tags
|
||||||
const modeSettingsAddTag viewMode = 100
|
const modeSettingsAddTag viewMode = 100
|
||||||
|
|
||||||
// modeSettingsAddState is a special mode for adding states
|
// modeSettingsStateForm is the add/edit state form mode.
|
||||||
const modeSettingsAddState viewMode = 101
|
const modeSettingsStateForm viewMode = 102
|
||||||
|
|
||||||
// updateSettingsAddTag handles the add tag flow
|
// updateSettingsAddTag handles the add tag flow
|
||||||
func (m *uiModel) updateSettingsAddTag(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *uiModel) updateSettingsAddTag(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
@ -898,57 +873,6 @@ func (m *uiModel) viewSettingsAddTag() string {
|
||||||
return content.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
|
// moveSettingsItemUp moves the current settings item up and auto-saves
|
||||||
func (m *uiModel) moveSettingsItemUp() {
|
func (m *uiModel) moveSettingsItemUp() {
|
||||||
switch m.settingsSection {
|
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()
|
return m.viewSettings()
|
||||||
case modeSettingsAddTag:
|
case modeSettingsAddTag:
|
||||||
return m.viewSettingsAddTag()
|
return m.viewSettingsAddTag()
|
||||||
case modeSettingsAddState:
|
case modeSettingsStateForm:
|
||||||
return m.viewSettingsAddState()
|
return m.viewSettingsStateForm()
|
||||||
case modeTagEdit:
|
case modeTagEdit:
|
||||||
return m.viewTagEdit()
|
return m.viewTagEdit()
|
||||||
case modeRename:
|
case modeRename:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue