diff --git a/README.md b/README.md index f863ecf..d50fa82 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,10 @@ org # Opens ./todo.org by default ## Features ### Task Management -- **TODO States**: Cycle through TODO, PROG (in progress), BLOCK (blocked), and DONE states +- **Customizable TODO States**: Define your own workflow states with custom colors (default: TODO, PROG, BLOCK, DONE) - **Hierarchical Tasks**: Create sub-tasks and organize items with multiple levels - **Priority Levels**: Set priorities (A, B, C) with color-coded indicators +- **Tags**: Organize tasks with tags like `:work:urgent:` with customizable colors - **Folding**: Collapse and expand tasks and notes with Tab key - **Quick Capture**: Press 'c' to quickly capture new TODO items - **Reorder Mode**: Reorganize tasks with shift+up/down arrows @@ -63,6 +64,7 @@ org # Opens ./todo.org by default | `c` | Capture new TODO | | `s` | Add sub-task | | `D` | Delete item (with confirmation) | +| `#` | Add/edit tags | | `a` | Toggle agenda view | | `i` | Clock in | | `o` | Clock out | @@ -71,10 +73,13 @@ org # Opens ./todo.org by default | `e` | Set effort | | `r` | Toggle reorder mode | | `shift+↑/↓` | Move item up/down | +| `,` | Open settings | | `ctrl+s` | Save | | `?` | Toggle help | | `q` or `ctrl+c` | Quit | +**Note**: All keybindings can be customized in the configuration file. + ### Auto-save Changes are automatically saved when you quit the application. @@ -91,9 +96,129 @@ Changes are automatically saved when you quit the application. ![delete](./.imgs/delete_prompt.png) ![priority](./.imgs/priority_prompt.png) +## Configuration + +The application can be configured using a TOML configuration file located at: +- Linux/macOS: `~/.config/org/config.toml` +- Windows: `%APPDATA%\org\config.toml` + +The configuration file is automatically created with default values on first run. + +### Configuration Structure + +#### Tags +Define custom tags with colors: +```toml +[tags] +enabled = true +default_tag = "work" + +[[tags.tags]] +name = "work" +color = "99" # Blue + +[[tags.tags]] +name = "personal" +color = "141" # Purple + +[[tags.tags]] +name = "urgent" +color = "196" # Red +``` + +#### States +Customize TODO states with colors: +```toml +[states] +[[states.states]] +name = "TODO" +color = "202" # Orange + +[[states.states]] +name = "PROG" +color = "220" # Yellow + +[[states.states]] +name = "BLOCK" +color = "196" # Red + +[[states.states]] +name = "DONE" +color = "34" # Green +``` + +#### Colors +Customize UI colors (using ANSI color codes): +```toml +[colors] +todo = "202" # Orange +progress = "220" # Yellow +blocked = "196" # Red +done = "34" # Green +cursor = "240" # Gray +title = "99" # Blue +scheduled = "141" # Purple +overdue = "196" # Red +status = "241" # Dark gray +note = "246" # Light gray +folded = "243" # Medium gray +``` + +#### Keybindings +Customize all keybindings (can specify multiple keys per action): +```toml +[keybindings] +up = ["up", "k"] +down = ["down", "j"] +left = ["left", "h"] +right = ["right", "l"] +cycle_state = ["t", " "] +toggle_fold = ["tab"] +edit_notes = ["enter"] +capture = ["c"] +add_subtask = ["s"] +delete = ["D"] +tag_item = ["#"] +settings = [","] +toggle_view = ["a"] +save = ["ctrl+s"] +help = ["?"] +quit = ["q", "ctrl+c"] +# ... and more +``` + +### Settings UI + +Press `,` (comma) to open the settings interface where you can: + +#### Tags Tab +- Add new tags with custom colors +- Edit tag names and colors (format: `name,color`) +- Delete tags with `D` +- Reorder tags with `shift+up/down` + +#### States Tab +- Add new TODO states with custom colors +- Edit state names and colors (format: `name,color`) +- Delete states with `D` +- Reorder states with `shift+up/down` (affects cycling order) + +#### Keybindings Tab +- View all keybindings +- Edit keybindings (format: comma-separated keys, e.g., `up,k`) +- Multiple keys can be bound to the same action + +**Navigation**: Use left/right arrows to switch between tabs +**Auto-save**: All changes are automatically saved to the config file + ## File Format -The application uses standard Org-mode file format (.org), making it compatible with Emacs Org-mode and other Org-mode tools. +The application uses standard Org-mode file format (.org), making it compatible with Emacs Org-mode and other Org-mode tools. Tags are stored in the standard org-mode format: + +```org +* TODO Task title :work:urgent: +* DONE Completed task :personal: +``` ## License diff --git a/cmd/org/main.go b/cmd/org/main.go index a2591a7..003a595 100644 --- a/cmd/org/main.go +++ b/cmd/org/main.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/rwejlgaard/org/internal/config" "github.com/rwejlgaard/org/internal/parser" "github.com/rwejlgaard/org/internal/ui" ) @@ -31,6 +32,13 @@ func main() { filePath = filepath.Join(cwd, "todo.org") } + // Load configuration + cfg, err := config.LoadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Error loading config, using defaults: %v\n", err) + cfg = config.DefaultConfig() + } + // Parse the org file orgFile, err := parser.ParseOrgFile(filePath) if err != nil { @@ -39,7 +47,7 @@ func main() { } // Run the UI - if err := ui.RunUI(orgFile); err != nil { + if err := ui.RunUI(orgFile, cfg); err != nil { fmt.Fprintf(os.Stderr, "Error running UI: %v\n", err) os.Exit(1) } diff --git a/go.mod b/go.mod index bf8551e..83cf95b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + github.com/BurntSushi/toml v1.5.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect diff --git a/go.sum b/go.sum index 98be612..cad1077 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..962eab3 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,544 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" + "github.com/charmbracelet/bubbles/key" +) + +// Config represents the application configuration +type Config struct { + Keybindings KeybindingsConfig `toml:"keybindings"` + Colors ColorsConfig `toml:"colors"` + Tags TagsConfig `toml:"tags"` + States StatesConfig `toml:"states"` + UI UIConfig `toml:"ui"` +} + +// KeybindingsConfig holds all keybinding configurations +type KeybindingsConfig struct { + Up []string `toml:"up"` + Down []string `toml:"down"` + Left []string `toml:"left"` + Right []string `toml:"right"` + ShiftUp []string `toml:"shift_up"` + ShiftDown []string `toml:"shift_down"` + CycleState []string `toml:"cycle_state"` + ToggleFold []string `toml:"toggle_fold"` + EditNotes []string `toml:"edit_notes"` + ToggleView []string `toml:"toggle_view"` + Capture []string `toml:"capture"` + AddSubTask []string `toml:"add_subtask"` + Delete []string `toml:"delete"` + Save []string `toml:"save"` + ToggleReorder []string `toml:"toggle_reorder"` + ClockIn []string `toml:"clock_in"` + ClockOut []string `toml:"clock_out"` + SetDeadline []string `toml:"set_deadline"` + SetPriority []string `toml:"set_priority"` + SetEffort []string `toml:"set_effort"` + Help []string `toml:"help"` + Quit []string `toml:"quit"` + Settings []string `toml:"settings"` + TagItem []string `toml:"tag_item"` +} + +// ColorsConfig holds color configurations +type ColorsConfig struct { + Todo string `toml:"todo"` + Progress string `toml:"progress"` + Blocked string `toml:"blocked"` + Done string `toml:"done"` + Cursor string `toml:"cursor"` + Title string `toml:"title"` + Scheduled string `toml:"scheduled"` + Overdue string `toml:"overdue"` + Status string `toml:"status"` + Note string `toml:"note"` + Folded string `toml:"folded"` +} + +// TagConfig represents a single tag configuration +type TagConfig struct { + Name string `toml:"name"` + Color string `toml:"color"` +} + +// TagsConfig holds tag configurations +type TagsConfig struct { + Enabled bool `toml:"enabled"` + Tags []TagConfig `toml:"tags"` + DefaultTag string `toml:"default_tag"` +} + +// StateConfig represents a single TODO state configuration +type StateConfig struct { + Name string `toml:"name"` + Color string `toml:"color"` +} + +// StatesConfig holds TODO state configurations +type StatesConfig struct { + States []StateConfig `toml:"states"` +} + +// UIConfig holds UI-related configurations +type UIConfig struct { + HelpTextWidth int `toml:"help_text_width"` + MinTerminalWidth int `toml:"min_terminal_width"` + AgendaDays int `toml:"agenda_days"` +} + +// DefaultConfig returns the default configuration +func DefaultConfig() *Config { + return &Config{ + Keybindings: KeybindingsConfig{ + Up: []string{"up", "k"}, + Down: []string{"down", "j"}, + Left: []string{"left", "h"}, + Right: []string{"right", "l"}, + ShiftUp: []string{"shift+up"}, + ShiftDown: []string{"shift+down"}, + CycleState: []string{"t", " "}, + ToggleFold: []string{"tab"}, + EditNotes: []string{"enter"}, + ToggleView: []string{"a"}, + Capture: []string{"c"}, + AddSubTask: []string{"s"}, + Delete: []string{"D"}, + Save: []string{"ctrl+s"}, + ToggleReorder: []string{"r"}, + ClockIn: []string{"i"}, + ClockOut: []string{"o"}, + SetDeadline: []string{"d"}, + SetPriority: []string{"p"}, + SetEffort: []string{"e"}, + Help: []string{"?"}, + Quit: []string{"q", "ctrl+c"}, + Settings: []string{","}, + TagItem: []string{"#"}, + }, + Colors: ColorsConfig{ + Todo: "202", + Progress: "220", + Blocked: "196", + Done: "34", + Cursor: "240", + Title: "99", + Scheduled: "141", + Overdue: "196", + Status: "241", + Note: "246", + Folded: "243", + }, + Tags: TagsConfig{ + Enabled: true, + DefaultTag: "work", + Tags: []TagConfig{ + {Name: "work", Color: "99"}, + {Name: "personal", Color: "141"}, + {Name: "urgent", Color: "196"}, + {Name: "important", Color: "220"}, + }, + }, + States: StatesConfig{ + States: []StateConfig{ + {Name: "TODO", Color: "202"}, + {Name: "PROG", Color: "220"}, + {Name: "BLOCK", Color: "196"}, + {Name: "DONE", Color: "34"}, + }, + }, + UI: UIConfig{ + HelpTextWidth: 22, + MinTerminalWidth: 40, + AgendaDays: 7, + }, + } +} + +// GetConfigPath returns the path to the config file +func GetConfigPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get config directory: %w", err) + } + + orgConfigDir := filepath.Join(configDir, "org") + configPath := filepath.Join(orgConfigDir, "config.toml") + + return configPath, nil +} + +// LoadConfig loads the configuration from the config file +func LoadConfig() (*Config, error) { + configPath, err := GetConfigPath() + if err != nil { + return nil, err + } + + // If config file doesn't exist, create it with defaults + if _, err := os.Stat(configPath); os.IsNotExist(err) { + defaultCfg := DefaultConfig() + if err := defaultCfg.Save(); err != nil { + // If we can't save, just return defaults + return defaultCfg, nil + } + return defaultCfg, nil + } + + var config Config + if _, err := toml.DecodeFile(configPath, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // Merge with defaults for any missing values + config.fillDefaults() + + return &config, nil +} + +// Save saves the configuration to the config file +func (c *Config) Save() error { + configPath, err := GetConfigPath() + if err != nil { + return err + } + + // Ensure config directory exists + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Create or truncate the file + f, err := os.Create(configPath) + if err != nil { + return fmt.Errorf("failed to create config file: %w", err) + } + defer f.Close() + + // Encode config to TOML + encoder := toml.NewEncoder(f) + if err := encoder.Encode(c); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + return nil +} + +// fillDefaults fills in any missing config values with defaults +func (c *Config) fillDefaults() { + defaults := DefaultConfig() + + // Fill keybindings if empty + if len(c.Keybindings.Up) == 0 { + c.Keybindings.Up = defaults.Keybindings.Up + } + if len(c.Keybindings.Down) == 0 { + c.Keybindings.Down = defaults.Keybindings.Down + } + if len(c.Keybindings.Left) == 0 { + c.Keybindings.Left = defaults.Keybindings.Left + } + if len(c.Keybindings.Right) == 0 { + c.Keybindings.Right = defaults.Keybindings.Right + } + if len(c.Keybindings.ShiftUp) == 0 { + c.Keybindings.ShiftUp = defaults.Keybindings.ShiftUp + } + if len(c.Keybindings.ShiftDown) == 0 { + c.Keybindings.ShiftDown = defaults.Keybindings.ShiftDown + } + if len(c.Keybindings.CycleState) == 0 { + c.Keybindings.CycleState = defaults.Keybindings.CycleState + } + if len(c.Keybindings.ToggleFold) == 0 { + c.Keybindings.ToggleFold = defaults.Keybindings.ToggleFold + } + if len(c.Keybindings.EditNotes) == 0 { + c.Keybindings.EditNotes = defaults.Keybindings.EditNotes + } + if len(c.Keybindings.ToggleView) == 0 { + c.Keybindings.ToggleView = defaults.Keybindings.ToggleView + } + if len(c.Keybindings.Capture) == 0 { + c.Keybindings.Capture = defaults.Keybindings.Capture + } + if len(c.Keybindings.AddSubTask) == 0 { + c.Keybindings.AddSubTask = defaults.Keybindings.AddSubTask + } + if len(c.Keybindings.Delete) == 0 { + c.Keybindings.Delete = defaults.Keybindings.Delete + } + if len(c.Keybindings.Save) == 0 { + c.Keybindings.Save = defaults.Keybindings.Save + } + if len(c.Keybindings.ToggleReorder) == 0 { + c.Keybindings.ToggleReorder = defaults.Keybindings.ToggleReorder + } + if len(c.Keybindings.ClockIn) == 0 { + c.Keybindings.ClockIn = defaults.Keybindings.ClockIn + } + if len(c.Keybindings.ClockOut) == 0 { + c.Keybindings.ClockOut = defaults.Keybindings.ClockOut + } + if len(c.Keybindings.SetDeadline) == 0 { + c.Keybindings.SetDeadline = defaults.Keybindings.SetDeadline + } + if len(c.Keybindings.SetPriority) == 0 { + c.Keybindings.SetPriority = defaults.Keybindings.SetPriority + } + if len(c.Keybindings.SetEffort) == 0 { + c.Keybindings.SetEffort = defaults.Keybindings.SetEffort + } + if len(c.Keybindings.Help) == 0 { + c.Keybindings.Help = defaults.Keybindings.Help + } + if len(c.Keybindings.Quit) == 0 { + c.Keybindings.Quit = defaults.Keybindings.Quit + } + if len(c.Keybindings.Settings) == 0 { + c.Keybindings.Settings = defaults.Keybindings.Settings + } + if len(c.Keybindings.TagItem) == 0 { + c.Keybindings.TagItem = defaults.Keybindings.TagItem + } + + // Fill colors if empty + if c.Colors.Todo == "" { + c.Colors.Todo = defaults.Colors.Todo + } + if c.Colors.Progress == "" { + c.Colors.Progress = defaults.Colors.Progress + } + if c.Colors.Blocked == "" { + c.Colors.Blocked = defaults.Colors.Blocked + } + if c.Colors.Done == "" { + c.Colors.Done = defaults.Colors.Done + } + if c.Colors.Cursor == "" { + c.Colors.Cursor = defaults.Colors.Cursor + } + if c.Colors.Title == "" { + c.Colors.Title = defaults.Colors.Title + } + if c.Colors.Scheduled == "" { + c.Colors.Scheduled = defaults.Colors.Scheduled + } + if c.Colors.Overdue == "" { + c.Colors.Overdue = defaults.Colors.Overdue + } + if c.Colors.Status == "" { + c.Colors.Status = defaults.Colors.Status + } + if c.Colors.Note == "" { + c.Colors.Note = defaults.Colors.Note + } + if c.Colors.Folded == "" { + c.Colors.Folded = defaults.Colors.Folded + } + + // Fill tags if empty + if len(c.Tags.Tags) == 0 { + c.Tags.Tags = defaults.Tags.Tags + } + if c.Tags.DefaultTag == "" { + c.Tags.DefaultTag = defaults.Tags.DefaultTag + } + + // Fill states if empty + if len(c.States.States) == 0 { + c.States.States = defaults.States.States + } + + // Fill UI if zero values + if c.UI.HelpTextWidth == 0 { + c.UI.HelpTextWidth = defaults.UI.HelpTextWidth + } + if c.UI.MinTerminalWidth == 0 { + c.UI.MinTerminalWidth = defaults.UI.MinTerminalWidth + } + if c.UI.AgendaDays == 0 { + c.UI.AgendaDays = defaults.UI.AgendaDays + } +} + +// BuildKeyBinding creates a key.Binding from config +func BuildKeyBinding(keys []string, help string, description string) key.Binding { + return key.NewBinding( + key.WithKeys(keys...), + key.WithHelp(help, description), + ) +} + +// GetTagColor returns the color for a given tag name +func (c *Config) GetTagColor(tagName string) string { + for _, tag := range c.Tags.Tags { + if tag.Name == tagName { + return tag.Color + } + } + // Return a default color if tag not found + return "99" +} + +// AddTag adds a new tag to the configuration +func (c *Config) AddTag(name, color string) { + // Check if tag already exists + for i, tag := range c.Tags.Tags { + if tag.Name == name { + c.Tags.Tags[i].Color = color + return + } + } + c.Tags.Tags = append(c.Tags.Tags, TagConfig{Name: name, Color: color}) +} + +// RemoveTag removes a tag from the configuration +func (c *Config) RemoveTag(name string) { + for i, tag := range c.Tags.Tags { + if tag.Name == name { + c.Tags.Tags = append(c.Tags.Tags[:i], c.Tags.Tags[i+1:]...) + return + } + } +} + +// UpdateTagColor updates the color of an existing tag +func (c *Config) UpdateTagColor(name, color string) { + for i, tag := range c.Tags.Tags { + if tag.Name == name { + c.Tags.Tags[i].Color = color + return + } + } +} + +// 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 + } + } + // 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 + } + } + c.States.States = append(c.States.States, StateConfig{Name: name, Color: color}) +} + +// RemoveState removes a state from the configuration +func (c *Config) RemoveState(name string) { + for i, state := range c.States.States { + if state.Name == name { + c.States.States = append(c.States.States[:i], c.States.States[i+1:]...) + return + } + } +} + +// 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 + } + } +} + +// GetStateNames returns all configured state names +func (c *Config) GetStateNames() []string { + names := make([]string, len(c.States.States)) + for i, state := range c.States.States { + names[i] = state.Name + } + return names +} + +// 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 + switch action { + case "up": + c.Keybindings.Up = keys + case "down": + c.Keybindings.Down = keys + case "left": + c.Keybindings.Left = keys + case "right": + c.Keybindings.Right = keys + case "cycle_state": + c.Keybindings.CycleState = keys + case "toggle_fold": + c.Keybindings.ToggleFold = keys + case "edit_notes": + c.Keybindings.EditNotes = keys + case "capture": + c.Keybindings.Capture = keys + case "add_subtask": + c.Keybindings.AddSubTask = keys + case "delete": + c.Keybindings.Delete = keys + case "tag_item": + c.Keybindings.TagItem = keys + case "settings": + c.Keybindings.Settings = keys + case "toggle_view": + c.Keybindings.ToggleView = keys + case "save": + c.Keybindings.Save = keys + case "help": + c.Keybindings.Help = keys + case "quit": + c.Keybindings.Quit = keys + default: + return fmt.Errorf("unknown action: %s", action) + } + return nil +} + +// GetAllKeybindings returns a map of all keybindings +func (c *Config) GetAllKeybindings() map[string][]string { + return map[string][]string{ + "up": c.Keybindings.Up, + "down": c.Keybindings.Down, + "left": c.Keybindings.Left, + "right": c.Keybindings.Right, + "shift_up": c.Keybindings.ShiftUp, + "shift_down": c.Keybindings.ShiftDown, + "cycle_state": c.Keybindings.CycleState, + "toggle_fold": c.Keybindings.ToggleFold, + "edit_notes": c.Keybindings.EditNotes, + "toggle_view": c.Keybindings.ToggleView, + "capture": c.Keybindings.Capture, + "add_subtask": c.Keybindings.AddSubTask, + "delete": c.Keybindings.Delete, + "save": c.Keybindings.Save, + "toggle_reorder": c.Keybindings.ToggleReorder, + "clock_in": c.Keybindings.ClockIn, + "clock_out": c.Keybindings.ClockOut, + "set_deadline": c.Keybindings.SetDeadline, + "set_priority": c.Keybindings.SetPriority, + "set_effort": c.Keybindings.SetEffort, + "help": c.Keybindings.Help, + "quit": c.Keybindings.Quit, + "settings": c.Keybindings.Settings, + "tag_item": c.Keybindings.TagItem, + } +} diff --git a/internal/model/item.go b/internal/model/item.go index 1b1fa6e..2a91470 100644 --- a/internal/model/item.go +++ b/internal/model/item.go @@ -18,6 +18,7 @@ type Item struct { State TodoState // TODO, PROG, BLOCK, DONE, or empty Priority Priority // Priority: A, B, C, or empty Title string // The main title text + Tags []string // Tags for this item (e.g., :work:urgent:) Scheduled *time.Time Deadline *time.Time Effort string // Effort estimate (e.g., "8h", "2d") diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 96b3d0e..8fa1590 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -11,7 +11,7 @@ import ( // Parser patterns var ( - headingPattern = regexp.MustCompile(`^(\*+)\s+(?:(TODO|PROG|BLOCK|DONE)\s+)?(?:\[#([A-C])\]\s+)?(.+)$`) + headingPattern = regexp.MustCompile(`^(\*+)\s+(?:(TODO|PROG|BLOCK|DONE)\s+)?(?:\[#([A-C])\]\s+)?(.+?)(?:\s+(:[[:alnum:]_@#%:]+:)\s*)?$`) scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`) deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`) clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`) @@ -108,13 +108,24 @@ func ParseOrgFile(path string) (*model.OrgFile, error) { level := len(matches[1]) state := model.TodoState(matches[2]) priority := model.Priority(matches[3]) - title := matches[4] + title := strings.TrimSpace(matches[4]) + tagsStr := matches[5] + + // Parse tags from :tag1:tag2: format + var tags []string + if tagsStr != "" { + tagsStr = strings.Trim(tagsStr, ":") + if tagsStr != "" { + tags = strings.Split(tagsStr, ":") + } + } item := &model.Item{ Level: level, State: state, Priority: priority, Title: title, + Tags: tags, Notes: []string{}, Children: []*model.Item{}, } diff --git a/internal/parser/writer.go b/internal/parser/writer.go index 06020ab..4209047 100644 --- a/internal/parser/writer.go +++ b/internal/parser/writer.go @@ -40,7 +40,14 @@ func writeItem(writer *bufio.Writer, item *model.Item) error { if item.Priority != model.PriorityNone { line += " [#" + string(item.Priority) + "]" } - line += " " + item.Title + "\n" + line += " " + item.Title + + // Add tags if present + if len(item.Tags) > 0 { + line += " :" + strings.Join(item.Tags, ":") + ":" + } + + line += "\n" if _, err := writer.WriteString(line); err != nil { return err diff --git a/internal/ui/app.go b/internal/ui/app.go index a9a3b8d..8b6e689 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/rwejlgaard/org/internal/config" "github.com/rwejlgaard/org/internal/model" ) @@ -23,6 +24,8 @@ const ( modeSetPriority modeSetEffort modeHelp + modeSettings + modeTagEdit ) type uiModel struct { @@ -33,6 +36,8 @@ type uiModel struct { mode viewMode help help.Model keys keyMap + styles styleMap + config *config.Config width int height int statusMsg string @@ -40,11 +45,14 @@ type uiModel struct { editingItem *model.Item textarea textarea.Model textinput textinput.Model - itemToDelete *model.Item - reorderMode bool + itemToDelete *model.Item + reorderMode bool + settingsCursor int // Cursor position in settings view + settingsScroll int // Scroll position in settings view + settingsSection settingsSection // Current settings section/tab } -func initialModel(orgFile *model.OrgFile) uiModel { +func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel { ta := textarea.New() ta.Placeholder = "Enter notes here (code blocks supported)..." ta.ShowLineNumbers = false @@ -61,7 +69,9 @@ func initialModel(orgFile *model.OrgFile) uiModel { cursor: 0, mode: modeList, help: h, - keys: keys, + keys: newKeyMapFromConfig(cfg), + styles: newStyleMapFromConfig(cfg), + config: cfg, textarea: ta, textinput: ti, } @@ -122,8 +132,8 @@ func (m *uiModel) updateScrollOffset(availableHeight int) { } // RunUI starts the terminal UI -func RunUI(orgFile *model.OrgFile) error { - p := tea.NewProgram(initialModel(orgFile), tea.WithAltScreen()) +func RunUI(orgFile *model.OrgFile, cfg *config.Config) error { + p := tea.NewProgram(InitialModel(orgFile, cfg), tea.WithAltScreen()) _, err := p.Run() return err } diff --git a/internal/ui/keybindings.go b/internal/ui/keybindings.go index 73f3f1f..65cd3af 100644 --- a/internal/ui/keybindings.go +++ b/internal/ui/keybindings.go @@ -1,6 +1,11 @@ package ui -import "github.com/charmbracelet/bubbles/key" +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/rwejlgaard/org/internal/config" +) type keyMap struct { Up key.Binding @@ -25,97 +30,134 @@ type keyMap struct { SetDeadline key.Binding SetPriority key.Binding SetEffort key.Binding + Settings key.Binding + TagItem key.Binding } -var keys = keyMap{ - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "move up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "move down"), - ), - Left: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("←/h", "cycle state backward"), - ), - Right: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "cycle state forward"), - ), - ShiftUp: key.NewBinding( - key.WithKeys("shift+up"), - key.WithHelp("shift+↑", "move item up"), - ), - ShiftDown: key.NewBinding( - key.WithKeys("shift+down"), - key.WithHelp("shift+↓", "move item down"), - ), - CycleState: key.NewBinding( - key.WithKeys("t", " "), - key.WithHelp("t/space", "cycle todo state"), - ), - ToggleFold: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "fold/unfold"), - ), - EditNotes: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "edit notes"), - ), - ToggleView: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "toggle agenda view"), - ), - Capture: key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "capture TODO"), - ), - AddSubTask: key.NewBinding( - key.WithKeys("s"), - key.WithHelp("s", "add sub-task"), - ), - Delete: key.NewBinding( - key.WithKeys("D"), - key.WithHelp("D", "delete item"), - ), - Save: key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "save"), - ), - ToggleReorder: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "reorder mode"), - ), - ClockIn: key.NewBinding( - key.WithKeys("i"), - key.WithHelp("i", "clock in"), - ), - ClockOut: key.NewBinding( - key.WithKeys("o"), - key.WithHelp("o", "clock out"), - ), - SetDeadline: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "set deadline"), - ), - SetPriority: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "set priority"), - ), - SetEffort: key.NewBinding( - key.WithKeys("e"), - key.WithHelp("e", "set effort"), - ), - Help: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "ctrl+c"), - key.WithHelp("q", "quit"), - ), +// newKeyMapFromConfig creates a keyMap from configuration +func newKeyMapFromConfig(cfg *config.Config) keyMap { + kb := cfg.Keybindings + + return keyMap{ + Up: key.NewBinding( + key.WithKeys(kb.Up...), + key.WithHelp(formatKeyHelp(kb.Up), "move up"), + ), + Down: key.NewBinding( + key.WithKeys(kb.Down...), + key.WithHelp(formatKeyHelp(kb.Down), "move down"), + ), + Left: key.NewBinding( + key.WithKeys(kb.Left...), + key.WithHelp(formatKeyHelp(kb.Left), "cycle state backward"), + ), + Right: key.NewBinding( + key.WithKeys(kb.Right...), + key.WithHelp(formatKeyHelp(kb.Right), "cycle state forward"), + ), + ShiftUp: key.NewBinding( + key.WithKeys(kb.ShiftUp...), + key.WithHelp(formatKeyHelp(kb.ShiftUp), "move item up"), + ), + ShiftDown: key.NewBinding( + key.WithKeys(kb.ShiftDown...), + key.WithHelp(formatKeyHelp(kb.ShiftDown), "move item down"), + ), + CycleState: key.NewBinding( + key.WithKeys(kb.CycleState...), + key.WithHelp(formatKeyHelp(kb.CycleState), "cycle todo state"), + ), + ToggleFold: key.NewBinding( + key.WithKeys(kb.ToggleFold...), + key.WithHelp(formatKeyHelp(kb.ToggleFold), "fold/unfold"), + ), + EditNotes: key.NewBinding( + key.WithKeys(kb.EditNotes...), + key.WithHelp(formatKeyHelp(kb.EditNotes), "edit notes"), + ), + ToggleView: key.NewBinding( + key.WithKeys(kb.ToggleView...), + key.WithHelp(formatKeyHelp(kb.ToggleView), "toggle agenda view"), + ), + Capture: key.NewBinding( + key.WithKeys(kb.Capture...), + key.WithHelp(formatKeyHelp(kb.Capture), "capture TODO"), + ), + AddSubTask: key.NewBinding( + key.WithKeys(kb.AddSubTask...), + key.WithHelp(formatKeyHelp(kb.AddSubTask), "add sub-task"), + ), + Delete: key.NewBinding( + key.WithKeys(kb.Delete...), + key.WithHelp(formatKeyHelp(kb.Delete), "delete item"), + ), + Save: key.NewBinding( + key.WithKeys(kb.Save...), + key.WithHelp(formatKeyHelp(kb.Save), "save"), + ), + ToggleReorder: key.NewBinding( + key.WithKeys(kb.ToggleReorder...), + key.WithHelp(formatKeyHelp(kb.ToggleReorder), "reorder mode"), + ), + ClockIn: key.NewBinding( + key.WithKeys(kb.ClockIn...), + key.WithHelp(formatKeyHelp(kb.ClockIn), "clock in"), + ), + ClockOut: key.NewBinding( + key.WithKeys(kb.ClockOut...), + key.WithHelp(formatKeyHelp(kb.ClockOut), "clock out"), + ), + SetDeadline: key.NewBinding( + key.WithKeys(kb.SetDeadline...), + key.WithHelp(formatKeyHelp(kb.SetDeadline), "set deadline"), + ), + SetPriority: key.NewBinding( + key.WithKeys(kb.SetPriority...), + key.WithHelp(formatKeyHelp(kb.SetPriority), "set priority"), + ), + SetEffort: key.NewBinding( + key.WithKeys(kb.SetEffort...), + key.WithHelp(formatKeyHelp(kb.SetEffort), "set effort"), + ), + Help: key.NewBinding( + key.WithKeys(kb.Help...), + key.WithHelp(formatKeyHelp(kb.Help), "toggle help"), + ), + Quit: key.NewBinding( + key.WithKeys(kb.Quit...), + key.WithHelp(formatKeyHelp(kb.Quit), "quit"), + ), + Settings: key.NewBinding( + key.WithKeys(kb.Settings...), + key.WithHelp(formatKeyHelp(kb.Settings), "settings"), + ), + TagItem: key.NewBinding( + key.WithKeys(kb.TagItem...), + key.WithHelp(formatKeyHelp(kb.TagItem), "add/edit tags"), + ), + } +} + +// formatKeyHelp formats a slice of keys for display in help +func formatKeyHelp(keys []string) string { + if len(keys) == 0 { + return "" + } + // Take first two keys for display + if len(keys) == 1 { + return formatKey(keys[0]) + } + return formatKey(keys[0]) + "/" + formatKey(keys[1]) +} + +// formatKey formats a single key for display +func formatKey(k string) string { + // Convert key names to symbols where appropriate + k = strings.ReplaceAll(k, "up", "↑") + k = strings.ReplaceAll(k, "down", "↓") + k = strings.ReplaceAll(k, "left", "←") + k = strings.ReplaceAll(k, "right", "→") + return k } func (k keyMap) ShortHelp() []key.Binding { @@ -139,6 +181,6 @@ func (k keyMap) getAllBindings() []key.Binding { k.ToggleFold, k.EditNotes, k.ToggleReorder, k.Capture, k.AddSubTask, k.Delete, k.Save, k.ClockIn, k.ClockOut, k.SetDeadline, k.SetPriority, k.SetEffort, - k.ToggleView, k.Help, k.Quit, + k.TagItem, k.Settings, k.ToggleView, k.Help, k.Quit, } } diff --git a/internal/ui/modes.go b/internal/ui/modes.go index cd5b518..93ed15b 100644 --- a/internal/ui/modes.go +++ b/internal/ui/modes.go @@ -32,6 +32,14 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSetEffort(msg) case modeHelp: return m.updateHelp(msg) + case modeSettings: + return m.updateSettings(msg) + case modeSettingsAddTag: + return m.updateSettingsAddTag(msg) + case modeSettingsAddState: + return m.updateSettingsAddState(msg) + case modeTagEdit: + return m.updateTagEdit(msg) } switch msg := msg.(type) { @@ -99,9 +107,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Right): items := m.getVisibleItems() if len(items) > 0 && m.cursor < len(items) { - items[m.cursor].CycleState() - // Auto clock out when changing to DONE - if items[m.cursor].State == model.StateDONE && items[m.cursor].IsClockedIn() { + 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() { items[m.cursor].ClockOut() } m.setStatus("State changed") @@ -116,9 +125,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.CycleState): items := m.getVisibleItems() if len(items) > 0 && m.cursor < len(items) { - items[m.cursor].CycleState() - // Auto clock out when changing to DONE - if items[m.cursor].State == model.StateDONE && items[m.cursor].IsClockedIn() { + 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() { items[m.cursor].ClockOut() } m.setStatus("State changed") @@ -145,6 +155,22 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, textarea.Blink } + case key.Matches(msg, m.keys.Settings): + m.mode = modeSettings + m.initSettings() + return m, nil + + case key.Matches(msg, m.keys.TagItem): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.editingItem = items[m.cursor] + m.mode = modeTagEdit + m.textinput.SetValue(strings.Join(items[m.cursor].Tags, ":")) + m.textinput.Placeholder = "tag1:tag2:tag3" + m.textinput.Focus() + return m, textinput.Blink + } + case key.Matches(msg, m.keys.Capture): m.mode = modeCapture m.textinput.SetValue("") @@ -587,18 +613,69 @@ func (m uiModel) updateSetEffort(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +func (m *uiModel) cycleStateForward(item *model.Item) { + stateNames := m.config.GetStateNames() + if len(stateNames) == 0 { + return + } + + // Find current state index + currentIndex := -1 + currentState := string(item.State) + + // Handle empty state + if currentState == "" { + currentIndex = -1 + } else { + for i, name := range stateNames { + if name == currentState { + currentIndex = i + break + } + } + } + + // Cycle forward + if currentIndex < 0 || currentIndex >= len(stateNames)-1 { + if currentIndex == len(stateNames)-1 { + item.State = model.TodoState("") // Back to empty + } else { + item.State = model.TodoState(stateNames[0]) // First state + } + } else { + item.State = model.TodoState(stateNames[currentIndex+1]) + } +} + func (m *uiModel) cycleStateBackward(item *model.Item) { - switch item.State { - case model.StateNone: - item.State = model.StateDONE - case model.StateTODO: - item.State = model.StateNone - case model.StatePROG: - item.State = model.StateTODO - case model.StateBLOCK: - item.State = model.StatePROG - case model.StateDONE: - item.State = model.StateBLOCK + stateNames := m.config.GetStateNames() + if len(stateNames) == 0 { + return + } + + // Find current state index + currentIndex := -1 + currentState := string(item.State) + + // Handle empty state + if currentState == "" { + currentIndex = len(stateNames) // One past the last state + } else { + for i, name := range stateNames { + if name == currentState { + currentIndex = i + break + } + } + } + + // Cycle backward + if currentIndex <= 0 { + item.State = model.TodoState("") // Empty state + } else if currentIndex > len(stateNames) { + item.State = model.TodoState(stateNames[len(stateNames)-1]) + } else { + item.State = model.TodoState(stateNames[currentIndex-1]) } } @@ -774,3 +851,46 @@ func (m uiModel) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + +// updateTagEdit handles tag editing mode +func (m *uiModel) updateTagEdit(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + m.mode = modeList + m.textinput.Blur() + return m, nil + + case msg.Type == tea.KeyEnter: + if m.editingItem != nil { + // Parse tags from input (colon-separated) + tagsStr := m.textinput.Value() + var tags []string + if tagsStr != "" { + tags = strings.Split(tagsStr, ":") + // Remove empty strings + var filteredTags []string + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag != "" { + filteredTags = append(filteredTags, tag) + } + } + tags = filteredTags + } + m.editingItem.Tags = tags + m.setStatus("Tags updated") + } + m.mode = modeList + m.textinput.Blur() + return m, nil + + default: + var cmd tea.Cmd + m.textinput, cmd = m.textinput.Update(msg) + return m, cmd + } + } + return m, nil +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go new file mode 100644 index 0000000..6720cd0 --- /dev/null +++ b/internal/ui/settings.go @@ -0,0 +1,723 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// settingsSection represents different sections in the settings view +type settingsSection int + +const ( + settingsSectionTags settingsSection = iota + settingsSectionStates + settingsSectionKeybindings +) + +// settingsState tracks the state of the settings editor +type settingsState struct { + section settingsSection + cursor int + scroll int + editing bool + editingField string + editingValue string + modified bool +} + +// initSettings initializes the settings state +func (m *uiModel) initSettings() { + m.settingsCursor = 0 + m.settingsScroll = 0 +} + +// updateSettings handles updates in settings mode +func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // If editing, handle text input + if m.textinput.Focused() { + switch { + case key.Matches(msg, m.keys.Quit): + m.textinput.Blur() + return m, nil + case msg.Type == tea.KeyEnter: + // Save the edited value + m.saveSettingsEdit() + m.textinput.Blur() + return m, nil + default: + var cmd tea.Cmd + m.textinput, cmd = m.textinput.Update(msg) + return m, cmd + } + } + + // Navigation and actions + switch { + case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Settings): + // Exit settings + m.mode = modeList + return m, nil + + case key.Matches(msg, m.keys.Up): + if m.settingsCursor > 0 { + m.settingsCursor-- + } + + case key.Matches(msg, m.keys.Down): + maxCursor := m.getSettingsItemCount() - 1 + if m.settingsCursor < maxCursor { + m.settingsCursor++ + } + + case key.Matches(msg, m.keys.ShiftUp): + // Move item up + m.moveSettingsItemUp() + + case key.Matches(msg, m.keys.ShiftDown): + // Move item down + m.moveSettingsItemDown() + + case key.Matches(msg, m.keys.Left): + // Previous section + if m.settingsSection > settingsSectionTags { + m.settingsSection-- + m.settingsCursor = 0 + m.settingsScroll = 0 + } + + case key.Matches(msg, m.keys.Right): + // Next section + if m.settingsSection < settingsSectionKeybindings { + m.settingsSection++ + m.settingsCursor = 0 + m.settingsScroll = 0 + } + + case key.Matches(msg, m.keys.EditNotes): + // Enter edit mode + m.startSettingsEdit() + + case key.Matches(msg, m.keys.Delete): + // Delete tag + m.deleteSettingsItem() + + case key.Matches(msg, m.keys.Capture): + // Add new tag or state + switch m.settingsSection { + case settingsSectionTags: + m.addNewTag() + case settingsSectionStates: + m.addNewState() + case settingsSectionKeybindings: + // Cannot add keybindings yet + } + + case key.Matches(msg, m.keys.Save): + // Save config to disk + if err := m.config.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error saving config: %v", err)) + } else { + m.setStatus("Configuration saved!") + // Reload keybindings and styles + m.keys = newKeyMapFromConfig(m.config) + m.styles = newStyleMapFromConfig(m.config) + } + } + } + + return m, nil +} + +// getSettingsItemCount returns the number of items in the current settings view +func (m *uiModel) getSettingsItemCount() int { + switch m.settingsSection { + case settingsSectionTags: + return len(m.config.Tags.Tags) + 1 // +1 for "Add new tag" option + case settingsSectionStates: + return len(m.config.States.States) + 1 // +1 for "Add new state" option + case settingsSectionKeybindings: + return len(m.config.GetAllKeybindings()) + default: + return 0 + } +} + +// startSettingsEdit starts editing a settings item +func (m *uiModel) startSettingsEdit() { + switch m.settingsSection { + case settingsSectionTags: + if m.settingsCursor >= len(m.config.Tags.Tags) { + return + } + tag := m.config.Tags.Tags[m.settingsCursor] + m.textinput.SetValue(tag.Name + "," + tag.Color) + m.textinput.Placeholder = "name,color (e.g., work,99)" + m.textinput.Focus() + + case settingsSectionStates: + if m.settingsCursor >= len(m.config.States.States) { + return + } + state := m.config.States.States[m.settingsCursor] + m.textinput.SetValue(state.Name + "," + state.Color) + m.textinput.Placeholder = "name,color (e.g., TODO,202)" + m.textinput.Focus() + + case settingsSectionKeybindings: + // Edit keybinding + keybindings := m.config.GetAllKeybindings() + + // Convert to sorted slice + type kbPair struct { + action string + keys []string + } + var kbList []kbPair + for action, keys := range keybindings { + kbList = append(kbList, kbPair{action, keys}) + } + + // Sort alphabetically + for i := 0; i < len(kbList)-1; i++ { + for j := i + 1; j < len(kbList); j++ { + if kbList[i].action > kbList[j].action { + kbList[i], kbList[j] = kbList[j], kbList[i] + } + } + } + + if m.settingsCursor >= len(kbList) { + return + } + + kb := kbList[m.settingsCursor] + m.textinput.SetValue(strings.Join(kb.keys, ",")) + m.textinput.Placeholder = "Enter keys separated by commas (e.g., up,k)" + m.textinput.Focus() + } +} + +// saveSettingsEdit saves the edited value and auto-saves to disk +func (m *uiModel) saveSettingsEdit() { + switch m.settingsSection { + case settingsSectionTags: + if m.settingsCursor >= len(m.config.Tags.Tags) { + return + } + // Parse "name,color" format + parts := strings.Split(m.textinput.Value(), ",") + if len(parts) >= 2 { + tag := &m.config.Tags.Tags[m.settingsCursor] + tag.Name = strings.TrimSpace(parts[0]) + tag.Color = strings.TrimSpace(parts[1]) + m.setStatus(fmt.Sprintf("Updated tag '%s' (saved)", tag.Name)) + } else { + m.setStatus("Invalid format. Use: name,color") + return + } + + case settingsSectionStates: + if m.settingsCursor >= 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[m.settingsCursor] + 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() + + // Convert to sorted slice + type kbPair struct { + action string + keys []string + } + var kbList []kbPair + for action, keys := range keybindings { + kbList = append(kbList, kbPair{action, keys}) + } + + // Sort alphabetically + for i := 0; i < len(kbList)-1; i++ { + for j := i + 1; j < len(kbList); j++ { + if kbList[i].action > kbList[j].action { + kbList[i], kbList[j] = kbList[j], kbList[i] + } + } + } + + if m.settingsCursor >= len(kbList) { + return + } + + kb := kbList[m.settingsCursor] + // Parse comma-separated keys + keysStr := m.textinput.Value() + var newKeys []string + for _, k := range strings.Split(keysStr, ",") { + k = strings.TrimSpace(k) + if k != "" { + newKeys = append(newKeys, k) + } + } + + if len(newKeys) > 0 { + if err := m.config.UpdateKeybinding(kb.action, newKeys); err != nil { + m.setStatus(fmt.Sprintf("Error updating keybinding: %v", err)) + return + } else { + m.setStatus(fmt.Sprintf("Updated keybinding for '%s' (saved)", kb.action)) + } + } + } + + // Auto-save configuration to disk + if err := m.config.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err)) + } else { + // Reload keybindings and styles from updated config + m.keys = newKeyMapFromConfig(m.config) + m.styles = newStyleMapFromConfig(m.config) + } +} + +// deleteSettingsItem deletes the current settings item and auto-saves +func (m *uiModel) deleteSettingsItem() { + switch m.settingsSection { + case settingsSectionTags: + if m.settingsCursor >= len(m.config.Tags.Tags) { + return + } + tag := m.config.Tags.Tags[m.settingsCursor] + m.config.RemoveTag(tag.Name) + m.setStatus(fmt.Sprintf("Deleted tag '%s' (saved)", tag.Name)) + + // Adjust cursor if needed + if m.settingsCursor >= len(m.config.Tags.Tags) { + m.settingsCursor = len(m.config.Tags.Tags) - 1 + if m.settingsCursor < 0 { + m.settingsCursor = 0 + } + } + + case settingsSectionStates: + if m.settingsCursor >= len(m.config.States.States) { + return + } + state := m.config.States.States[m.settingsCursor] + m.config.RemoveState(state.Name) + m.setStatus(fmt.Sprintf("Deleted state '%s' (saved)", state.Name)) + + // Adjust cursor if needed + if m.settingsCursor >= len(m.config.States.States) { + m.settingsCursor = len(m.config.States.States) - 1 + if m.settingsCursor < 0 { + m.settingsCursor = 0 + } + } + + case settingsSectionKeybindings: + // Keybindings cannot be deleted + return + } + + // Auto-save configuration to disk + if err := m.config.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err)) + } +} + +// addNewTag adds a new tag +func (m *uiModel) addNewTag() { + m.textinput.SetValue("") + m.textinput.Placeholder = "Enter tag name" + m.textinput.Focus() + m.textinput.Blur() // Will be refocused when user types + + // Prompt for tag name first, then color + 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 + + // Title + title := m.styles.titleStyle.Render("⚙ Settings") + content.WriteString(title + "\n\n") + + // Tab selector + tabStyle := lipgloss.NewStyle().Padding(0, 2) + activeTabStyle := lipgloss.NewStyle().Padding(0, 2).Bold(true).Foreground(lipgloss.Color(m.config.Colors.Title)) + + tabs := "" + if m.settingsSection == settingsSectionTags { + tabs += activeTabStyle.Render("[Tags]") + } else { + tabs += tabStyle.Render("Tags") + } + tabs += " " + if m.settingsSection == settingsSectionStates { + tabs += activeTabStyle.Render("[States]") + } else { + tabs += tabStyle.Render("States") + } + tabs += " " + if m.settingsSection == settingsSectionKeybindings { + tabs += activeTabStyle.Render("[Keybindings]") + } else { + tabs += tabStyle.Render("Keybindings") + } + content.WriteString(tabs + "\n\n") + + // Instructions + var instructions string + switch m.settingsSection { + case settingsSectionTags: + instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Edit • D: Delete • c: Add new tag • ctrl+s: Save • q/,: Exit" + case settingsSectionStates: + instructions = "←/→: Switch tabs • ↑/↓: Navigate • shift+↑/↓: Reorder • Enter: Edit • D: Delete • c: Add new state • ctrl+s: Save • q/,: Exit" + case settingsSectionKeybindings: + instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Edit keybinding • ctrl+s: Save • q/,: Exit" + } + content.WriteString(m.styles.statusStyle.Render(instructions) + "\n\n") + + // Render the appropriate section + switch m.settingsSection { + case settingsSectionTags: + content.WriteString(m.viewSettingsTags()) + case settingsSectionStates: + content.WriteString(m.viewSettingsStates()) + case settingsSectionKeybindings: + content.WriteString(m.viewSettingsKeybindings()) + } + + // If editing, show input + if m.textinput.Focused() { + content.WriteString("\n") + content.WriteString(m.textinput.View() + "\n") + content.WriteString(m.styles.statusStyle.Render("Enter: Save • ESC/q: Cancel") + "\n") + } + + return content.String() +} + +// viewSettingsTags renders the tags section +func (m *uiModel) viewSettingsTags() string { + var content strings.Builder + + for i, tag := range m.config.Tags.Tags { + line := "" + + // Cursor + if i == m.settingsCursor && !m.textinput.Focused() { + line += "▶ " + } else { + line += " " + } + + // Tag name with its color + tagStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(tag.Color)) + line += fmt.Sprintf(":%s: ", tag.Name) + line += tagStyle.Render(fmt.Sprintf("(color: %s)", tag.Color)) + + content.WriteString(line + "\n") + } + + // Add new tag option + if m.settingsCursor == len(m.config.Tags.Tags) && !m.textinput.Focused() { + content.WriteString("▶ ") + } else { + content.WriteString(" ") + } + content.WriteString(m.styles.statusStyle.Render("+ Add new tag (press 'c')") + "\n") + + return content.String() +} + +// viewSettingsStates renders the states section +func (m *uiModel) viewSettingsStates() string { + var content strings.Builder + + for i, state := range m.config.States.States { + line := "" + + // Cursor + if i == m.settingsCursor && !m.textinput.Focused() { + line += "▶ " + } else { + line += " " + } + + // State name with its color + stateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(state.Color)) + line += stateStyle.Render(fmt.Sprintf("%s", state.Name)) + line += fmt.Sprintf(" (color: %s)", state.Color) + + content.WriteString(line + "\n") + } + + // Add new state option + if m.settingsCursor == len(m.config.States.States) && !m.textinput.Focused() { + content.WriteString("▶ ") + } else { + content.WriteString(" ") + } + content.WriteString(m.styles.statusStyle.Render("+ Add new state (press 'c')") + "\n") + + return content.String() +} + +// viewSettingsKeybindings renders the keybindings section +func (m *uiModel) viewSettingsKeybindings() string { + var content strings.Builder + + // Get all keybindings + keybindings := m.config.GetAllKeybindings() + + // Convert to sorted slice for consistent display + type kbPair struct { + action string + keys []string + } + var kbList []kbPair + for action, keys := range keybindings { + kbList = append(kbList, kbPair{action, keys}) + } + + // Simple alphabetical sort by action name + for i := 0; i < len(kbList)-1; i++ { + for j := i + 1; j < len(kbList); j++ { + if kbList[i].action > kbList[j].action { + kbList[i], kbList[j] = kbList[j], kbList[i] + } + } + } + + for i, kb := range kbList { + line := "" + + // Cursor + if i == m.settingsCursor && !m.textinput.Focused() { + line += "▶ " + } else { + line += " " + } + + // Format keybinding + keysStr := strings.Join(kb.keys, ", ") + line += fmt.Sprintf("%-20s : %s", kb.action, keysStr) + + content.WriteString(line + "\n") + } + + return content.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 + +// updateSettingsAddTag handles the add tag flow +func (m *uiModel) updateSettingsAddTag(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if !m.textinput.Focused() { + m.textinput.Focus() + } + + switch { + case key.Matches(msg, m.keys.Quit): + m.textinput.Blur() + m.mode = modeSettings + return m, nil + + case msg.Type == tea.KeyEnter: + tagName := m.textinput.Value() + if tagName != "" { + // Default color + m.config.AddTag(tagName, "99") + // Auto-save + if err := m.config.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error saving: %v", err)) + } else { + m.setStatus(fmt.Sprintf("Added tag '%s' (saved)", tagName)) + } + } + 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 +} + +// viewSettingsAddTag renders the add tag view +func (m *uiModel) viewSettingsAddTag() string { + var content strings.Builder + + content.WriteString(m.styles.titleStyle.Render("Add New Tag") + "\n\n") + content.WriteString(m.textinput.View() + "\n\n") + content.WriteString(m.styles.statusStyle.Render("Enter tag name • Press Enter to add • ESC to cancel") + "\n") + + 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 key.Matches(msg, m.keys.Quit): + 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 { + case settingsSectionTags: + if m.settingsCursor > 0 && m.settingsCursor < len(m.config.Tags.Tags) { + // Swap with previous item + m.config.Tags.Tags[m.settingsCursor], m.config.Tags.Tags[m.settingsCursor-1] = + m.config.Tags.Tags[m.settingsCursor-1], m.config.Tags.Tags[m.settingsCursor] + m.settingsCursor-- + // Auto-save + if err := m.config.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error saving: %v", err)) + } else { + m.setStatus("Reordered (saved)") + } + } + + case settingsSectionStates: + if m.settingsCursor > 0 && m.settingsCursor < len(m.config.States.States) { + // Swap with previous item + m.config.States.States[m.settingsCursor], m.config.States.States[m.settingsCursor-1] = + m.config.States.States[m.settingsCursor-1], m.config.States.States[m.settingsCursor] + m.settingsCursor-- + // Auto-save + if err := m.config.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error saving: %v", err)) + } else { + m.setStatus("Reordered (saved)") + } + } + + case settingsSectionKeybindings: + // Keybindings cannot be reordered + return + } +} + +// moveSettingsItemDown moves the current settings item down and auto-saves +func (m *uiModel) moveSettingsItemDown() { + switch m.settingsSection { + case settingsSectionTags: + if m.settingsCursor >= 0 && m.settingsCursor < len(m.config.Tags.Tags)-1 { + // Swap with next item + m.config.Tags.Tags[m.settingsCursor], m.config.Tags.Tags[m.settingsCursor+1] = + m.config.Tags.Tags[m.settingsCursor+1], m.config.Tags.Tags[m.settingsCursor] + m.settingsCursor++ + // Auto-save + if err := m.config.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error saving: %v", err)) + } else { + m.setStatus("Reordered (saved)") + } + } + + case settingsSectionStates: + if m.settingsCursor >= 0 && m.settingsCursor < len(m.config.States.States)-1 { + // Swap with next item + m.config.States.States[m.settingsCursor], m.config.States.States[m.settingsCursor+1] = + m.config.States.States[m.settingsCursor+1], m.config.States.States[m.settingsCursor] + m.settingsCursor++ + // Auto-save + if err := m.config.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error saving: %v", err)) + } else { + m.setStatus("Reordered (saved)") + } + } + + case settingsSectionKeybindings: + // Keybindings cannot be reordered + return + } +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 68cb67c..f1839f0 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -1,18 +1,40 @@ package ui -import "github.com/charmbracelet/lipgloss" - -// Styles for UI rendering -var ( - todoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("202")) // Orange - progStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // Yellow - blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red - doneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34")) // Green - cursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("240")) - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")) - scheduledStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple - overdueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red - statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Italic(true) - noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246")).Italic(true) - foldedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) +import ( + "github.com/charmbracelet/lipgloss" + "github.com/rwejlgaard/org/internal/config" ) + +// styleMap holds all the styles used in the UI +type styleMap struct { + todoStyle lipgloss.Style + progStyle lipgloss.Style + blockStyle lipgloss.Style + doneStyle lipgloss.Style + cursorStyle lipgloss.Style + titleStyle lipgloss.Style + scheduledStyle lipgloss.Style + overdueStyle lipgloss.Style + statusStyle lipgloss.Style + noteStyle lipgloss.Style + foldedStyle lipgloss.Style +} + +// newStyleMapFromConfig creates a styleMap from configuration +func newStyleMapFromConfig(cfg *config.Config) styleMap { + colors := cfg.Colors + + return styleMap{ + todoStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Todo)), + progStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Progress)), + blockStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Blocked)), + doneStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Done)), + cursorStyle: lipgloss.NewStyle().Background(lipgloss.Color(colors.Cursor)), + titleStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colors.Title)), + scheduledStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Scheduled)), + overdueStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Overdue)), + statusStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Status)).Italic(true), + noteStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Note)).Italic(true), + foldedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Folded)), + } +} diff --git a/internal/ui/views.go b/internal/ui/views.go index 042b8d2..677bcbf 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -87,6 +87,14 @@ func (m uiModel) View() string { return m.viewSetEffort() case modeHelp: return m.viewHelp() + case modeSettings: + return m.viewSettings() + case modeSettingsAddTag: + return m.viewSettingsAddTag() + case modeSettingsAddState: + return m.viewSettingsAddState() + case modeTagEdit: + return m.viewTagEdit() } // Build footer (status + help) @@ -94,7 +102,7 @@ func (m uiModel) View() string { // Status message if time.Now().Before(m.statusExpiry) { - footer.WriteString(statusStyle.Render(m.statusMsg)) + footer.WriteString(m.styles.statusStyle.Render(m.statusMsg)) footer.WriteString("\n") } @@ -117,10 +125,10 @@ func (m uiModel) View() string { } if m.reorderMode { reorderIndicator := lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(" [REORDER MODE]") - content.WriteString(titleStyle.Render(title)) + content.WriteString(m.styles.titleStyle.Render(title)) content.WriteString(reorderIndicator) } else { - content.WriteString(titleStyle.Render(title)) + content.WriteString(m.styles.titleStyle.Render(title)) } content.WriteString("\n\n") @@ -261,7 +269,7 @@ func (m uiModel) viewConfirmDelete() string { Width(60) var content strings.Builder - content.WriteString(titleStyle.Render("⚠ Delete Item")) + content.WriteString(m.styles.titleStyle.Render("⚠ Delete Item")) content.WriteString("\n\n") if m.itemToDelete != nil { @@ -271,7 +279,7 @@ func (m uiModel) viewConfirmDelete() string { } content.WriteString("\n") - content.WriteString(statusStyle.Render("This will delete the item and all sub-tasks.")) + content.WriteString(m.styles.statusStyle.Render("This will delete the item and all sub-tasks.")) content.WriteString("\n\n") content.WriteString("Press Y to confirm • N or ESC to cancel") @@ -289,11 +297,11 @@ func (m uiModel) viewCapture() string { Width(60) var content strings.Builder - content.WriteString(titleStyle.Render("Capture TODO")) + content.WriteString(m.styles.titleStyle.Render("Capture TODO")) content.WriteString("\n\n") content.WriteString(m.textinput.View()) content.WriteString("\n\n") - content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel")) + content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel")) dialog := dialogStyle.Render(content.String()) @@ -309,15 +317,15 @@ func (m uiModel) viewAddSubTask() string { Width(60) var content strings.Builder - content.WriteString(titleStyle.Render("Add Sub-Task")) + content.WriteString(m.styles.titleStyle.Render("Add Sub-Task")) content.WriteString("\n") if m.editingItem != nil { - content.WriteString(statusStyle.Render(fmt.Sprintf("Under: %s", m.editingItem.Title))) + content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Under: %s", m.editingItem.Title))) } content.WriteString("\n\n") content.WriteString(m.textinput.View()) content.WriteString("\n\n") - content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel")) + content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel")) dialog := dialogStyle.Render(content.String()) @@ -333,19 +341,19 @@ func (m uiModel) viewSetDeadline() string { Width(60) var content strings.Builder - content.WriteString(titleStyle.Render("Set Deadline")) + content.WriteString(m.styles.titleStyle.Render("Set Deadline")) content.WriteString("\n") if m.editingItem != nil { - content.WriteString(statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) + content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) } content.WriteString("\n\n") content.WriteString(m.textinput.View()) content.WriteString("\n\n") - content.WriteString(statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)")) + content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)")) content.WriteString("\n") - content.WriteString(statusStyle.Render("Leave empty to clear deadline")) + content.WriteString(m.styles.statusStyle.Render("Leave empty to clear deadline")) content.WriteString("\n") - content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel")) + content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel")) dialog := dialogStyle.Render(content.String()) @@ -361,13 +369,13 @@ func (m uiModel) viewSetPriority() string { Width(60) var content strings.Builder - content.WriteString(titleStyle.Render("Set Priority")) + content.WriteString(m.styles.titleStyle.Render("Set Priority")) content.WriteString("\n") if m.editingItem != nil { - content.WriteString(statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) + content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) content.WriteString("\n") if m.editingItem.Priority != model.PriorityNone { - content.WriteString(statusStyle.Render(fmt.Sprintf("Current: [#%s]", m.editingItem.Priority))) + content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: [#%s]", m.editingItem.Priority))) } } content.WriteString("\n\n") @@ -381,9 +389,9 @@ func (m uiModel) viewSetPriority() string { content.WriteString(priorityBStyle.Render("[B] Medium Priority") + "\n") content.WriteString(priorityCStyle.Render("[C] Low Priority") + "\n") content.WriteString("\n") - content.WriteString(statusStyle.Render("Press Space/Enter to clear priority")) + content.WriteString(m.styles.statusStyle.Render("Press Space/Enter to clear priority")) content.WriteString("\n") - content.WriteString(statusStyle.Render("Press ESC to cancel")) + content.WriteString(m.styles.statusStyle.Render("Press ESC to cancel")) dialog := dialogStyle.Render(content.String()) @@ -399,23 +407,23 @@ func (m uiModel) viewSetEffort() string { Width(60) var content strings.Builder - content.WriteString(titleStyle.Render("Set Effort")) + content.WriteString(m.styles.titleStyle.Render("Set Effort")) content.WriteString("\n") if m.editingItem != nil { - content.WriteString(statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) + content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) content.WriteString("\n") if m.editingItem.Effort != "" { - content.WriteString(statusStyle.Render(fmt.Sprintf("Current: %s", m.editingItem.Effort))) + content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: %s", m.editingItem.Effort))) } } content.WriteString("\n\n") content.WriteString(m.textinput.View()) content.WriteString("\n\n") - content.WriteString(statusStyle.Render("Examples: 8h, 2d, 1w, 4h30m")) + content.WriteString(m.styles.statusStyle.Render("Examples: 8h, 2d, 1w, 4h30m")) content.WriteString("\n") - content.WriteString(statusStyle.Render("Leave empty to clear effort")) + content.WriteString(m.styles.statusStyle.Render("Leave empty to clear effort")) content.WriteString("\n") - content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel")) + content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel")) dialog := dialogStyle.Render(content.String()) @@ -428,7 +436,7 @@ func (m uiModel) viewHelp() string { var lines []string // Title - lines = append(lines, titleStyle.Render("Keybindings Help")) + lines = append(lines, m.styles.titleStyle.Render("Keybindings Help")) lines = append(lines, "") // Group bindings by category @@ -436,8 +444,8 @@ func (m uiModel) viewHelp() string { itemBindings := []key.Binding{m.keys.ToggleFold, m.keys.EditNotes, m.keys.CycleState} taskBindings := []key.Binding{m.keys.Capture, m.keys.AddSubTask, m.keys.Delete} timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetEffort} - organizationBindings := []key.Binding{m.keys.SetPriority, m.keys.ShiftUp, m.keys.ShiftDown, m.keys.ToggleReorder} - viewBindings := []key.Binding{m.keys.ToggleView, m.keys.Save, m.keys.Help, m.keys.Quit} + organizationBindings := []key.Binding{m.keys.SetPriority, m.keys.TagItem, m.keys.ShiftUp, m.keys.ShiftDown, m.keys.ToggleReorder} + viewBindings := []key.Binding{m.keys.ToggleView, m.keys.Settings, m.keys.Save, m.keys.Help, m.keys.Quit} // Helper function to render a binding renderBinding := func(b key.Binding) string { @@ -519,10 +527,10 @@ func (m uiModel) viewHelp() string { var footer strings.Builder if startLine > 0 || endLine < totalLines { scrollInfo := fmt.Sprintf("(Scroll: %d-%d of %d lines)", startLine+1, endLine, totalLines) - footer.WriteString(statusStyle.Render(scrollInfo)) + footer.WriteString(m.styles.statusStyle.Render(scrollInfo)) footer.WriteString(" ") } - footer.WriteString(statusStyle.Render("↑/↓ scroll • ? or ESC to close")) + footer.WriteString(m.styles.statusStyle.Render("↑/↓ scroll • ? or ESC to close")) // Combine content and footer var result strings.Builder @@ -543,12 +551,12 @@ func (m uiModel) viewHelp() string { func (m uiModel) viewEditMode() string { var b strings.Builder - b.WriteString(titleStyle.Render("Editing Notes")) + b.WriteString(m.styles.titleStyle.Render("Editing Notes")) b.WriteString("\n") if m.editingItem != nil { b.WriteString(fmt.Sprintf("Item: %s\n", m.editingItem.Title)) } - b.WriteString(statusStyle.Render("Press ESC to save and exit")) + b.WriteString(m.styles.statusStyle.Render("Press ESC to save and exit")) b.WriteString("\n\n") b.WriteString(m.textarea.View()) @@ -729,9 +737,9 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string { // Fold indicator if len(item.Children) > 0 || len(item.Notes) > 0 { if item.Folded { - b.WriteString(foldedStyle.Render("▶ ")) + b.WriteString(m.styles.foldedStyle.Render("▶ ")) } else { - b.WriteString(foldedStyle.Render("▼ ")) + b.WriteString(m.styles.foldedStyle.Render("▼ ")) } } else { b.WriteString(" ") @@ -739,17 +747,10 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string { // State stateStr := "" - switch item.State { - case model.StateTODO: - stateStr = todoStyle.Render("[TODO]") - case model.StatePROG: - stateStr = progStyle.Render("[PROG]") - case model.StateBLOCK: - stateStr = blockStyle.Render("[BLOCK]") - case model.StateDONE: - stateStr = doneStyle.Render("[DONE]") - default: - stateStr = "" // Empty space for alignment + if item.State != model.StateNone { + stateColor := m.config.GetStateColor(string(item.State)) + stateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(stateColor)) + stateStr = stateStyle.Render(fmt.Sprintf("[%s]", item.State)) } b.WriteString(stateStr) b.WriteString(" ") @@ -771,6 +772,16 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string { // Title b.WriteString(item.Title) + // Tags + if len(item.Tags) > 0 { + b.WriteString(" ") + for _, tag := range item.Tags { + tagColor := m.config.GetTagColor(tag) + tagStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(tagColor)) + b.WriteString(tagStyle.Render(fmt.Sprintf(":%s:", tag))) + } + } + // Effort if item.Effort != "" { effortStr := fmt.Sprintf(" (Effort: %s)", item.Effort) @@ -812,23 +823,42 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string { if item.Scheduled != nil { schedStr := fmt.Sprintf(" (Scheduled: %s)", parser.FormatOrgDate(*item.Scheduled)) if item.Scheduled.Before(now) { - b.WriteString(overdueStyle.Render(schedStr)) + b.WriteString(m.styles.overdueStyle.Render(schedStr)) } else { - b.WriteString(scheduledStyle.Render(schedStr)) + b.WriteString(m.styles.scheduledStyle.Render(schedStr)) } } if item.Deadline != nil { deadlineStr := fmt.Sprintf(" (Deadline: %s)", parser.FormatOrgDate(*item.Deadline)) if item.Deadline.Before(now) { - b.WriteString(overdueStyle.Render(deadlineStr)) + b.WriteString(m.styles.overdueStyle.Render(deadlineStr)) } else { - b.WriteString(scheduledStyle.Render(deadlineStr)) + b.WriteString(m.styles.scheduledStyle.Render(deadlineStr)) } } line := b.String() if isCursor { - return cursorStyle.Render(line) + return m.styles.cursorStyle.Render(line) } return line } + +// viewTagEdit renders the tag editing view +func (m uiModel) viewTagEdit() string { + var content strings.Builder + + content.WriteString(m.styles.titleStyle.Render("Edit Tags") + "\n\n") + + if m.editingItem != nil { + content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)) + "\n\n") + } + + content.WriteString(m.textinput.View() + "\n\n") + + content.WriteString(m.styles.statusStyle.Render("Enter tags separated by colons (e.g., work:urgent:important)") + "\n") + content.WriteString(m.styles.statusStyle.Render("Leave empty to remove all tags") + "\n\n") + content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel") + "\n") + + return content.String() +}