mirror of
https://github.com/RWejlgaard/org.git
synced 2026-05-06 04:34:45 +00:00
Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc00d6891 | ||
|
|
fce607e29d | ||
| c858e70d07 | |||
| 0b88465e21 | |||
| 8ff2b254a4 | |||
| 8ed20e48ff | |||
| 6b404cd722 | |||
| 2e9980e73c | |||
| 5eb672e0c9 | |||
| 5a6fede2d8 | |||
| aaa0ad0f55 | |||
| 23c7095477 | |||
| 015feb3637 | |||
| 2f98d8e0f1 |
18 changed files with 1822 additions and 214 deletions
11
.github/pull_request_template.md
vendored
Normal file
11
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
## Description
|
||||
|
||||
|
||||
## Important note
|
||||
|
||||
The first commit should be prefixed with one of the following depending on the severity of the changes:
|
||||
|
||||
- `chore:` - non-code changes, such as typos in readme or pipeline changes.
|
||||
- `fix:` - for a small change like a bugfix or other minor things.
|
||||
- `feat:` - for a new feature.
|
||||
- `major:` - for a large refactor or breaking changes.
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
- name: Get commit message
|
||||
id: get_commit_message
|
||||
run: |
|
||||
COMMIT_MSG=$(git log -1 --pretty=%B)
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s)
|
||||
echo "commit_message=${COMMIT_MSG}" >> $GITHUB_OUTPUT
|
||||
echo "Commit message: ${COMMIT_MSG}"
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 91 KiB |
BIN
.imgs/settings_keybinds.png
Normal file
BIN
.imgs/settings_keybinds.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
.imgs/settings_states.png
Normal file
BIN
.imgs/settings_states.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
.imgs/settings_tags.png
Normal file
BIN
.imgs/settings_tags.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
76
README.md
76
README.md
|
|
@ -18,11 +18,69 @@ go build -o bin/org ./cmd/org
|
|||
## Usage
|
||||
|
||||
```bash
|
||||
org [filename] # Open specific org file
|
||||
org -f tasks.org # Open using -f flag
|
||||
org # Opens ./todo.org by default
|
||||
org # Open ./todo.org (default)
|
||||
org tasks.org # Open specific org file
|
||||
org /path/to/work.org # Open specific org file with path
|
||||
org -m # Multi-file: Load all .org files in current directory
|
||||
org -m /path/to/dir # Multi-file: Load all .org files in specified directory
|
||||
org -c # Quick capture mode
|
||||
org -c "Task description" # Quick capture with pre-filled text
|
||||
echo "Task" | org # Pipe text to capture
|
||||
```
|
||||
|
||||
### Single-File Mode (Default)
|
||||
|
||||
By default, `org` opens `./todo.org` or the file you specify:
|
||||
|
||||
```bash
|
||||
org # Opens ./todo.org
|
||||
org tasks.org # Opens tasks.org
|
||||
org ~/work/project.org # Opens specific file
|
||||
```
|
||||
|
||||
### Quick Capture Mode
|
||||
|
||||
Use the `-c` or `--capture` flag to quickly add tasks without navigating through the UI:
|
||||
|
||||
```bash
|
||||
org -c # Open directly in capture mode
|
||||
org -c "Buy groceries" # Capture with pre-filled text
|
||||
org -c "Write report" tasks.org # Capture to specific file
|
||||
echo "Meeting notes" | org # Pipe text to capture
|
||||
echo "Task" | org ~/work.org # Pipe to specific file
|
||||
```
|
||||
|
||||
This is perfect for quickly capturing tasks from scripts, terminal workflows, or shell aliases. The capture mode skips the need to press 'c' once inside the application, making it faster to add quick TODO items.
|
||||
|
||||
### Multi-File Mode
|
||||
|
||||
Use the `-m` or `--multi` flag to load all `.org` files in a directory as top-level items. Each file appears as a top-level item in the interface, with its contents nested underneath. Changes made to items are automatically saved back to their respective files.
|
||||
|
||||
```bash
|
||||
org -m # Load all .org files in current directory
|
||||
org -m /path/to/dir # Load all .org files in specified directory
|
||||
```
|
||||
|
||||
**Example:** If you have these files in your directory:
|
||||
- `work.org` containing work tasks
|
||||
- `personal.org` containing personal tasks
|
||||
- `ideas.org` containing project ideas
|
||||
|
||||
Running `org -m` will display:
|
||||
```
|
||||
* work.org
|
||||
** TODO Complete project proposal
|
||||
** PROG Review code changes
|
||||
* personal.org
|
||||
** TODO Buy groceries
|
||||
* ideas.org
|
||||
** New app concept
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to fork and create a pull request if there's any features missing for your own use case!
|
||||
|
||||
## Features
|
||||
|
||||
### Task Management
|
||||
|
|
@ -51,6 +109,7 @@ org # Opens ./todo.org by default
|
|||
- **Syntax Highlighting**: Code blocks are automatically highlighted (supports both ```lang and #+BEGIN_SRC formats)
|
||||
- **Markdown Support**: Use markdown-style code blocks in your notes
|
||||
- **Drawer Management**: LOGBOOK and PROPERTIES drawers are automatically filtered in list view
|
||||
- **Fold/Unfold All**: Fold/Unfold all items with shift+tab
|
||||
|
||||
### Keybindings
|
||||
|
||||
|
|
@ -60,21 +119,25 @@ org # Opens ./todo.org by default
|
|||
| `←/h`, `→/l` | Cycle state backward/forward |
|
||||
| `t` or `space` | Cycle TODO state |
|
||||
| `tab` | Fold/unfold item |
|
||||
| `shift+tab` | Fold/Unfold all items |
|
||||
| `enter` | Edit notes |
|
||||
| `c` | Capture new TODO |
|
||||
| `s` | Add sub-task |
|
||||
| `D` | Delete item (with confirmation) |
|
||||
| `R` | Rename item |
|
||||
| `#` | Add/edit tags |
|
||||
| `a` | Toggle agenda view |
|
||||
| `i` | Clock in |
|
||||
| `o` | Clock out |
|
||||
| `d` | Set deadline |
|
||||
| `S` | Set scheduled date |
|
||||
| `p` | Set priority |
|
||||
| `e` | Set effort |
|
||||
| `r` | Toggle reorder mode |
|
||||
| `shift+↑/↓` | Move item up/down |
|
||||
| `sift+←/→` | Promote/demote item |
|
||||
| `,` | Open settings |
|
||||
| `ctrl+s` | Save |
|
||||
| `ctrl+s` | Force save |
|
||||
| `?` | Toggle help |
|
||||
| `q` or `ctrl+c` | Quit |
|
||||
|
||||
|
|
@ -96,6 +159,11 @@ Changes are automatically saved when you quit the application.
|
|||

|
||||

|
||||
|
||||
### Settings
|
||||

|
||||

|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
The application can be configured using a TOML configuration file located at:
|
||||
|
|
|
|||
102
cmd/org/main.go
102
cmd/org/main.go
|
|
@ -1,35 +1,59 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/rwejlgaard/org/internal/config"
|
||||
"github.com/rwejlgaard/org/internal/model"
|
||||
"github.com/rwejlgaard/org/internal/parser"
|
||||
"github.com/rwejlgaard/org/internal/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var filePath string
|
||||
flag.StringVar(&filePath, "file", "", "Path to org file (default: ./todo.org)")
|
||||
flag.StringVar(&filePath, "f", "", "Path to org file (shorthand)")
|
||||
var multiMode bool
|
||||
var captureMode bool
|
||||
flag.BoolVar(&multiMode, "multi", false, "Load all org files in current directory as top-level items")
|
||||
flag.BoolVar(&multiMode, "m", false, "Load all org files in current directory (shorthand)")
|
||||
flag.BoolVar(&captureMode, "capture", false, "Start in capture mode")
|
||||
flag.BoolVar(&captureMode, "c", false, "Start in capture mode (shorthand)")
|
||||
flag.Parse()
|
||||
|
||||
// Check for positional argument first
|
||||
if filePath == "" && len(flag.Args()) > 0 {
|
||||
filePath = flag.Args()[0]
|
||||
// Check for positional argument or capture text
|
||||
var captureText string
|
||||
if len(flag.Args()) > 0 {
|
||||
if captureMode {
|
||||
// First argument is capture text when in capture mode
|
||||
captureText = flag.Args()[0]
|
||||
// Second argument (if present) is the file path
|
||||
if len(flag.Args()) > 1 {
|
||||
filePath = flag.Args()[1]
|
||||
}
|
||||
} else {
|
||||
// First argument is file path in normal mode
|
||||
filePath = flag.Args()[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Default to ./todo.org if no file specified
|
||||
if filePath == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
// Check if input is being piped
|
||||
stat, _ := os.Stdin.Stat()
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
// Data is being piped to stdin
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
pipedText, err := io.ReadAll(reader)
|
||||
if err == nil && len(pipedText) > 0 {
|
||||
captureMode = true
|
||||
captureText = string(pipedText)
|
||||
// If no file path was provided via args, check if last arg could be a path
|
||||
if filePath == "" && len(flag.Args()) > 0 {
|
||||
filePath = flag.Args()[0]
|
||||
}
|
||||
}
|
||||
filePath = filepath.Join(cwd, "todo.org")
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
|
|
@ -39,15 +63,57 @@ func main() {
|
|||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
// Parse the org file
|
||||
orgFile, err := parser.ParseOrgFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err)
|
||||
os.Exit(1)
|
||||
var orgFile *model.OrgFile
|
||||
|
||||
if multiMode {
|
||||
// Multi-file mode: load all .org files in directory
|
||||
var dirPath string
|
||||
if filePath != "" {
|
||||
// Check if provided path is a directory
|
||||
info, err := os.Stat(filePath)
|
||||
if err == nil && info.IsDir() {
|
||||
dirPath = filePath
|
||||
} else {
|
||||
// Use directory of the provided file path
|
||||
dirPath = filepath.Dir(filePath)
|
||||
}
|
||||
} else {
|
||||
// Use current directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
dirPath = cwd
|
||||
}
|
||||
|
||||
orgFile, err = parser.ParseMultipleOrgFiles(dirPath, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing org files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
// Single file mode (default)
|
||||
if filePath == "" {
|
||||
// Default to ./todo.org
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
filePath = filepath.Join(cwd, "todo.org")
|
||||
}
|
||||
|
||||
// Parse the org file
|
||||
orgFile, err = parser.ParseOrgFile(filePath, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Run the UI
|
||||
if err := ui.RunUI(orgFile, cfg); err != nil {
|
||||
if err := ui.RunUI(orgFile, cfg, captureMode, captureText); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error running UI: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,12 @@ type KeybindingsConfig struct {
|
|||
Right []string `toml:"right"`
|
||||
ShiftUp []string `toml:"shift_up"`
|
||||
ShiftDown []string `toml:"shift_down"`
|
||||
ShiftLeft []string `toml:"shift_left"`
|
||||
ShiftRight []string `toml:"shift_right"`
|
||||
Rename []string `toml:"rename"`
|
||||
CycleState []string `toml:"cycle_state"`
|
||||
ToggleFold []string `toml:"toggle_fold"`
|
||||
ToggleFoldAll []string `toml:"toggle_fold_all"`
|
||||
EditNotes []string `toml:"edit_notes"`
|
||||
ToggleView []string `toml:"toggle_view"`
|
||||
Capture []string `toml:"capture"`
|
||||
|
|
@ -38,6 +42,7 @@ type KeybindingsConfig struct {
|
|||
ClockIn []string `toml:"clock_in"`
|
||||
ClockOut []string `toml:"clock_out"`
|
||||
SetDeadline []string `toml:"set_deadline"`
|
||||
SetScheduled []string `toml:"set_scheduled"`
|
||||
SetPriority []string `toml:"set_priority"`
|
||||
SetEffort []string `toml:"set_effort"`
|
||||
Help []string `toml:"help"`
|
||||
|
|
@ -88,9 +93,12 @@ type StatesConfig struct {
|
|||
|
||||
// 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"`
|
||||
HelpTextWidth int `toml:"help_text_width"`
|
||||
MinTerminalWidth int `toml:"min_terminal_width"`
|
||||
AgendaDays int `toml:"agenda_days"`
|
||||
OrgSyntaxHighlighting bool `toml:"org_syntax_highlighting"`
|
||||
ShowIndentationGuides bool `toml:"show_indentation_guides"`
|
||||
IndentationGuideColor string `toml:"indentation_guide_color"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default configuration
|
||||
|
|
@ -103,8 +111,12 @@ func DefaultConfig() *Config {
|
|||
Right: []string{"right", "l"},
|
||||
ShiftUp: []string{"shift+up"},
|
||||
ShiftDown: []string{"shift+down"},
|
||||
ShiftLeft: []string{"shift+left"},
|
||||
ShiftRight: []string{"shift+right"},
|
||||
Rename: []string{"R"},
|
||||
CycleState: []string{"t", " "},
|
||||
ToggleFold: []string{"tab"},
|
||||
ToggleFoldAll: []string{"shift+tab", "backtab"},
|
||||
EditNotes: []string{"enter"},
|
||||
ToggleView: []string{"a"},
|
||||
Capture: []string{"c"},
|
||||
|
|
@ -115,6 +127,7 @@ func DefaultConfig() *Config {
|
|||
ClockIn: []string{"i"},
|
||||
ClockOut: []string{"o"},
|
||||
SetDeadline: []string{"d"},
|
||||
SetScheduled: []string{"S"},
|
||||
SetPriority: []string{"p"},
|
||||
SetEffort: []string{"e"},
|
||||
Help: []string{"?"},
|
||||
|
|
@ -155,9 +168,12 @@ func DefaultConfig() *Config {
|
|||
DefaultNewTaskState: "TODO",
|
||||
},
|
||||
UI: UIConfig{
|
||||
HelpTextWidth: 22,
|
||||
MinTerminalWidth: 40,
|
||||
AgendaDays: 7,
|
||||
HelpTextWidth: 22,
|
||||
MinTerminalWidth: 40,
|
||||
AgendaDays: 7,
|
||||
OrgSyntaxHighlighting: true,
|
||||
ShowIndentationGuides: true,
|
||||
IndentationGuideColor: "245",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -255,12 +271,24 @@ func (c *Config) fillDefaults() {
|
|||
if len(c.Keybindings.ShiftDown) == 0 {
|
||||
c.Keybindings.ShiftDown = defaults.Keybindings.ShiftDown
|
||||
}
|
||||
if len(c.Keybindings.ShiftLeft) == 0 {
|
||||
c.Keybindings.ShiftLeft = defaults.Keybindings.ShiftLeft
|
||||
}
|
||||
if len(c.Keybindings.ShiftRight) == 0 {
|
||||
c.Keybindings.ShiftRight = defaults.Keybindings.ShiftRight
|
||||
}
|
||||
if len(c.Keybindings.Rename) == 0 {
|
||||
c.Keybindings.Rename = defaults.Keybindings.Rename
|
||||
}
|
||||
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.ToggleFoldAll) == 0 {
|
||||
c.Keybindings.ToggleFoldAll = defaults.Keybindings.ToggleFoldAll
|
||||
}
|
||||
if len(c.Keybindings.EditNotes) == 0 {
|
||||
c.Keybindings.EditNotes = defaults.Keybindings.EditNotes
|
||||
}
|
||||
|
|
@ -291,6 +319,9 @@ func (c *Config) fillDefaults() {
|
|||
if len(c.Keybindings.SetDeadline) == 0 {
|
||||
c.Keybindings.SetDeadline = defaults.Keybindings.SetDeadline
|
||||
}
|
||||
if len(c.Keybindings.SetScheduled) == 0 {
|
||||
c.Keybindings.SetScheduled = defaults.Keybindings.SetScheduled
|
||||
}
|
||||
if len(c.Keybindings.SetPriority) == 0 {
|
||||
c.Keybindings.SetPriority = defaults.Keybindings.SetPriority
|
||||
}
|
||||
|
|
@ -356,10 +387,11 @@ func (c *Config) fillDefaults() {
|
|||
// Fill states if empty
|
||||
if len(c.States.States) == 0 {
|
||||
c.States.States = defaults.States.States
|
||||
}
|
||||
if c.States.DefaultNewTaskState == "" {
|
||||
// Also set the default new task state since the entire states section is missing
|
||||
c.States.DefaultNewTaskState = defaults.States.DefaultNewTaskState
|
||||
}
|
||||
// Note: We don't fill DefaultNewTaskState if States.States is non-empty because
|
||||
// an empty string is a valid intentional value meaning "no default state".
|
||||
|
||||
// Fill UI if zero values
|
||||
if c.UI.HelpTextWidth == 0 {
|
||||
|
|
@ -371,6 +403,9 @@ func (c *Config) fillDefaults() {
|
|||
if c.UI.AgendaDays == 0 {
|
||||
c.UI.AgendaDays = defaults.UI.AgendaDays
|
||||
}
|
||||
if c.UI.IndentationGuideColor == "" {
|
||||
c.UI.IndentationGuideColor = defaults.UI.IndentationGuideColor
|
||||
}
|
||||
}
|
||||
|
||||
// BuildKeyBinding creates a key.Binding from config
|
||||
|
|
@ -492,6 +527,8 @@ func (c *Config) UpdateKeybinding(action string, keys []string) error {
|
|||
c.Keybindings.CycleState = keys
|
||||
case "toggle_fold":
|
||||
c.Keybindings.ToggleFold = keys
|
||||
case "toggle_fold_all":
|
||||
c.Keybindings.ToggleFoldAll = keys
|
||||
case "edit_notes":
|
||||
c.Keybindings.EditNotes = keys
|
||||
case "capture":
|
||||
|
|
@ -521,30 +558,35 @@ func (c *Config) UpdateKeybinding(action string, keys []string) error {
|
|||
// 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,
|
||||
"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,
|
||||
"shift_left": c.Keybindings.ShiftLeft,
|
||||
"shift_right": c.Keybindings.ShiftRight,
|
||||
"rename": c.Keybindings.Rename,
|
||||
"cycle_state": c.Keybindings.CycleState,
|
||||
"toggle_fold": c.Keybindings.ToggleFold,
|
||||
"toggle_fold_all": c.Keybindings.ToggleFoldAll,
|
||||
"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_scheduled": c.Keybindings.SetScheduled,
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,11 +21,13 @@ type Item struct {
|
|||
Tags []string // Tags for this item (e.g., :work:urgent:)
|
||||
Scheduled *time.Time
|
||||
Deadline *time.Time
|
||||
Closed *time.Time // Closed timestamp (when task was marked as done)
|
||||
Effort string // Effort estimate (e.g., "8h", "2d")
|
||||
Notes []string // Notes/content under the heading
|
||||
Children []*Item // Sub-items
|
||||
Folded bool // Whether the item is folded (hides notes and children)
|
||||
ClockEntries []ClockEntry // Clock in/out entries
|
||||
SourceFile string // Source file path (used in multi-file mode)
|
||||
}
|
||||
|
||||
// OrgFile represents a parsed org-mode file
|
||||
|
|
|
|||
|
|
@ -3,28 +3,52 @@ package parser
|
|||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/rwejlgaard/org/internal/config"
|
||||
"github.com/rwejlgaard/org/internal/model"
|
||||
)
|
||||
|
||||
// Parser patterns
|
||||
var (
|
||||
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*\[([^\]]+)\](?:--\[([^\]]+)\])?`)
|
||||
effortPattern = regexp.MustCompile(`^\s*:EFFORT:\s*(.+)$`)
|
||||
logbookDrawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`)
|
||||
scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`)
|
||||
deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`)
|
||||
closedPattern = regexp.MustCompile(`CLOSED:\s*\[([^\]]+)\]`)
|
||||
clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`)
|
||||
effortPattern = regexp.MustCompile(`^\s*:EFFORT:\s*(.+)$`)
|
||||
logbookDrawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`)
|
||||
propertiesDrawerStart = regexp.MustCompile(`^\s*:PROPERTIES:\s*$`)
|
||||
drawerEnd = regexp.MustCompile(`^\s*:END:\s*$`)
|
||||
codeBlockStart = regexp.MustCompile(`^\s*#\+BEGIN_SRC`)
|
||||
codeBlockEnd = regexp.MustCompile(`^\s*#\+END_SRC`)
|
||||
drawerEnd = regexp.MustCompile(`^\s*:END:\s*$`)
|
||||
codeBlockStart = regexp.MustCompile(`^\s*#\+BEGIN_SRC`)
|
||||
codeBlockEnd = regexp.MustCompile(`^\s*#\+END_SRC`)
|
||||
)
|
||||
|
||||
// buildHeadingPattern creates a regex pattern that matches configured states
|
||||
func buildHeadingPattern(cfg *config.Config) *regexp.Regexp {
|
||||
stateNames := cfg.GetStateNames()
|
||||
var statesPattern string
|
||||
if len(stateNames) > 0 {
|
||||
// Escape state names and join with |
|
||||
escapedStates := make([]string, len(stateNames))
|
||||
for i, state := range stateNames {
|
||||
escapedStates[i] = regexp.QuoteMeta(state)
|
||||
}
|
||||
statesPattern = strings.Join(escapedStates, "|")
|
||||
} else {
|
||||
// Fallback to default states if none configured
|
||||
statesPattern = "TODO|PROG|BLOCK|DONE"
|
||||
}
|
||||
|
||||
pattern := `^(\*+)\s+(?:(` + statesPattern + `)\s+)?(?:\[#([A-C])\]\s+)?(.+?)(?:\s+(:[[:alnum:]_@#%:]+:)\s*)?$`
|
||||
return regexp.MustCompile(pattern)
|
||||
}
|
||||
|
||||
// ParseOrgFile reads and parses an org-mode file
|
||||
func ParseOrgFile(path string) (*model.OrgFile, error) {
|
||||
func ParseOrgFile(path string, cfg *config.Config) (*model.OrgFile, error) {
|
||||
headingPattern := buildHeadingPattern(cfg)
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
// If file doesn't exist, return empty org file
|
||||
|
|
@ -164,6 +188,13 @@ func ParseOrgFile(path string) (*model.OrgFile, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Check for CLOSED
|
||||
if matches := closedPattern.FindStringSubmatch(line); matches != nil {
|
||||
if t, err := parseClockTimestamp(matches[1]); err == nil {
|
||||
currentItem.Closed = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Check for EFFORT (inside PROPERTIES drawer)
|
||||
if matches := effortPattern.FindStringSubmatch(line); matches != nil {
|
||||
currentItem.Effort = strings.TrimSpace(matches[1])
|
||||
|
|
@ -195,3 +226,70 @@ func ParseOrgFile(path string) (*model.OrgFile, error) {
|
|||
|
||||
return orgFile, nil
|
||||
}
|
||||
|
||||
// ParseMultipleOrgFiles loads all .org files in a directory and wraps them as top-level items
|
||||
func ParseMultipleOrgFiles(dirPath string, cfg *config.Config) (*model.OrgFile, error) {
|
||||
// Find all .org files in the directory
|
||||
matches, err := filepath.Glob(filepath.Join(dirPath, "*.org"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort files alphabetically
|
||||
sort.Strings(matches)
|
||||
|
||||
// Create a virtual org file
|
||||
multiOrgFile := &model.OrgFile{
|
||||
Path: dirPath, // Store directory path
|
||||
Items: []*model.Item{},
|
||||
}
|
||||
|
||||
// Parse each file and wrap it as a top-level item
|
||||
for _, filePath := range matches {
|
||||
orgFile, err := ParseOrgFile(filePath, cfg)
|
||||
if err != nil {
|
||||
// Skip files that can't be parsed
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a wrapper item for this file
|
||||
fileName := filepath.Base(filePath)
|
||||
fileItem := &model.Item{
|
||||
Level: 1,
|
||||
State: model.StateNone,
|
||||
Priority: model.PriorityNone,
|
||||
Title: fileName,
|
||||
Tags: []string{},
|
||||
Notes: []string{},
|
||||
Children: []*model.Item{},
|
||||
SourceFile: filePath,
|
||||
}
|
||||
|
||||
// Increment the level of all items from this file and add as children
|
||||
for _, item := range orgFile.Items {
|
||||
incrementItemLevel(item)
|
||||
setSourceFileRecursive(item, filePath)
|
||||
fileItem.Children = append(fileItem.Children, item)
|
||||
}
|
||||
|
||||
multiOrgFile.Items = append(multiOrgFile.Items, fileItem)
|
||||
}
|
||||
|
||||
return multiOrgFile, nil
|
||||
}
|
||||
|
||||
// incrementItemLevel recursively increments the level of an item and its children
|
||||
func incrementItemLevel(item *model.Item) {
|
||||
item.Level++
|
||||
for _, child := range item.Children {
|
||||
incrementItemLevel(child)
|
||||
}
|
||||
}
|
||||
|
||||
// setSourceFileRecursive sets the source file for an item and all its descendants
|
||||
func setSourceFileRecursive(item *model.Item, filePath string) {
|
||||
item.SourceFile = filePath
|
||||
for _, child := range item.Children {
|
||||
setSourceFileRecursive(child, filePath)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ import (
|
|||
|
||||
// Save writes the org file back to disk
|
||||
func Save(orgFile *model.OrgFile) error {
|
||||
// Check if this is a multi-file org (directory-based)
|
||||
// In multi-file mode, top-level items have SourceFile set and represent files
|
||||
isMultiFile := false
|
||||
if len(orgFile.Items) > 0 && orgFile.Items[0].SourceFile != "" {
|
||||
isMultiFile = true
|
||||
}
|
||||
|
||||
if isMultiFile {
|
||||
return saveMultiFile(orgFile)
|
||||
}
|
||||
|
||||
// Single file mode
|
||||
file, err := os.Create(orgFile.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -29,6 +41,66 @@ func Save(orgFile *model.OrgFile) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// saveMultiFile saves items back to their individual source files
|
||||
func saveMultiFile(orgFile *model.OrgFile) error {
|
||||
// Group items by source file
|
||||
fileItems := make(map[string][]*model.Item)
|
||||
|
||||
for _, fileItem := range orgFile.Items {
|
||||
if fileItem.SourceFile == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// The children of this file item are the actual items to save
|
||||
fileItems[fileItem.SourceFile] = fileItem.Children
|
||||
}
|
||||
|
||||
// Save each file
|
||||
for filePath, items := range fileItems {
|
||||
if err := saveItemsToFile(filePath, items); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveItemsToFile writes a list of items to a specific file
|
||||
func saveItemsToFile(filePath string, items []*model.Item) error {
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
|
||||
for _, item := range items {
|
||||
// Decrement level since we're saving to individual files
|
||||
decrementedItem := decrementItemLevelForSave(item)
|
||||
if err := writeItem(writer, decrementedItem); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decrementItemLevelForSave creates a copy of an item with decremented levels for saving
|
||||
func decrementItemLevelForSave(item *model.Item) *model.Item {
|
||||
copied := *item
|
||||
copied.Level--
|
||||
|
||||
copiedChildren := make([]*model.Item, len(item.Children))
|
||||
for i, child := range item.Children {
|
||||
copiedChildren[i] = decrementItemLevelForSave(child)
|
||||
}
|
||||
copied.Children = copiedChildren
|
||||
|
||||
return &copied
|
||||
}
|
||||
|
||||
// writeItem recursively writes an item and its children
|
||||
func writeItem(writer *bufio.Writer, item *model.Item) error {
|
||||
// Write heading
|
||||
|
|
@ -56,6 +128,7 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
|
|||
// Write scheduling info if not already in notes
|
||||
hasScheduled := false
|
||||
hasDeadline := false
|
||||
hasClosed := false
|
||||
hasLogbook := false
|
||||
hasProperties := false
|
||||
for _, note := range item.Notes {
|
||||
|
|
@ -65,6 +138,9 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
|
|||
if strings.Contains(note, "DEADLINE:") {
|
||||
hasDeadline = true
|
||||
}
|
||||
if strings.Contains(note, "CLOSED:") {
|
||||
hasClosed = true
|
||||
}
|
||||
if strings.Contains(note, ":LOGBOOK:") {
|
||||
hasLogbook = true
|
||||
}
|
||||
|
|
@ -73,6 +149,13 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
|
|||
}
|
||||
}
|
||||
|
||||
if item.Closed != nil && !hasClosed {
|
||||
closedLine := fmt.Sprintf("CLOSED: [%s]\n", formatClockTimestamp(*item.Closed))
|
||||
if _, err := writer.WriteString(closedLine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if item.Scheduled != nil && !hasScheduled {
|
||||
scheduledLine := fmt.Sprintf("SCHEDULED: <%s>\n", FormatOrgDate(*item.Scheduled))
|
||||
if _, err := writer.WriteString(scheduledLine); err != nil {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
|
|
@ -21,38 +22,41 @@ const (
|
|||
modeCapture
|
||||
modeAddSubTask
|
||||
modeSetDeadline
|
||||
modeSetScheduled
|
||||
modeSetPriority
|
||||
modeSetEffort
|
||||
modeHelp
|
||||
modeSettings
|
||||
modeTagEdit
|
||||
modeRename
|
||||
)
|
||||
|
||||
type uiModel struct {
|
||||
orgFile *model.OrgFile
|
||||
cursor int
|
||||
scrollOffset int // Track the scroll position
|
||||
helpScroll int // Track scroll position in help mode
|
||||
mode viewMode
|
||||
help help.Model
|
||||
keys keyMap
|
||||
styles styleMap
|
||||
config *config.Config
|
||||
width int
|
||||
height int
|
||||
statusMsg string
|
||||
statusExpiry time.Time
|
||||
editingItem *model.Item
|
||||
textarea textarea.Model
|
||||
textinput textinput.Model
|
||||
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
|
||||
orgFile *model.OrgFile
|
||||
cursor int
|
||||
scrollOffset int // Track the scroll position
|
||||
helpScroll int // Track scroll position in help mode
|
||||
mode viewMode
|
||||
help help.Model
|
||||
keys keyMap
|
||||
styles styleMap
|
||||
config *config.Config
|
||||
width int
|
||||
height int
|
||||
statusMsg string
|
||||
statusExpiry time.Time
|
||||
editingItem *model.Item
|
||||
textarea textarea.Model
|
||||
textinput textinput.Model
|
||||
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
|
||||
captureCursor int // Store cursor position when entering capture mode
|
||||
}
|
||||
|
||||
func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel {
|
||||
func InitialModel(orgFile *model.OrgFile, cfg *config.Config, captureMode bool, captureText string) uiModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "Enter notes here (code blocks supported)..."
|
||||
ta.ShowLineNumbers = false
|
||||
|
|
@ -64,10 +68,16 @@ func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel {
|
|||
h := help.New()
|
||||
h.ShowAll = false
|
||||
|
||||
mode := modeList
|
||||
if captureMode {
|
||||
mode = modeCapture
|
||||
ti.SetValue(strings.TrimSpace(captureText))
|
||||
}
|
||||
|
||||
return uiModel{
|
||||
orgFile: orgFile,
|
||||
cursor: 0,
|
||||
mode: modeList,
|
||||
mode: mode,
|
||||
help: h,
|
||||
keys: newKeyMapFromConfig(cfg),
|
||||
styles: newStyleMapFromConfig(cfg),
|
||||
|
|
@ -78,6 +88,9 @@ func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel {
|
|||
}
|
||||
|
||||
func (m uiModel) Init() tea.Cmd {
|
||||
if m.mode == modeCapture {
|
||||
return textinput.Blink
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -104,8 +117,13 @@ func (m *uiModel) updateScrollOffset(availableHeight int) {
|
|||
for i, item := range items {
|
||||
lineCount := 1 // The item itself
|
||||
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
|
||||
// Count note lines (simplified - just count notes)
|
||||
lineCount += len(item.Notes)
|
||||
// Count note lines with wrapping
|
||||
indent := strings.Repeat(" ", item.Level)
|
||||
noteIndent := indent + " "
|
||||
filteredNotes := filterLogbookDrawer(item.Notes)
|
||||
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
|
||||
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
|
||||
lineCount += len(highlightedNotes)
|
||||
}
|
||||
itemLineCount[i] = lineCount
|
||||
}
|
||||
|
|
@ -132,8 +150,12 @@ func (m *uiModel) updateScrollOffset(availableHeight int) {
|
|||
}
|
||||
|
||||
// RunUI starts the terminal UI
|
||||
func RunUI(orgFile *model.OrgFile, cfg *config.Config) error {
|
||||
p := tea.NewProgram(InitialModel(orgFile, cfg), tea.WithAltScreen())
|
||||
func RunUI(orgFile *model.OrgFile, cfg *config.Config, captureMode bool, captureText string) error {
|
||||
m := InitialModel(orgFile, cfg, captureMode, captureText)
|
||||
if captureMode {
|
||||
m.textinput.Focus()
|
||||
}
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ type keyMap struct {
|
|||
Right key.Binding
|
||||
ShiftUp key.Binding
|
||||
ShiftDown key.Binding
|
||||
ShiftLeft key.Binding
|
||||
ShiftRight key.Binding
|
||||
Rename key.Binding
|
||||
CycleState key.Binding
|
||||
ToggleView key.Binding
|
||||
Quit key.Binding
|
||||
|
|
@ -23,11 +26,13 @@ type keyMap struct {
|
|||
Delete key.Binding
|
||||
Save key.Binding
|
||||
ToggleFold key.Binding
|
||||
ToggleFoldAll key.Binding
|
||||
EditNotes key.Binding
|
||||
ToggleReorder key.Binding
|
||||
ClockIn key.Binding
|
||||
ClockOut key.Binding
|
||||
SetDeadline key.Binding
|
||||
SetScheduled key.Binding
|
||||
SetPriority key.Binding
|
||||
SetEffort key.Binding
|
||||
Settings key.Binding
|
||||
|
|
@ -63,6 +68,18 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
|
|||
key.WithKeys(kb.ShiftDown...),
|
||||
key.WithHelp(formatKeyHelp(kb.ShiftDown), "move item down"),
|
||||
),
|
||||
ShiftLeft: key.NewBinding(
|
||||
key.WithKeys(kb.ShiftLeft...),
|
||||
key.WithHelp(formatKeyHelp(kb.ShiftLeft), "promote item"),
|
||||
),
|
||||
ShiftRight: key.NewBinding(
|
||||
key.WithKeys(kb.ShiftRight...),
|
||||
key.WithHelp(formatKeyHelp(kb.ShiftRight), "demote item"),
|
||||
),
|
||||
Rename: key.NewBinding(
|
||||
key.WithKeys(kb.Rename...),
|
||||
key.WithHelp(formatKeyHelp(kb.Rename), "rename item"),
|
||||
),
|
||||
CycleState: key.NewBinding(
|
||||
key.WithKeys(kb.CycleState...),
|
||||
key.WithHelp(formatKeyHelp(kb.CycleState), "cycle todo state"),
|
||||
|
|
@ -71,6 +88,10 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
|
|||
key.WithKeys(kb.ToggleFold...),
|
||||
key.WithHelp(formatKeyHelp(kb.ToggleFold), "fold/unfold"),
|
||||
),
|
||||
ToggleFoldAll: key.NewBinding(
|
||||
key.WithKeys(kb.ToggleFoldAll...),
|
||||
key.WithHelp(formatKeyHelp(kb.ToggleFoldAll), "fold/unfold all"),
|
||||
),
|
||||
EditNotes: key.NewBinding(
|
||||
key.WithKeys(kb.EditNotes...),
|
||||
key.WithHelp(formatKeyHelp(kb.EditNotes), "edit notes"),
|
||||
|
|
@ -111,6 +132,10 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
|
|||
key.WithKeys(kb.SetDeadline...),
|
||||
key.WithHelp(formatKeyHelp(kb.SetDeadline), "set deadline"),
|
||||
),
|
||||
SetScheduled: key.NewBinding(
|
||||
key.WithKeys(kb.SetScheduled...),
|
||||
key.WithHelp(formatKeyHelp(kb.SetScheduled), "set scheduled"),
|
||||
),
|
||||
SetPriority: key.NewBinding(
|
||||
key.WithKeys(kb.SetPriority...),
|
||||
key.WithHelp(formatKeyHelp(kb.SetPriority), "set priority"),
|
||||
|
|
@ -168,7 +193,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
|
|||
// This will be overridden by custom rendering in viewFullHelp
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Left, k.Right},
|
||||
{k.ToggleFold, k.EditNotes, k.ToggleReorder},
|
||||
{k.ToggleFold, k.ToggleFoldAll, k.EditNotes, k.ToggleReorder},
|
||||
{k.Capture, k.AddSubTask, k.Delete, k.Save},
|
||||
{k.ToggleView, k.Help, k.Quit},
|
||||
}
|
||||
|
|
@ -178,9 +203,9 @@ func (k keyMap) FullHelp() [][]key.Binding {
|
|||
func (k keyMap) getAllBindings() []key.Binding {
|
||||
return []key.Binding{
|
||||
k.Up, k.Down, k.Left, k.Right,
|
||||
k.ToggleFold, k.EditNotes, k.ToggleReorder,
|
||||
k.ToggleFold, k.ToggleFoldAll, k.EditNotes, k.ToggleReorder,
|
||||
k.Capture, k.AddSubTask, k.Delete, k.Save,
|
||||
k.ClockIn, k.ClockOut, k.SetDeadline, k.SetPriority, k.SetEffort,
|
||||
k.ClockIn, k.ClockOut, k.SetDeadline, k.SetScheduled, k.SetPriority, k.SetEffort,
|
||||
k.TagItem, k.Settings, k.ToggleView, k.Help, k.Quit,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
380
internal/ui/latex.go
Normal file
380
internal/ui/latex.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
package ui
|
||||
|
||||
import "strings"
|
||||
|
||||
// renderLatexMath converts LaTeX math expressions to Unicode for terminal display
|
||||
func renderLatexMath(latex string) string {
|
||||
result := latex
|
||||
|
||||
// Remove LaTeX math delimiters
|
||||
result = strings.ReplaceAll(result, `\(`, "")
|
||||
result = strings.ReplaceAll(result, `\)`, "")
|
||||
result = strings.ReplaceAll(result, `\[`, "")
|
||||
result = strings.ReplaceAll(result, `\]`, "")
|
||||
result = strings.ReplaceAll(result, `$$`, "")
|
||||
|
||||
// Remove single $ delimiters (being careful not to remove actual dollar signs in text)
|
||||
// Simple approach: if line starts/ends with $, remove it
|
||||
result = strings.TrimSpace(result)
|
||||
if strings.HasPrefix(result, "$") && strings.HasSuffix(result, "$") && len(result) > 2 {
|
||||
result = result[1 : len(result)-1]
|
||||
result = strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
// Map of common LaTeX commands to Unicode equivalents
|
||||
replacements := map[string]string{
|
||||
// Greek letters (lowercase)
|
||||
`\alpha`: "α",
|
||||
`\beta`: "β",
|
||||
`\gamma`: "γ",
|
||||
`\delta`: "δ",
|
||||
`\epsilon`: "ε",
|
||||
`\zeta`: "ζ",
|
||||
`\eta`: "η",
|
||||
`\theta`: "θ",
|
||||
`\iota`: "ι",
|
||||
`\kappa`: "κ",
|
||||
`\lambda`: "λ",
|
||||
`\mu`: "μ",
|
||||
`\nu`: "ν",
|
||||
`\xi`: "ξ",
|
||||
`\pi`: "π",
|
||||
`\rho`: "ρ",
|
||||
`\sigma`: "σ",
|
||||
`\tau`: "τ",
|
||||
`\upsilon`: "υ",
|
||||
`\phi`: "φ",
|
||||
`\chi`: "χ",
|
||||
`\psi`: "ψ",
|
||||
`\omega`: "ω",
|
||||
|
||||
// Greek letters (uppercase)
|
||||
`\Gamma`: "Γ",
|
||||
`\Delta`: "Δ",
|
||||
`\Theta`: "Θ",
|
||||
`\Lambda`: "Λ",
|
||||
`\Xi`: "Ξ",
|
||||
`\Pi`: "Π",
|
||||
`\Sigma`: "Σ",
|
||||
`\Upsilon`: "Υ",
|
||||
`\Phi`: "Φ",
|
||||
`\Psi`: "Ψ",
|
||||
`\Omega`: "Ω",
|
||||
|
||||
// Math operators
|
||||
`\times`: "×",
|
||||
`\div`: "÷",
|
||||
`\pm`: "±",
|
||||
`\mp`: "∓",
|
||||
`\cdot`: "·",
|
||||
`\star`: "⋆",
|
||||
`\ast`: "∗",
|
||||
`\circ`: "∘",
|
||||
`\bullet`: "•",
|
||||
|
||||
// Relations
|
||||
`\le`: "≤",
|
||||
`\ge`: "≥",
|
||||
`\leq`: "≤",
|
||||
`\geq`: "≥",
|
||||
`\ne`: "≠",
|
||||
`\neq`: "≠",
|
||||
`\approx`: "≈",
|
||||
`\equiv`: "≡",
|
||||
`\sim`: "∼",
|
||||
`\simeq`: "≃",
|
||||
`\propto`: "∝",
|
||||
|
||||
// Arrows
|
||||
`\to`: "→",
|
||||
`\rightarrow`: "→",
|
||||
`\leftarrow`: "←",
|
||||
`\Rightarrow`: "⇒",
|
||||
`\Leftarrow`: "⇐",
|
||||
`\mapsto`: "↦",
|
||||
|
||||
// Set theory
|
||||
`\in`: "∈",
|
||||
`\notin`: "∉",
|
||||
`\subset`: "⊂",
|
||||
`\supset`: "⊃",
|
||||
`\subseteq`: "⊆",
|
||||
`\supseteq`: "⊇",
|
||||
`\cup`: "∪",
|
||||
`\cap`: "∩",
|
||||
`\emptyset`: "∅",
|
||||
`\forall`: "∀",
|
||||
`\exists`: "∃",
|
||||
|
||||
// Calculus
|
||||
`\partial`: "∂",
|
||||
`\nabla`: "∇",
|
||||
`\int`: "∫",
|
||||
`\sum`: "∑",
|
||||
`\prod`: "∏",
|
||||
`\infty`: "∞",
|
||||
|
||||
// Logic
|
||||
`\land`: "∧",
|
||||
`\lor`: "∨",
|
||||
`\lnot`: "¬",
|
||||
`\neg`: "¬",
|
||||
`\wedge`: "∧",
|
||||
`\vee`: "∨",
|
||||
|
||||
// Special symbols
|
||||
`\hbar`: "ℏ",
|
||||
`\ell`: "ℓ",
|
||||
`\Re`: "ℜ",
|
||||
`\Im`: "ℑ",
|
||||
`\angle`: "∠",
|
||||
`\triangle`: "△",
|
||||
`\square`: "□",
|
||||
`\degree`: "°",
|
||||
|
||||
// Superscripts (common ones)
|
||||
`^0`: "⁰",
|
||||
`^1`: "¹",
|
||||
`^2`: "²",
|
||||
`^3`: "³",
|
||||
`^4`: "⁴",
|
||||
`^5`: "⁵",
|
||||
`^6`: "⁶",
|
||||
`^7`: "⁷",
|
||||
`^8`: "⁸",
|
||||
`^9`: "⁹",
|
||||
`^+`: "⁺",
|
||||
`^-`: "⁻",
|
||||
`^=`: "⁼",
|
||||
`^(`: "⁽",
|
||||
`^)`: "⁾",
|
||||
|
||||
// Subscripts (common ones)
|
||||
`_0`: "₀",
|
||||
`_1`: "₁",
|
||||
`_2`: "₂",
|
||||
`_3`: "₃",
|
||||
`_4`: "₄",
|
||||
`_5`: "₅",
|
||||
`_6`: "₆",
|
||||
`_7`: "₇",
|
||||
`_8`: "₈",
|
||||
`_9`: "₉",
|
||||
`_+`: "₊",
|
||||
`_-`: "₋",
|
||||
`_=`: "₌",
|
||||
`_(`: "₍",
|
||||
`_)`: "₎",
|
||||
}
|
||||
|
||||
// Apply all replacements
|
||||
for latexCmd, unicode := range replacements {
|
||||
result = strings.ReplaceAll(result, latexCmd, unicode)
|
||||
}
|
||||
|
||||
// Handle simple fractions \frac{a}{b} -> a/b
|
||||
result = handleFractions(result)
|
||||
|
||||
// Handle square roots \sqrt{x} -> √(x)
|
||||
result = handleSquareRoots(result)
|
||||
|
||||
// Handle superscripts ^{...} and subscripts _{...}
|
||||
result = handleSuperscripts(result)
|
||||
result = handleSubscripts(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// handleFractions converts \frac{numerator}{denominator} to numerator/denominator
|
||||
func handleFractions(text string) string {
|
||||
// Simple regex-free approach for basic fractions
|
||||
result := text
|
||||
for {
|
||||
start := strings.Index(result, `\frac{`)
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Find the numerator
|
||||
numStart := start + 6
|
||||
numEnd, numerator := findBracedContent(result, numStart)
|
||||
if numEnd == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Find the denominator
|
||||
if numEnd >= len(result) || result[numEnd] != '{' {
|
||||
break
|
||||
}
|
||||
denomEnd, denominator := findBracedContent(result, numEnd+1)
|
||||
if denomEnd == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Replace \frac{num}{denom} with (num)/(denom)
|
||||
replacement := "(" + numerator + ")/(" + denominator + ")"
|
||||
result = result[:start] + replacement + result[denomEnd:]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// handleSquareRoots converts \sqrt{x} to √(x)
|
||||
func handleSquareRoots(text string) string {
|
||||
result := text
|
||||
for {
|
||||
start := strings.Index(result, `\sqrt{`)
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Find the content
|
||||
contentStart := start + 6
|
||||
contentEnd, content := findBracedContent(result, contentStart)
|
||||
if contentEnd == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Replace \sqrt{content} with √(content)
|
||||
replacement := "√(" + content + ")"
|
||||
result = result[:start] + replacement + result[contentEnd:]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// findBracedContent finds content within braces starting at position i
|
||||
// Returns the position after the closing brace and the content
|
||||
func findBracedContent(text string, start int) (int, string) {
|
||||
if start >= len(text) {
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
depth := 1
|
||||
i := start
|
||||
|
||||
for i < len(text) && depth > 0 {
|
||||
if text[i] == '{' {
|
||||
depth++
|
||||
} else if text[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i + 1, text[start:i]
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
// handleSuperscripts converts ^{...} to Unicode superscripts where possible
|
||||
func handleSuperscripts(text string) string {
|
||||
superscriptMap := map[rune]string{
|
||||
'0': "⁰", '1': "¹", '2': "²", '3': "³", '4': "⁴",
|
||||
'5': "⁵", '6': "⁶", '7': "⁷", '8': "⁸", '9': "⁹",
|
||||
'a': "ᵃ", 'b': "ᵇ", 'c': "ᶜ", 'd': "ᵈ", 'e': "ᵉ",
|
||||
'f': "ᶠ", 'g': "ᵍ", 'h': "ʰ", 'i': "ⁱ", 'j': "ʲ",
|
||||
'k': "ᵏ", 'l': "ˡ", 'm': "ᵐ", 'n': "ⁿ", 'o': "ᵒ",
|
||||
'p': "ᵖ", 'r': "ʳ", 's': "ˢ", 't': "ᵗ", 'u': "ᵘ",
|
||||
'v': "ᵛ", 'w': "ʷ", 'x': "ˣ", 'y': "ʸ", 'z': "ᶻ",
|
||||
'A': "ᴬ", 'B': "ᴮ", 'D': "ᴰ", 'E': "ᴱ", 'G': "ᴳ",
|
||||
'H': "ᴴ", 'I': "ᴵ", 'J': "ᴶ", 'K': "ᴷ", 'L': "ᴸ",
|
||||
'M': "ᴹ", 'N': "ᴺ", 'O': "ᴼ", 'P': "ᴾ", 'R': "ᴿ",
|
||||
'T': "ᵀ", 'U': "ᵁ", 'V': "ⱽ", 'W': "ᵂ",
|
||||
'+': "⁺", '-': "⁻", '=': "⁼", '(': "⁽", ')': "⁾",
|
||||
}
|
||||
|
||||
result := text
|
||||
|
||||
// Handle ^{...} format
|
||||
for {
|
||||
start := strings.Index(result, "^{")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
contentStart := start + 2
|
||||
contentEnd, content := findBracedContent(result, contentStart)
|
||||
if contentEnd == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Convert content to superscript
|
||||
var superscript strings.Builder
|
||||
for _, ch := range content {
|
||||
if sup, ok := superscriptMap[ch]; ok {
|
||||
superscript.WriteString(sup)
|
||||
} else {
|
||||
// If no superscript version exists, wrap in parentheses
|
||||
superscript.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
|
||||
result = result[:start] + superscript.String() + result[contentEnd:]
|
||||
}
|
||||
|
||||
// Handle simple ^x format (single character without braces)
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
if result[i] == '^' && result[i+1] != '{' {
|
||||
ch := rune(result[i+1])
|
||||
if sup, ok := superscriptMap[ch]; ok {
|
||||
result = result[:i] + sup + result[i+2:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// handleSubscripts converts _{...} to Unicode subscripts where possible
|
||||
func handleSubscripts(text string) string {
|
||||
subscriptMap := map[rune]string{
|
||||
'0': "₀", '1': "₁", '2': "₂", '3': "₃", '4': "₄",
|
||||
'5': "₅", '6': "₆", '7': "₇", '8': "₈", '9': "₉",
|
||||
'a': "ₐ", 'e': "ₑ", 'h': "ₕ", 'i': "ᵢ", 'j': "ⱼ",
|
||||
'k': "ₖ", 'l': "ₗ", 'm': "ₘ", 'n': "ₙ", 'o': "ₒ",
|
||||
'p': "ₚ", 'r': "ᵣ", 's': "ₛ", 't': "ₜ", 'u': "ᵤ",
|
||||
'v': "ᵥ", 'x': "ₓ",
|
||||
'+': "₊", '-': "₋", '=': "₌", '(': "₍", ')': "₎",
|
||||
}
|
||||
|
||||
result := text
|
||||
|
||||
// Handle _{...} format
|
||||
for {
|
||||
start := strings.Index(result, "_{")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
contentStart := start + 2
|
||||
contentEnd, content := findBracedContent(result, contentStart)
|
||||
if contentEnd == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Convert content to subscript
|
||||
var subscript strings.Builder
|
||||
for _, ch := range content {
|
||||
if sub, ok := subscriptMap[ch]; ok {
|
||||
subscript.WriteString(sub)
|
||||
} else {
|
||||
// If no subscript version exists, wrap in parentheses
|
||||
subscript.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
|
||||
result = result[:start] + subscript.String() + result[contentEnd:]
|
||||
}
|
||||
|
||||
// Handle simple _x format (single character without braces)
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
if result[i] == '_' && result[i+1] != '{' {
|
||||
ch := rune(result[i+1])
|
||||
if sub, ok := subscriptMap[ch]; ok {
|
||||
result = result[:i] + sub + result[i+2:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m.updateAddSubTask(msg)
|
||||
case modeSetDeadline:
|
||||
return m.updateSetDeadline(msg)
|
||||
case modeSetScheduled:
|
||||
return m.updateSetScheduled(msg)
|
||||
case modeSetPriority:
|
||||
return m.updateSetPriority(msg)
|
||||
case modeSetEffort:
|
||||
|
|
@ -40,6 +42,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m.updateSettingsAddState(msg)
|
||||
case modeTagEdit:
|
||||
return m.updateTagEdit(msg)
|
||||
case modeRename:
|
||||
return m.updateRename(msg)
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
|
@ -122,6 +126,12 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case key.Matches(msg, m.keys.ShiftDown):
|
||||
m.moveItemDown()
|
||||
|
||||
case key.Matches(msg, m.keys.ShiftLeft):
|
||||
m.promoteItem()
|
||||
|
||||
case key.Matches(msg, m.keys.ShiftRight):
|
||||
m.demoteItem()
|
||||
|
||||
case key.Matches(msg, m.keys.CycleState):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
|
|
@ -145,10 +155,61 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.ToggleFoldAll):
|
||||
if len(m.orgFile.Items) > 0 {
|
||||
// Check if any top-level item is not folded
|
||||
anyUnfolded := false
|
||||
for _, item := range m.orgFile.Items {
|
||||
if !item.Folded {
|
||||
anyUnfolded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if anyUnfolded {
|
||||
// Fold all items recursively (collapse all)
|
||||
var foldAll func([]*model.Item)
|
||||
foldAll = func(items []*model.Item) {
|
||||
for _, item := range items {
|
||||
item.Folded = true
|
||||
if len(item.Children) > 0 {
|
||||
foldAll(item.Children)
|
||||
}
|
||||
}
|
||||
}
|
||||
foldAll(m.orgFile.Items)
|
||||
m.setStatus("All items folded")
|
||||
} else {
|
||||
// Unfold everything recursively
|
||||
var unfoldAll func([]*model.Item)
|
||||
unfoldAll = func(items []*model.Item) {
|
||||
for _, item := range items {
|
||||
item.Folded = false
|
||||
if len(item.Children) > 0 {
|
||||
unfoldAll(item.Children)
|
||||
}
|
||||
}
|
||||
}
|
||||
unfoldAll(m.orgFile.Items)
|
||||
m.setStatus("All items unfolded")
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.EditNotes):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
m.editingItem = items[m.cursor]
|
||||
selectedItem := items[m.cursor]
|
||||
|
||||
// Check if we're in multi-file mode
|
||||
isMultiFile := len(m.orgFile.Items) > 0 && m.orgFile.Items[0].SourceFile != ""
|
||||
|
||||
// Prevent editing notes for top-level file items in multi-file mode
|
||||
if isMultiFile && selectedItem.Level == 1 && selectedItem.SourceFile != "" {
|
||||
m.setStatus("Cannot add notes to file-level items")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.editingItem = selectedItem
|
||||
m.mode = modeEdit
|
||||
m.textarea.SetValue(strings.Join(m.editingItem.Notes, "\n"))
|
||||
m.textarea.Focus()
|
||||
|
|
@ -171,8 +232,20 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Rename):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
m.editingItem = items[m.cursor]
|
||||
m.mode = modeRename
|
||||
m.textinput.SetValue(items[m.cursor].Title)
|
||||
m.textinput.Placeholder = "Item title"
|
||||
m.textinput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Capture):
|
||||
m.mode = modeCapture
|
||||
m.captureCursor = m.cursor // Store current cursor position
|
||||
m.textinput.SetValue("")
|
||||
m.textinput.Placeholder = "What needs doing?"
|
||||
m.textinput.Focus()
|
||||
|
|
@ -250,6 +323,17 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.SetScheduled):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
m.editingItem = items[m.cursor]
|
||||
m.mode = modeSetScheduled
|
||||
m.textinput.SetValue("")
|
||||
m.textinput.Placeholder = "YYYY-MM-DD or +N (days from today)"
|
||||
m.textinput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.SetPriority):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
|
|
@ -356,13 +440,36 @@ func (m uiModel) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
Notes: []string{},
|
||||
Children: []*model.Item{},
|
||||
}
|
||||
// Insert at beginning
|
||||
m.orgFile.Items = append([]*model.Item{newItem}, m.orgFile.Items...)
|
||||
m.setStatus("TODO captured!")
|
||||
|
||||
// Check if we're in multi-file mode
|
||||
isMultiFile := len(m.orgFile.Items) > 0 && m.orgFile.Items[0].SourceFile != ""
|
||||
|
||||
if isMultiFile {
|
||||
// In multi-file mode, add to the file of the highlighted item (using stored cursor position)
|
||||
items := m.getVisibleItems()
|
||||
targetFileItem := m.findTopLevelFileItem(items, m.captureCursor)
|
||||
|
||||
if targetFileItem != nil {
|
||||
// Set the source file for the new item
|
||||
newItem.SourceFile = targetFileItem.SourceFile
|
||||
newItem.Level = 2 // Children of file items are level 2
|
||||
|
||||
// Insert at the beginning of the file item's children
|
||||
targetFileItem.Children = append([]*model.Item{newItem}, targetFileItem.Children...)
|
||||
targetFileItem.Folded = false // Unfold to show the new item
|
||||
m.setStatus("TODO captured to " + targetFileItem.Title)
|
||||
} else {
|
||||
m.setStatus("Error: Could not find file to add to")
|
||||
}
|
||||
} else {
|
||||
// Single file mode: insert at beginning
|
||||
m.orgFile.Items = append([]*model.Item{newItem}, m.orgFile.Items...)
|
||||
m.setStatus("TODO captured!")
|
||||
}
|
||||
}
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.cursor = 0
|
||||
// Don't reset cursor, keep it where it was
|
||||
return m, nil
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeList
|
||||
|
|
@ -376,6 +483,49 @@ func (m uiModel) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, cmd
|
||||
}
|
||||
|
||||
// findTopLevelFileItem finds the top-level file item that contains the item at the given cursor position
|
||||
func (m *uiModel) findTopLevelFileItem(items []*model.Item, cursorPos int) *model.Item {
|
||||
if cursorPos < 0 || cursorPos >= len(items) {
|
||||
// Fallback to first file if cursor out of bounds
|
||||
if len(m.orgFile.Items) > 0 {
|
||||
return m.orgFile.Items[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
selectedItem := items[cursorPos]
|
||||
|
||||
// Check if we're in multi-file mode
|
||||
isMultiFile := len(m.orgFile.Items) > 0 && m.orgFile.Items[0].SourceFile != ""
|
||||
|
||||
if !isMultiFile {
|
||||
// Not in multi-file mode, return nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the selected item itself is a file item (level 1 with SourceFile), return it
|
||||
if selectedItem.SourceFile != "" && selectedItem.Level == 1 {
|
||||
return selectedItem
|
||||
}
|
||||
|
||||
// Otherwise, find which top-level file item this item belongs to
|
||||
// by checking the SourceFile field
|
||||
if selectedItem.SourceFile != "" {
|
||||
for _, fileItem := range m.orgFile.Items {
|
||||
if fileItem.SourceFile == selectedItem.SourceFile {
|
||||
return fileItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return the first file item
|
||||
if len(m.orgFile.Items) > 0 {
|
||||
return m.orgFile.Items[0]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
|
|
@ -395,11 +545,12 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
// Create new sub-task
|
||||
newItem := &model.Item{
|
||||
Level: m.editingItem.Level + 1,
|
||||
State: defaultState,
|
||||
Title: title,
|
||||
Notes: []string{},
|
||||
Children: []*model.Item{},
|
||||
Level: m.editingItem.Level + 1,
|
||||
State: defaultState,
|
||||
Title: title,
|
||||
Notes: []string{},
|
||||
Children: []*model.Item{},
|
||||
SourceFile: m.editingItem.SourceFile, // Inherit source file from parent
|
||||
}
|
||||
m.editingItem.Children = append(m.editingItem.Children, newItem)
|
||||
m.editingItem.Folded = false // Unfold to show new sub-task
|
||||
|
|
@ -423,83 +574,11 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.textinput.Width = 50
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEnter:
|
||||
input := strings.TrimSpace(m.textinput.Value())
|
||||
if m.editingItem != nil {
|
||||
if input == "" {
|
||||
// Empty input clears the deadline
|
||||
m.editingItem.Deadline = nil
|
||||
// Remove DEADLINE line from notes (only lines starting with DEADLINE:)
|
||||
var filteredNotes []string
|
||||
for _, note := range m.editingItem.Notes {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
if !strings.HasPrefix(trimmedNote, "DEADLINE:") {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
m.editingItem.Notes = filteredNotes
|
||||
m.setStatus("Deadline cleared!")
|
||||
} else {
|
||||
deadline, err := parseDeadlineInput(input)
|
||||
if err != nil {
|
||||
m.setStatus(fmt.Sprintf("Invalid date: %v", err))
|
||||
} else {
|
||||
m.editingItem.Deadline = &deadline
|
||||
// Also update or add DEADLINE line in notes
|
||||
updatedNotes := false
|
||||
for i, note := range m.editingItem.Notes {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
if strings.HasPrefix(trimmedNote, "DEADLINE:") {
|
||||
m.editingItem.Notes[i] = fmt.Sprintf("DEADLINE: <%s>", parser.FormatOrgDate(deadline))
|
||||
updatedNotes = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// If DEADLINE wasn't in notes, it will be added by writeItem
|
||||
if !updatedNotes {
|
||||
// Remove old deadline lines just to be safe
|
||||
var filteredNotes []string
|
||||
for _, note := range m.editingItem.Notes {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
if !strings.HasPrefix(trimmedNote, "DEADLINE:") {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
m.editingItem.Notes = filteredNotes
|
||||
}
|
||||
m.setStatus("Deadline set!")
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
return m, nil
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
m.setStatus("Cancelled")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.textinput, cmd = m.textinput.Update(msg)
|
||||
return m, cmd
|
||||
return m.updateSetDate(msg, "DEADLINE")
|
||||
}
|
||||
|
||||
// parseDeadlineInput parses deadline input like "2024-01-15" or "+3" (3 days from now)
|
||||
func parseDeadlineInput(input string) (time.Time, error) {
|
||||
// parseDateInput parses date input like "2024-01-15" or "+3" (3 days from now)
|
||||
func parseDateInput(input string) (time.Time, error) {
|
||||
// Check if it's a relative date (+N days)
|
||||
if strings.HasPrefix(input, "+") {
|
||||
daysStr := strings.TrimPrefix(input, "+")
|
||||
|
|
@ -527,6 +606,110 @@ func parseDeadlineInput(input string) (time.Time, error) {
|
|||
return time.Time{}, fmt.Errorf("unable to parse date: %s (use YYYY-MM-DD or +N)", input)
|
||||
}
|
||||
|
||||
func (m uiModel) updateSetScheduled(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.updateSetDate(msg, "SCHEDULED")
|
||||
}
|
||||
|
||||
func (m uiModel) updateSetDate(msg tea.Msg, dateType string) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.textinput.Width = 50
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEnter:
|
||||
input := strings.TrimSpace(m.textinput.Value())
|
||||
if m.editingItem != nil {
|
||||
var prefixDate string
|
||||
var clearedDateMsg string
|
||||
var setDateMsg string
|
||||
|
||||
if dateType == "DEADLINE" {
|
||||
prefixDate = "DEADLINE:"
|
||||
clearedDateMsg = "Deadline cleared!"
|
||||
setDateMsg = "Deadline set!"
|
||||
} else {
|
||||
prefixDate = "SCHEDULED:"
|
||||
clearedDateMsg = "Scheduled date cleared!"
|
||||
setDateMsg = "Scheduled date set!"
|
||||
}
|
||||
|
||||
if input == "" {
|
||||
// Empty input clears the date
|
||||
if dateType == "DEADLINE" {
|
||||
m.editingItem.Deadline = nil
|
||||
} else {
|
||||
m.editingItem.Scheduled = nil
|
||||
}
|
||||
|
||||
// Remove property line from notes
|
||||
var filteredNotes []string
|
||||
for _, note := range m.editingItem.Notes {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
if !strings.HasPrefix(trimmedNote, prefixDate) {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
m.editingItem.Notes = filteredNotes
|
||||
m.setStatus(clearedDateMsg)
|
||||
} else {
|
||||
dateVal, err := parseDateInput(input)
|
||||
if err != nil {
|
||||
m.setStatus(fmt.Sprintf("Invalid date: %v", err))
|
||||
} else {
|
||||
if dateType == "DEADLINE" {
|
||||
m.editingItem.Deadline = &dateVal
|
||||
} else {
|
||||
m.editingItem.Scheduled = &dateVal
|
||||
}
|
||||
|
||||
// Also update or add property line in notes
|
||||
updatedNotes := false
|
||||
for i, note := range m.editingItem.Notes {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
if strings.HasPrefix(trimmedNote, prefixDate) {
|
||||
m.editingItem.Notes[i] = fmt.Sprintf("%s <%s>", prefixDate, parser.FormatOrgDate(dateVal))
|
||||
updatedNotes = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// If property wasn't in notes, it will be added by writeItem
|
||||
if !updatedNotes {
|
||||
// Remove old property lines just to be safe
|
||||
var filteredNotes []string
|
||||
for _, note := range m.editingItem.Notes {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
if !strings.HasPrefix(trimmedNote, prefixDate) {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
m.editingItem.Notes = filteredNotes
|
||||
}
|
||||
m.setStatus(setDateMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
return m, nil
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
m.setStatus("Cancelled")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.textinput, cmd = m.textinput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m uiModel) updateSetPriority(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
|
|
@ -628,6 +811,7 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
|
|||
// Find current state index
|
||||
currentIndex := -1
|
||||
currentState := string(item.State)
|
||||
lastStateIndex := len(stateNames) - 1
|
||||
|
||||
// Handle empty state
|
||||
if currentState == "" {
|
||||
|
|
@ -641,15 +825,51 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
|
|||
}
|
||||
}
|
||||
|
||||
// Store the old state to check if we're transitioning to/from DONE
|
||||
oldState := currentState
|
||||
var newState string
|
||||
|
||||
// Cycle forward
|
||||
if currentIndex < 0 || currentIndex >= len(stateNames)-1 {
|
||||
if currentIndex == len(stateNames)-1 {
|
||||
item.State = model.TodoState("") // Back to empty
|
||||
newState = "" // Back to empty
|
||||
} else {
|
||||
item.State = model.TodoState(stateNames[0]) // First state
|
||||
newState = stateNames[0] // First state
|
||||
}
|
||||
} else {
|
||||
item.State = model.TodoState(stateNames[currentIndex+1])
|
||||
newState = stateNames[currentIndex+1]
|
||||
}
|
||||
|
||||
// Update the item state
|
||||
item.State = model.TodoState(newState)
|
||||
|
||||
// Manage CLOSED timestamp
|
||||
wasInDoneState := (oldState == stateNames[lastStateIndex])
|
||||
isInDoneState := (newState == stateNames[lastStateIndex])
|
||||
|
||||
if isInDoneState && !wasInDoneState {
|
||||
// Moving TO done state - add CLOSED timestamp
|
||||
now := time.Now()
|
||||
item.Closed = &now
|
||||
// Remove any existing CLOSED line from notes
|
||||
var filteredNotes []string
|
||||
for _, note := range item.Notes {
|
||||
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
item.Notes = filteredNotes
|
||||
} else if wasInDoneState && !isInDoneState {
|
||||
// Moving FROM done state - remove CLOSED timestamp
|
||||
item.Closed = nil
|
||||
// Remove any existing CLOSED line from notes
|
||||
var filteredNotes []string
|
||||
for _, note := range item.Notes {
|
||||
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
item.Notes = filteredNotes
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -662,6 +882,7 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
|
|||
// Find current state index
|
||||
currentIndex := -1
|
||||
currentState := string(item.State)
|
||||
lastStateIndex := len(stateNames) - 1
|
||||
|
||||
// Handle empty state
|
||||
if currentState == "" {
|
||||
|
|
@ -675,13 +896,49 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
|
|||
}
|
||||
}
|
||||
|
||||
// Store the old state to check if we're transitioning to/from DONE
|
||||
oldState := currentState
|
||||
var newState string
|
||||
|
||||
// Cycle backward
|
||||
if currentIndex <= 0 {
|
||||
item.State = model.TodoState("") // Empty state
|
||||
newState = "" // Empty state
|
||||
} else if currentIndex > len(stateNames) {
|
||||
item.State = model.TodoState(stateNames[len(stateNames)-1])
|
||||
newState = stateNames[len(stateNames)-1]
|
||||
} else {
|
||||
item.State = model.TodoState(stateNames[currentIndex-1])
|
||||
newState = stateNames[currentIndex-1]
|
||||
}
|
||||
|
||||
// Update the item state
|
||||
item.State = model.TodoState(newState)
|
||||
|
||||
// Manage CLOSED timestamp
|
||||
wasInDoneState := (oldState == stateNames[lastStateIndex])
|
||||
isInDoneState := (newState == stateNames[lastStateIndex])
|
||||
|
||||
if isInDoneState && !wasInDoneState {
|
||||
// Moving TO done state - add CLOSED timestamp
|
||||
now := time.Now()
|
||||
item.Closed = &now
|
||||
// Remove any existing CLOSED line from notes
|
||||
var filteredNotes []string
|
||||
for _, note := range item.Notes {
|
||||
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
item.Notes = filteredNotes
|
||||
} else if wasInDoneState && !isInDoneState {
|
||||
// Moving FROM done state - remove CLOSED timestamp
|
||||
item.Closed = nil
|
||||
// Remove any existing CLOSED line from notes
|
||||
var filteredNotes []string
|
||||
for _, note := range item.Notes {
|
||||
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
item.Notes = filteredNotes
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -820,6 +1077,151 @@ func (m *uiModel) swapItems(item1, item2 *model.Item) {
|
|||
swapInList(m.orgFile.Items)
|
||||
}
|
||||
|
||||
func (m *uiModel) promoteItem() {
|
||||
items := m.getVisibleItems()
|
||||
if len(items) == 0 || m.cursor >= len(items) {
|
||||
return
|
||||
}
|
||||
|
||||
currentItem := items[m.cursor]
|
||||
|
||||
// Can't promote a top-level item
|
||||
if currentItem.Level <= 1 {
|
||||
m.setStatus("Cannot promote - already at top level")
|
||||
return
|
||||
}
|
||||
|
||||
// Find the parent of this item
|
||||
parent := m.findParent(currentItem)
|
||||
if parent == nil {
|
||||
m.setStatus("Cannot promote - no parent found")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove item from parent's children
|
||||
for i, child := range parent.Children {
|
||||
if child == currentItem {
|
||||
parent.Children = append(parent.Children[:i], parent.Children[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Find grandparent to insert this item after the parent
|
||||
grandparent := m.findParent(parent)
|
||||
if grandparent != nil {
|
||||
// Insert after parent in grandparent's children
|
||||
for i, child := range grandparent.Children {
|
||||
if child == parent {
|
||||
// Decrease level and update all descendants
|
||||
m.adjustItemLevels(currentItem, -1)
|
||||
grandparent.Children = append(grandparent.Children[:i+1], append([]*model.Item{currentItem}, grandparent.Children[i+1:]...)...)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Parent is at top level, insert after parent in m.orgFile.Items
|
||||
for i, item := range m.orgFile.Items {
|
||||
if item == parent {
|
||||
// Decrease level and update all descendants
|
||||
m.adjustItemLevels(currentItem, -1)
|
||||
m.orgFile.Items = append(m.orgFile.Items[:i+1], append([]*model.Item{currentItem}, m.orgFile.Items[i+1:]...)...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.setStatus("Item promoted")
|
||||
|
||||
// Update cursor to follow the item
|
||||
items = m.getVisibleItems()
|
||||
for i, item := range items {
|
||||
if item == currentItem {
|
||||
m.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *uiModel) demoteItem() {
|
||||
items := m.getVisibleItems()
|
||||
if len(items) == 0 || m.cursor >= len(items) {
|
||||
return
|
||||
}
|
||||
|
||||
currentItem := items[m.cursor]
|
||||
|
||||
// Find the previous sibling to make this item its child
|
||||
prevSibling := m.findPreviousSibling(currentItem)
|
||||
if prevSibling == nil {
|
||||
m.setStatus("Cannot demote - no previous sibling")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove item from its current parent's children
|
||||
parent := m.findParent(currentItem)
|
||||
if parent != nil {
|
||||
for i, child := range parent.Children {
|
||||
if child == currentItem {
|
||||
parent.Children = append(parent.Children[:i], parent.Children[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Item is at top level
|
||||
for i, item := range m.orgFile.Items {
|
||||
if item == currentItem {
|
||||
m.orgFile.Items = append(m.orgFile.Items[:i], m.orgFile.Items[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increase level and update all descendants
|
||||
m.adjustItemLevels(currentItem, 1)
|
||||
|
||||
// Add as child of previous sibling
|
||||
prevSibling.Children = append(prevSibling.Children, currentItem)
|
||||
prevSibling.Folded = false // Unfold to show the demoted item
|
||||
|
||||
m.setStatus("Item demoted")
|
||||
|
||||
// Update cursor to follow the item
|
||||
items = m.getVisibleItems()
|
||||
for i, item := range items {
|
||||
if item == currentItem {
|
||||
m.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *uiModel) findParent(target *model.Item) *model.Item {
|
||||
var findInList func([]*model.Item) *model.Item
|
||||
findInList = func(items []*model.Item) *model.Item {
|
||||
for _, item := range items {
|
||||
// Check if target is a direct child
|
||||
for _, child := range item.Children {
|
||||
if child == target {
|
||||
return item
|
||||
}
|
||||
}
|
||||
// Recursively check children
|
||||
if result := findInList(item.Children); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return findInList(m.orgFile.Items)
|
||||
}
|
||||
|
||||
func (m *uiModel) adjustItemLevels(item *model.Item, delta int) {
|
||||
item.Level += delta
|
||||
for _, child := range item.Children {
|
||||
m.adjustItemLevels(child, delta)
|
||||
}
|
||||
}
|
||||
|
||||
func (m uiModel) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
|
|
@ -900,3 +1302,45 @@ func (m *uiModel) updateTagEdit(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateRename handles item rename mode
|
||||
func (m *uiModel) updateRename(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()
|
||||
m.editingItem = nil
|
||||
return m, nil
|
||||
|
||||
case msg.Type == tea.KeyEnter:
|
||||
if m.editingItem != nil {
|
||||
newTitle := strings.TrimSpace(m.textinput.Value())
|
||||
if newTitle != "" {
|
||||
m.editingItem.Title = newTitle
|
||||
m.setStatus("Item renamed")
|
||||
} else {
|
||||
m.setStatus("Cannot rename to empty title")
|
||||
}
|
||||
}
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
return m, nil
|
||||
|
||||
case msg.Type == tea.KeyEsc:
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
m.setStatus("Cancelled")
|
||||
return m, nil
|
||||
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.textinput, cmd = m.textinput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import (
|
|||
type settingsSection int
|
||||
|
||||
const (
|
||||
settingsSectionTags settingsSection = iota
|
||||
settingsSectionGeneral settingsSection = iota
|
||||
settingsSectionTags
|
||||
settingsSectionStates
|
||||
settingsSectionKeybindings
|
||||
)
|
||||
|
|
@ -76,7 +77,7 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
case key.Matches(msg, m.keys.Left):
|
||||
// Previous section
|
||||
if m.settingsSection > settingsSectionTags {
|
||||
if m.settingsSection > settingsSectionGeneral {
|
||||
m.settingsSection--
|
||||
m.settingsCursor = 0
|
||||
m.settingsScroll = 0
|
||||
|
|
@ -101,6 +102,8 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case key.Matches(msg, m.keys.Capture):
|
||||
// Add new tag or state
|
||||
switch m.settingsSection {
|
||||
case settingsSectionGeneral:
|
||||
// No capture action in General
|
||||
case settingsSectionTags:
|
||||
m.addNewTag()
|
||||
case settingsSectionStates:
|
||||
|
|
@ -128,6 +131,8 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
// getSettingsItemCount returns the number of items in the current settings view
|
||||
func (m *uiModel) getSettingsItemCount() int {
|
||||
switch m.settingsSection {
|
||||
case settingsSectionGeneral:
|
||||
return 3 // Org syntax highlighting toggle, show indentation guides toggle, indentation guide color
|
||||
case settingsSectionTags:
|
||||
return len(m.config.Tags.Tags) + 1 // +1 for "Add new tag" option
|
||||
case settingsSectionStates:
|
||||
|
|
@ -172,6 +177,40 @@ func (m *uiModel) updateSettingsScrollOffset() {
|
|||
// startSettingsEdit starts editing a settings item
|
||||
func (m *uiModel) startSettingsEdit() {
|
||||
switch m.settingsSection {
|
||||
case settingsSectionGeneral:
|
||||
// Setting 0: Toggle org syntax highlighting
|
||||
if m.settingsCursor == 0 {
|
||||
m.config.UI.OrgSyntaxHighlighting = !m.config.UI.OrgSyntaxHighlighting
|
||||
if m.config.UI.OrgSyntaxHighlighting {
|
||||
m.setStatus("Org syntax highlighting enabled (saved)")
|
||||
} else {
|
||||
m.setStatus("Org syntax highlighting disabled (saved)")
|
||||
}
|
||||
// Auto-save
|
||||
if err := m.config.Save(); err != nil {
|
||||
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
|
||||
}
|
||||
}
|
||||
// Setting 1: Toggle show indentation guides
|
||||
if m.settingsCursor == 1 {
|
||||
m.config.UI.ShowIndentationGuides = !m.config.UI.ShowIndentationGuides
|
||||
if m.config.UI.ShowIndentationGuides {
|
||||
m.setStatus("Indentation guides enabled (saved)")
|
||||
} else {
|
||||
m.setStatus("Indentation guides disabled (saved)")
|
||||
}
|
||||
// Auto-save
|
||||
if err := m.config.Save(); err != nil {
|
||||
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
|
||||
}
|
||||
}
|
||||
// Setting 2: Edit indentation guide color
|
||||
if m.settingsCursor == 2 {
|
||||
m.textinput.SetValue(m.config.UI.IndentationGuideColor)
|
||||
m.textinput.Placeholder = "Enter color (e.g., 245, 99)"
|
||||
m.textinput.Focus()
|
||||
}
|
||||
return
|
||||
case settingsSectionTags:
|
||||
if m.settingsCursor >= len(m.config.Tags.Tags) {
|
||||
return
|
||||
|
|
@ -237,6 +276,23 @@ func (m *uiModel) startSettingsEdit() {
|
|||
// saveSettingsEdit saves the edited value and auto-saves to disk
|
||||
func (m *uiModel) saveSettingsEdit() {
|
||||
switch m.settingsSection {
|
||||
case settingsSectionGeneral:
|
||||
// Setting 2: Indentation guide color
|
||||
if m.settingsCursor == 2 {
|
||||
newColor := strings.TrimSpace(m.textinput.Value())
|
||||
if newColor != "" {
|
||||
m.config.UI.IndentationGuideColor = newColor
|
||||
m.setStatus(fmt.Sprintf("Indentation guide color set to '%s' (saved)", newColor))
|
||||
// Auto-save
|
||||
if err := m.config.Save(); err != nil {
|
||||
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
|
||||
} else {
|
||||
// Reload styles
|
||||
m.styles = newStyleMapFromConfig(m.config)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
case settingsSectionTags:
|
||||
if m.settingsCursor >= len(m.config.Tags.Tags) {
|
||||
return
|
||||
|
|
@ -350,6 +406,9 @@ func (m *uiModel) saveSettingsEdit() {
|
|||
// deleteSettingsItem deletes the current settings item and auto-saves
|
||||
func (m *uiModel) deleteSettingsItem() {
|
||||
switch m.settingsSection {
|
||||
case settingsSectionGeneral:
|
||||
// Cannot delete general settings
|
||||
return
|
||||
case settingsSectionTags:
|
||||
if m.settingsCursor >= len(m.config.Tags.Tags) {
|
||||
return
|
||||
|
|
@ -437,6 +496,12 @@ func (m *uiModel) viewSettings() string {
|
|||
activeTabStyle := lipgloss.NewStyle().Padding(0, 2).Bold(true).Foreground(lipgloss.Color(m.config.Colors.Title))
|
||||
|
||||
tabs := ""
|
||||
if m.settingsSection == settingsSectionGeneral {
|
||||
tabs += activeTabStyle.Render("[General]")
|
||||
} else {
|
||||
tabs += tabStyle.Render("General")
|
||||
}
|
||||
tabs += " "
|
||||
if m.settingsSection == settingsSectionTags {
|
||||
tabs += activeTabStyle.Render("[Tags]")
|
||||
} else {
|
||||
|
|
@ -459,6 +524,8 @@ func (m *uiModel) viewSettings() string {
|
|||
// Instructions
|
||||
var instructions string
|
||||
switch m.settingsSection {
|
||||
case settingsSectionGeneral:
|
||||
instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Toggle setting\nctrl+s: Save • q/,: Exit"
|
||||
case settingsSectionTags:
|
||||
instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Edit • D: Delete\nc: Add new tag • ctrl+s: Save • q/,: Exit"
|
||||
case settingsSectionStates:
|
||||
|
|
@ -470,6 +537,8 @@ func (m *uiModel) viewSettings() string {
|
|||
|
||||
// Render the appropriate section
|
||||
switch m.settingsSection {
|
||||
case settingsSectionGeneral:
|
||||
content.WriteString(m.viewSettingsGeneral())
|
||||
case settingsSectionTags:
|
||||
content.WriteString(m.viewSettingsTags())
|
||||
case settingsSectionStates:
|
||||
|
|
@ -488,6 +557,68 @@ func (m *uiModel) viewSettings() string {
|
|||
return content.String()
|
||||
}
|
||||
|
||||
// viewSettingsGeneral renders the general settings section
|
||||
func (m *uiModel) viewSettingsGeneral() string {
|
||||
var content strings.Builder
|
||||
|
||||
// Calculate visible window
|
||||
reservedLines := 10
|
||||
if m.textinput.Focused() {
|
||||
reservedLines += 3
|
||||
}
|
||||
availableHeight := m.height - reservedLines
|
||||
if availableHeight < 3 {
|
||||
availableHeight = 3
|
||||
}
|
||||
|
||||
enabledStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Bold(true)
|
||||
disabledStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||
colorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
|
||||
|
||||
// Setting 0: Org syntax highlighting toggle
|
||||
line := ""
|
||||
if m.settingsCursor == 0 && !m.textinput.Focused() {
|
||||
line += "▶ "
|
||||
} else {
|
||||
line += " "
|
||||
}
|
||||
line += "Org syntax highlighting: "
|
||||
if m.config.UI.OrgSyntaxHighlighting {
|
||||
line += enabledStyle.Render("Enabled")
|
||||
} else {
|
||||
line += disabledStyle.Render("Disabled")
|
||||
}
|
||||
content.WriteString(line + "\n")
|
||||
|
||||
// Setting 1: Show indentation guides toggle
|
||||
line = ""
|
||||
if m.settingsCursor == 1 && !m.textinput.Focused() {
|
||||
line += "▶ "
|
||||
} else {
|
||||
line += " "
|
||||
}
|
||||
line += "Show indentation guides: "
|
||||
if m.config.UI.ShowIndentationGuides {
|
||||
line += enabledStyle.Render("Enabled")
|
||||
} else {
|
||||
line += disabledStyle.Render("Disabled")
|
||||
}
|
||||
content.WriteString(line + "\n")
|
||||
|
||||
// Setting 2: Indentation guide color
|
||||
line = ""
|
||||
if m.settingsCursor == 2 && !m.textinput.Focused() {
|
||||
line += "▶ "
|
||||
} else {
|
||||
line += " "
|
||||
}
|
||||
line += "Indentation guide color: "
|
||||
line += colorStyle.Render(m.config.UI.IndentationGuideColor)
|
||||
content.WriteString(line + "\n")
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
// viewSettingsTags renders the tags section
|
||||
func (m *uiModel) viewSettingsTags() string {
|
||||
var content strings.Builder
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ func (m uiModel) View() string {
|
|||
return m.viewAddSubTask()
|
||||
case modeSetDeadline:
|
||||
return m.viewSetDeadline()
|
||||
case modeSetScheduled:
|
||||
return m.viewSetScheduled()
|
||||
case modeSetPriority:
|
||||
return m.viewSetPriority()
|
||||
case modeSetEffort:
|
||||
|
|
@ -92,6 +94,8 @@ func (m uiModel) View() string {
|
|||
return m.viewSettingsAddState()
|
||||
case modeTagEdit:
|
||||
return m.viewTagEdit()
|
||||
case modeRename:
|
||||
return m.viewRename()
|
||||
}
|
||||
|
||||
// Build footer (status + help)
|
||||
|
|
@ -146,8 +150,24 @@ func (m uiModel) View() string {
|
|||
for i, item := range items {
|
||||
lineCount := 1 // The item itself
|
||||
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
|
||||
// Build indentation for notes
|
||||
var notePrefix strings.Builder
|
||||
if m.config.UI.ShowIndentationGuides {
|
||||
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
|
||||
for j := 1; j <= item.Level; j++ {
|
||||
notePrefix.WriteString(guideStyle.Render("· "))
|
||||
}
|
||||
} else {
|
||||
// No visual guides, just use spaces
|
||||
for j := 1; j <= item.Level; j++ {
|
||||
notePrefix.WriteString(" ")
|
||||
}
|
||||
}
|
||||
indent := notePrefix.String()
|
||||
noteIndent := indent + " "
|
||||
filteredNotes := filterLogbookDrawer(item.Notes)
|
||||
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
|
||||
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
|
||||
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
|
||||
lineCount += len(highlightedNotes)
|
||||
}
|
||||
itemLineCount[i] = lineCount
|
||||
|
|
@ -204,9 +224,24 @@ func (m uiModel) View() string {
|
|||
|
||||
// Render remaining notes
|
||||
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
|
||||
indent := strings.Repeat(" ", item.Level)
|
||||
// Build indentation for notes
|
||||
var notePrefix strings.Builder
|
||||
if m.config.UI.ShowIndentationGuides {
|
||||
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
|
||||
for i := 1; i <= item.Level; i++ {
|
||||
notePrefix.WriteString(guideStyle.Render("· "))
|
||||
}
|
||||
} else {
|
||||
// No visual guides, just use spaces
|
||||
for i := 1; i <= item.Level; i++ {
|
||||
notePrefix.WriteString(" ")
|
||||
}
|
||||
}
|
||||
indent := notePrefix.String()
|
||||
noteIndent := indent + " "
|
||||
filteredNotes := filterLogbookDrawer(item.Notes)
|
||||
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
|
||||
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
|
||||
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
|
||||
for noteIdx := linesToSkip - 1; noteIdx < len(highlightedNotes) && itemLines < availableHeight; noteIdx++ {
|
||||
content.WriteString(indent)
|
||||
content.WriteString(" " + highlightedNotes[noteIdx])
|
||||
|
|
@ -226,9 +261,24 @@ func (m uiModel) View() string {
|
|||
|
||||
// Show notes if not folded
|
||||
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
|
||||
indent := strings.Repeat(" ", item.Level)
|
||||
// Build indentation for notes
|
||||
var notePrefix strings.Builder
|
||||
if m.config.UI.ShowIndentationGuides {
|
||||
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
|
||||
for i := 1; i <= item.Level; i++ {
|
||||
notePrefix.WriteString(guideStyle.Render("· "))
|
||||
}
|
||||
} else {
|
||||
// No visual guides, just use spaces
|
||||
for i := 1; i <= item.Level; i++ {
|
||||
notePrefix.WriteString(" ")
|
||||
}
|
||||
}
|
||||
indent := notePrefix.String()
|
||||
noteIndent := indent + " "
|
||||
filteredNotes := filterLogbookDrawer(item.Notes)
|
||||
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
|
||||
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
|
||||
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
|
||||
for _, note := range highlightedNotes {
|
||||
if itemLines >= availableHeight {
|
||||
break
|
||||
|
|
@ -331,6 +381,14 @@ func (m uiModel) viewAddSubTask() string {
|
|||
}
|
||||
|
||||
func (m uiModel) viewSetDeadline() string {
|
||||
return m.viewSetDate("Set Deadline", "Leave empty to clear deadline")
|
||||
}
|
||||
|
||||
func (m uiModel) viewSetScheduled() string {
|
||||
return m.viewSetDate("Set Scheduled Date", "Leave empty to clear scheduled date")
|
||||
}
|
||||
|
||||
func (m uiModel) viewSetDate(title, helpMsg string) string {
|
||||
dialogStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("141")).
|
||||
|
|
@ -338,7 +396,7 @@ func (m uiModel) viewSetDeadline() string {
|
|||
Width(60)
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(m.styles.titleStyle.Render("Set Deadline"))
|
||||
content.WriteString(m.styles.titleStyle.Render(title))
|
||||
content.WriteString("\n")
|
||||
if m.editingItem != nil {
|
||||
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
|
||||
|
|
@ -348,7 +406,7 @@ func (m uiModel) viewSetDeadline() string {
|
|||
content.WriteString("\n\n")
|
||||
content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)"))
|
||||
content.WriteString("\n")
|
||||
content.WriteString(m.styles.statusStyle.Render("Leave empty to clear deadline"))
|
||||
content.WriteString(m.styles.statusStyle.Render(helpMsg))
|
||||
content.WriteString("\n")
|
||||
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
|
||||
|
||||
|
|
@ -440,7 +498,7 @@ func (m uiModel) viewHelp() string {
|
|||
navigationBindings := []key.Binding{m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right}
|
||||
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}
|
||||
timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetScheduled, m.keys.SetEffort}
|
||||
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}
|
||||
|
||||
|
|
@ -599,8 +657,8 @@ func filterLogbookDrawer(notes []string) []string {
|
|||
continue
|
||||
}
|
||||
|
||||
// Skip SCHEDULED and DEADLINE lines
|
||||
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") {
|
||||
// Skip SCHEDULED, DEADLINE, and CLOSED lines
|
||||
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") || strings.HasPrefix(trimmed, "CLOSED:") {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -610,8 +668,31 @@ func filterLogbookDrawer(notes []string) []string {
|
|||
return filtered
|
||||
}
|
||||
|
||||
// wrapNoteLines wraps note lines to fit within the specified width
|
||||
func wrapNoteLines(notes []string, width int, indent string) []string {
|
||||
var wrapped []string
|
||||
for _, note := range notes {
|
||||
// Don't wrap code block delimiters or drawer markers
|
||||
trimmed := strings.TrimSpace(note)
|
||||
if strings.HasPrefix(trimmed, "#+BEGIN_SRC") ||
|
||||
strings.HasPrefix(trimmed, "#+END_SRC") ||
|
||||
strings.HasPrefix(trimmed, "```") ||
|
||||
trimmed == ":LOGBOOK:" ||
|
||||
trimmed == ":PROPERTIES:" ||
|
||||
trimmed == ":END:" {
|
||||
wrapped = append(wrapped, note)
|
||||
continue
|
||||
}
|
||||
|
||||
// Wrap the note line
|
||||
wrappedLines := wrapText(note, width, indent)
|
||||
wrapped = append(wrapped, wrappedLines...)
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// renderNotesWithHighlighting renders notes with syntax highlighting for code blocks
|
||||
func renderNotesWithHighlighting(notes []string) []string {
|
||||
func (m uiModel) renderNotesWithHighlighting(notes []string) []string {
|
||||
if len(notes) == 0 {
|
||||
return notes
|
||||
}
|
||||
|
|
@ -660,11 +741,20 @@ func renderNotesWithHighlighting(notes []string) []string {
|
|||
} else if codeBlockDelimiter == "markdown" {
|
||||
// Ending a markdown code block
|
||||
inCodeBlock = false
|
||||
// Highlight and add the code
|
||||
// Highlight and add the code (or render LaTeX)
|
||||
if len(codeLines) > 0 {
|
||||
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
|
||||
highlightedLines := strings.Split(highlighted, "\n")
|
||||
result = append(result, highlightedLines...)
|
||||
var processedLines []string
|
||||
if codeLanguage == "latex" {
|
||||
// Apply LaTeX-to-Unicode conversion
|
||||
for _, line := range codeLines {
|
||||
processedLines = append(processedLines, renderLatexMath(line))
|
||||
}
|
||||
} else {
|
||||
// Apply syntax highlighting
|
||||
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
|
||||
processedLines = strings.Split(highlighted, "\n")
|
||||
}
|
||||
result = append(result, processedLines...)
|
||||
}
|
||||
result = append(result, note) // Keep the delimiter visible
|
||||
codeLines = []string{}
|
||||
|
|
@ -677,11 +767,20 @@ func renderNotesWithHighlighting(notes []string) []string {
|
|||
// Check for org-mode style code block end
|
||||
if strings.HasPrefix(trimmed, "#+END_SRC") {
|
||||
inCodeBlock = false
|
||||
// Highlight and add the code
|
||||
// Highlight and add the code (or render LaTeX)
|
||||
if len(codeLines) > 0 {
|
||||
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
|
||||
highlightedLines := strings.Split(highlighted, "\n")
|
||||
result = append(result, highlightedLines...)
|
||||
var processedLines []string
|
||||
if codeLanguage == "latex" {
|
||||
// Apply LaTeX-to-Unicode conversion
|
||||
for _, line := range codeLines {
|
||||
processedLines = append(processedLines, renderLatexMath(line))
|
||||
}
|
||||
} else {
|
||||
// Apply syntax highlighting
|
||||
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
|
||||
processedLines = strings.Split(highlighted, "\n")
|
||||
}
|
||||
result = append(result, processedLines...)
|
||||
}
|
||||
result = append(result, note) // Keep the delimiter visible
|
||||
codeLines = []string{}
|
||||
|
|
@ -694,15 +793,30 @@ func renderNotesWithHighlighting(notes []string) []string {
|
|||
if inCodeBlock {
|
||||
codeLines = append(codeLines, note)
|
||||
} else {
|
||||
result = append(result, note)
|
||||
// Apply org-mode syntax highlighting to non-code text if enabled
|
||||
if m.config.UI.OrgSyntaxHighlighting {
|
||||
highlighted := highlightCode(note, "org")
|
||||
result = append(result, highlighted)
|
||||
} else {
|
||||
result = append(result, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case where code block wasn't closed
|
||||
if inCodeBlock && len(codeLines) > 0 {
|
||||
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
|
||||
highlightedLines := strings.Split(highlighted, "\n")
|
||||
result = append(result, highlightedLines...)
|
||||
var processedLines []string
|
||||
if codeLanguage == "latex" {
|
||||
// Apply LaTeX-to-Unicode conversion
|
||||
for _, line := range codeLines {
|
||||
processedLines = append(processedLines, renderLatexMath(line))
|
||||
}
|
||||
} else {
|
||||
// Apply syntax highlighting
|
||||
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
|
||||
processedLines = strings.Split(highlighted, "\n")
|
||||
}
|
||||
result = append(result, processedLines...)
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
@ -724,12 +838,116 @@ func highlightCode(code, language string) string {
|
|||
return strings.TrimRight(buf.String(), "\n")
|
||||
}
|
||||
|
||||
// wrapText wraps text to fit within the specified width, accounting for indent
|
||||
func wrapText(text string, width int, indent string) []string {
|
||||
if width <= 0 {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
// Calculate available width after indent
|
||||
indentWidth := lipgloss.Width(indent)
|
||||
availableWidth := width - indentWidth
|
||||
if availableWidth <= 10 {
|
||||
// If very little space, just return the original text
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
var result []string
|
||||
var currentLine strings.Builder
|
||||
currentWidth := 0
|
||||
|
||||
// Split by whitespace while preserving leading/trailing spaces
|
||||
words := strings.Fields(text)
|
||||
if len(words) == 0 {
|
||||
// Preserve empty lines
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
for i, word := range words {
|
||||
wordWidth := lipgloss.Width(word)
|
||||
|
||||
// If this is the first word on the line
|
||||
if currentWidth == 0 {
|
||||
// Handle words longer than available width
|
||||
if wordWidth > availableWidth {
|
||||
// Split the word across multiple lines
|
||||
for len(word) > 0 {
|
||||
if availableWidth <= 0 {
|
||||
availableWidth = 10 // Fallback
|
||||
}
|
||||
chunkSize := availableWidth
|
||||
if chunkSize > len(word) {
|
||||
chunkSize = len(word)
|
||||
}
|
||||
result = append(result, word[:chunkSize])
|
||||
word = word[chunkSize:]
|
||||
}
|
||||
continue
|
||||
}
|
||||
currentLine.WriteString(word)
|
||||
currentWidth = wordWidth
|
||||
} else {
|
||||
// Check if adding this word (plus a space) would exceed the width
|
||||
spaceAndWordWidth := currentWidth + 1 + wordWidth
|
||||
if spaceAndWordWidth > availableWidth {
|
||||
// Start a new line
|
||||
result = append(result, currentLine.String())
|
||||
currentLine.Reset()
|
||||
|
||||
// Handle words longer than available width
|
||||
if wordWidth > availableWidth {
|
||||
for len(word) > 0 {
|
||||
chunkSize := availableWidth
|
||||
if chunkSize > len(word) {
|
||||
chunkSize = len(word)
|
||||
}
|
||||
result = append(result, word[:chunkSize])
|
||||
word = word[chunkSize:]
|
||||
}
|
||||
currentWidth = 0
|
||||
continue
|
||||
}
|
||||
|
||||
currentLine.WriteString(word)
|
||||
currentWidth = wordWidth
|
||||
} else {
|
||||
// Add word to current line
|
||||
currentLine.WriteString(" ")
|
||||
currentLine.WriteString(word)
|
||||
currentWidth = spaceAndWordWidth
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the last word, add the line
|
||||
if i == len(words)-1 && currentLine.Len() > 0 {
|
||||
result = append(result, currentLine.String())
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Indentation for level
|
||||
indent := strings.Repeat(" ", item.Level-1)
|
||||
b.WriteString(indent)
|
||||
// Indentation with subtle visual nesting guides
|
||||
if m.config.UI.ShowIndentationGuides {
|
||||
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
|
||||
for i := 1; i < item.Level; i++ {
|
||||
if i == item.Level-1 {
|
||||
// Last level before the item - use subtle dot connector
|
||||
b.WriteString(guideStyle.Render("· "))
|
||||
} else {
|
||||
// Parent levels - use subtle dot
|
||||
b.WriteString(guideStyle.Render("· "))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No visual guides, just use spaces for indentation
|
||||
for i := 1; i < item.Level; i++ {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
// Fold indicator
|
||||
if len(item.Children) > 0 || len(item.Notes) > 0 {
|
||||
|
|
@ -859,3 +1077,21 @@ func (m uiModel) viewTagEdit() string {
|
|||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
// viewRename renders the rename item view
|
||||
func (m uiModel) viewRename() string {
|
||||
var content strings.Builder
|
||||
|
||||
content.WriteString(m.styles.titleStyle.Render("Rename Item") + "\n\n")
|
||||
|
||||
if m.editingItem != nil {
|
||||
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: %s", m.editingItem.Title)) + "\n\n")
|
||||
}
|
||||
|
||||
content.WriteString(m.textinput.View() + "\n\n")
|
||||
|
||||
content.WriteString(m.styles.statusStyle.Render("Enter new title for the item") + "\n\n")
|
||||
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel") + "\n")
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue