Adding settings view and remappable keybindings and custom states support and tags

This commit is contained in:
Rasmus Wejlgaard 2025-11-08 17:33:36 +00:00
parent 8f6ec4a79f
commit 43573a6e79
14 changed files with 1832 additions and 186 deletions

129
README.md
View file

@ -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

View file

@ -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)
}

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

544
internal/config/config.go Normal file
View file

@ -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,
}
}

View file

@ -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")

View file

@ -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{},
}

View file

@ -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

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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
}

723
internal/ui/settings.go Normal file
View file

@ -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
}
}

View file

@ -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)),
}
}

View file

@ -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()
}