Compare commits

..

No commits in common. "master" and "v0.5.0" have entirely different histories.

11 changed files with 195 additions and 547 deletions

View file

@ -83,7 +83,6 @@ 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
@ -164,7 +163,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", Done: true}, {Name: "DONE", Color: "34"},
}, },
DefaultNewTaskState: "TODO", DefaultNewTaskState: "TODO",
}, },
@ -394,21 +393,6 @@ 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
@ -475,36 +459,27 @@ 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 {
if state := c.findState(stateName); state != nil { for _, state := range c.States.States {
return state.Color if state.Name == stateName {
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 (or upgrades) a state in the configuration, marking it as a // AddState adds a new state to the configuration
// done state when done is true. func (c *Config) AddState(name, color string) {
func (c *Config) AddState(name, color string, done bool) { // Check if state already exists
if state := c.findState(name); state != nil { for i, state := range c.States.States {
state.Color = color if state.Name == name {
state.Done = done c.States.States[i].Color = color
return return
}
} }
c.States.States = append(c.States.States, StateConfig{Name: name, Color: color, Done: done}) c.States.States = append(c.States.States, StateConfig{Name: name, Color: color})
} }
// RemoveState removes a state from the configuration // RemoveState removes a state from the configuration
@ -519,8 +494,11 @@ 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) {
if state := c.findState(name); state != nil { for i, state := range c.States.States {
state.Color = color if state.Name == name {
c.States.States[i].Color = color
return
}
} }
} }
@ -533,17 +511,6 @@ 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

View file

@ -1,73 +0,0 @@
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,6 +41,22 @@ 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

View file

@ -4,7 +4,9 @@ package model
type TodoState string type TodoState string
const ( const (
// StateNone is the empty (no TODO keyword) state. Concrete TODO states are StateTODO TodoState = "TODO"
// user-configurable (see config.States), so they are not hardcoded here. StatePROG TodoState = "PROG"
StateNone TodoState = "" StateBLOCK TodoState = "BLOCK"
StateDONE TodoState = "DONE"
StateNone TodoState = ""
) )

View file

@ -56,8 +56,7 @@ 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 {

View file

@ -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 modeSettingsStateForm: case modeSettingsAddState:
return m.updateSettingsStateForm(msg) return m.updateSettingsAddState(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 m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() { if items[m.cursor].State == model.StateDONE && items[m.cursor].IsClockedIn() {
items[m.cursor].ClockOut() items[m.cursor].ClockOut()
} }
m.setStatus("State changed") m.setStatus("State changed")
@ -114,7 +114,8 @@ 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)
if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() { stateNames := m.config.GetStateNames()
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")
@ -137,7 +138,8 @@ 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)
if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() { stateNames := m.config.GetStateNames()
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")
@ -868,30 +870,6 @@ 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 {
@ -901,6 +879,7 @@ 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 == "" {
@ -932,8 +911,34 @@ 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 based on the configured done-state set. // Manage CLOSED timestamp
applyClosedTransition(item, m.config.IsDoneState(oldState), m.config.IsDoneState(newState), time.Now()) 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
}
} }
func (m *uiModel) cycleStateBackward(item *model.Item) { func (m *uiModel) cycleStateBackward(item *model.Item) {
@ -945,6 +950,7 @@ 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 == "" {
@ -974,8 +980,34 @@ 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 based on the configured done-state set. // Manage CLOSED timestamp
applyClosedTransition(item, m.config.IsDoneState(oldState), m.config.IsDoneState(newState), time.Now()) 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
}
} }
func (m *uiModel) deleteItem(item *model.Item) { func (m *uiModel) deleteItem(item *model.Item) {

View file

@ -1,51 +0,0 @@
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,8 +107,7 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case settingsSectionTags: case settingsSectionTags:
m.addNewTag() m.addNewTag()
case settingsSectionStates: case settingsSectionStates:
// Rows: [0]=default state, [1..N]=states, [N+1]=add state. m.addNewState()
m.startStateForm(false, 0)
case settingsSectionKeybindings: case settingsSectionKeybindings:
// Cannot add keybindings yet // Cannot add keybindings yet
} }
@ -137,7 +136,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 // default state + "Add new state" return len(m.config.States.States) + 2 // +1 for "Default new task state" setting, +1 for "Add new state" option
case settingsSectionKeybindings: case settingsSectionKeybindings:
return len(m.config.GetAllKeybindings()) return len(m.config.GetAllKeybindings())
default: default:
@ -233,11 +232,12 @@ 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
} }
m.startStateForm(true, stateIndex) 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()
case settingsSectionKeybindings: case settingsSectionKeybindings:
// Edit keybinding // Edit keybinding
@ -328,6 +328,23 @@ 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()
@ -455,6 +472,17 @@ 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
@ -713,9 +741,6 @@ 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")
} }
@ -819,8 +844,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
// modeSettingsStateForm is the add/edit state form mode. // modeSettingsAddState is a special mode for adding states
const modeSettingsStateForm viewMode = 102 const modeSettingsAddState viewMode = 101
// 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) {
@ -873,6 +898,57 @@ 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 {

View file

@ -1,214 +0,0 @@
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

@ -1,106 +0,0 @@
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() return m.viewSettings()
case modeSettingsAddTag: case modeSettingsAddTag:
return m.viewSettingsAddTag() return m.viewSettingsAddTag()
case modeSettingsStateForm: case modeSettingsAddState:
return m.viewSettingsStateForm() return m.viewSettingsAddState()
case modeTagEdit: case modeTagEdit:
return m.viewTagEdit() return m.viewTagEdit()
case modeRename: case modeRename: