Compare commits

...

25 commits

Author SHA1 Message Date
Vitaliy Sh
7bc00d6891
feat: ToggleFoldAll (#18) 2026-02-17 09:50:37 +00:00
Vitaliy Sh
fce607e29d
feat: SetScheduled (#17) 2026-02-10 13:29:23 +00:00
c858e70d07
chore: update readme for -c --capture (#16) 2025-11-17 21:44:37 +00:00
0b88465e21
feat: Add capture flag -c/--capture with pipe support (#15) 2025-11-17 21:40:06 +00:00
8ff2b254a4
chore: update list_view screenshot to show indentation guides (#13) 2025-11-12 21:17:44 +00:00
8ed20e48ff
fix: org syntax highlighting outside of codeblocks and more settings (#12) 2025-11-12 20:34:09 +00:00
6b404cd722
fix: quality of life, renaming and better shuffling of items (#11)
* fix: renaming and pro- and de-motion of items

* adding guides for indentation

* updating readme
2025-11-10 21:48:28 +00:00
2e9980e73c
feat: adding multi-file mode to allow opening all org files in a directory (#10) 2025-11-10 18:50:01 +00:00
5eb672e0c9
chore: pull request template and call for contributions (#9) 2025-11-10 18:47:55 +00:00
5a6fede2d8
fix: word-wrapping on notes when terminal is small (#8) 2025-11-09 18:28:55 +00:00
aaa0ad0f55
fix: fixing pipeline (#7)
* fix: fixing pipeline
2025-11-08 23:31:43 +00:00
23c7095477
fix: latex math support (#6)
* fix: latex math support

* move latex junk to another file
2025-11-08 23:22:37 +00:00
015feb3637
chore: update readme with settings screenshots (#5) 2025-11-08 22:53:47 +00:00
2f98d8e0f1
fix: custom states not parsed correctly has been fixed (#4) 2025-11-08 22:21:00 +00:00
eb5f9a16ce
fix: allow scrolling in settings for shorter terminals (#3) 2025-11-08 20:41:06 +00:00
d45d8fd5c1
fix: enabling building for BSD people (#2) 2025-11-08 20:19:24 +00:00
6e96d56e77
chore: add chore commits (#1) 2025-11-08 20:07:13 +00:00
264f0aa54a fix: github release pipeline 2025-11-08 19:53:00 +00:00
3819ce0bce fix bug not allowing Q to be typed in settings 2025-11-08 19:01:42 +00:00
3c7b64417b adding setting for default state of new items 2025-11-08 18:36:21 +00:00
43573a6e79 Adding settings view and remappable keybindings and custom states support and tags 2025-11-08 17:33:36 +00:00
8f6ec4a79f readme and keybindings improvements 2025-11-08 14:47:51 +00:00
097703beda readme update 2025-11-08 13:45:10 +00:00
9361e084e5
Create LICENSE 2025-11-08 13:44:11 +00:00
13e52e2880 images and getting closer to feature complete 2025-11-08 13:29:28 +00:00
26 changed files with 4738 additions and 377 deletions

11
.github/pull_request_template.md vendored Normal file
View 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.

175
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,175 @@
name: Release
on:
push:
branches:
- master
jobs:
create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.bump_version.outputs.new_version }}
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get latest tag
id: get_latest_tag
run: |
# Get the latest tag, default to v0.0.0 if no tags exist
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "latest_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT
echo "Latest tag: ${LATEST_TAG}"
- name: Get commit message
id: get_commit_message
run: |
COMMIT_MSG=$(git log -1 --pretty=%s)
echo "commit_message=${COMMIT_MSG}" >> $GITHUB_OUTPUT
echo "Commit message: ${COMMIT_MSG}"
- name: Determine version bump
id: bump_version
run: |
LATEST_TAG="${{ steps.get_latest_tag.outputs.latest_tag }}"
COMMIT_MSG="${{ steps.get_commit_message.outputs.commit_message }}"
# Remove 'v' prefix if present
VERSION=${LATEST_TAG#v}
# Split version into components
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
# Default values if parsing fails
MAJOR=${MAJOR:-0}
MINOR=${MINOR:-0}
PATCH=${PATCH:-0}
echo "Current version: $MAJOR.$MINOR.$PATCH"
# Check for chore commits - skip release
if [[ "$COMMIT_MSG" =~ ^chore:.*|^CHORE:.* ]]; then
echo "Chore commit detected. Skipping release."
echo "bump_type=none" >> $GITHUB_OUTPUT
echo "new_version=" >> $GITHUB_OUTPUT
exit 0
fi
# Determine bump type based on commit message prefix
if [[ "$COMMIT_MSG" =~ ^major:.*|^MAJOR:.*|^breaking:.*|^BREAKING:.* ]]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
echo "bump_type=major" >> $GITHUB_OUTPUT
elif [[ "$COMMIT_MSG" =~ ^feat:.*|^feature:.*|^FEAT:.*|^FEATURE:.* ]]; then
MINOR=$((MINOR + 1))
PATCH=0
echo "bump_type=minor" >> $GITHUB_OUTPUT
elif [[ "$COMMIT_MSG" =~ ^fix:.*|^FIX:.*|^bugfix:.*|^BUGFIX:.* ]]; then
PATCH=$((PATCH + 1))
echo "bump_type=patch" >> $GITHUB_OUTPUT
else
echo "No version bump keyword found in commit message. Skipping release."
echo "bump_type=none" >> $GITHUB_OUTPUT
echo "new_version=" >> $GITHUB_OUTPUT
exit 0
fi
NEW_VERSION="v$MAJOR.$MINOR.$PATCH"
echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "New version: ${NEW_VERSION}"
- name: Configure Git
if: steps.bump_version.outputs.new_version != ''
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Create and push tag
if: steps.bump_version.outputs.new_version != ''
run: |
NEW_VERSION="${{ steps.bump_version.outputs.new_version }}"
git tag -a "${NEW_VERSION}" -m "Release ${NEW_VERSION}"
git push origin "${NEW_VERSION}"
- name: Create Release
if: steps.bump_version.outputs.new_version != ''
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.bump_version.outputs.new_version }}
release_name: Release ${{ steps.bump_version.outputs.new_version }}
body: |
Release ${{ steps.bump_version.outputs.new_version }}
Version bump: ${{ steps.bump_version.outputs.bump_type }}
## Changes
${{ steps.get_commit_message.outputs.commit_message }}
draft: false
prerelease: false
build-binaries:
name: Build binaries
needs: create-release
if: needs.create-release.outputs.new_version != ''
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, darwin, windows, freebsd, netbsd, openbsd]
goarch: [amd64, arm64]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.create-release.outputs.new_version }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.3'
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
VERSION="${{ needs.create-release.outputs.new_version }}"
BINARY_NAME="org"
if [ "$GOOS" = "windows" ]; then
BINARY_NAME="org.exe"
fi
OUTPUT_NAME="org-${VERSION}-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT_NAME="${OUTPUT_NAME}.exe"
fi
echo "Building for $GOOS/$GOARCH..."
go build -o "${OUTPUT_NAME}" \
-ldflags "-s -w -X main.version=${VERSION}" \
./cmd/org
echo "binary_name=${OUTPUT_NAME}" >> $GITHUB_OUTPUT
ls -lh "${OUTPUT_NAME}"
id: build
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ./org-${{ needs.create-release.outputs.new_version }}-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }}
asset_name: org-${{ needs.create-release.outputs.new_version }}-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }}
asset_content_type: application/octet-stream

BIN
.imgs/capture_prompt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
.imgs/delete_prompt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
.imgs/editing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
.imgs/list_view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
.imgs/priority_prompt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
.imgs/settings_keybinds.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
.imgs/settings_states.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
.imgs/settings_tags.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Rasmus Wejlgaard
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

293
README.md Normal file
View file

@ -0,0 +1,293 @@
# Org
A simple terminal-based Org-mode task manager inspired by the simplicity of `nano`. Manage your TODO items, track time, and stay organized without leaving the command line.
## Installation
```bash
go install github.com/rwejlgaard/org/cmd/org@latest
```
Or build from source:
```bash
git clone https://github.com/rwejlgaard/org
cd org
go build -o bin/org ./cmd/org
```
## Usage
```bash
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
- **Customizable TODO States**: Define your own workflow states with custom colors (default: TODO, PROG, BLOCK, DONE)
- **Hierarchical Tasks**: Create sub-tasks and organize items with multiple levels
- **Priority Levels**: Set priorities (A, B, C) with color-coded indicators
- **Tags**: Organize tasks with tags like `:work:urgent:` with customizable colors
- **Folding**: Collapse and expand tasks and notes with Tab key
- **Quick Capture**: Press 'c' to quickly capture new TODO items
- **Reorder Mode**: Reorganize tasks with shift+up/down arrows
### Scheduling & Deadlines
- **Deadlines**: Set and track task deadlines with visual indicators
- **Scheduled Dates**: Schedule tasks for specific dates
- **Agenda View**: View upcoming tasks for the next 7 days
- **Overdue Highlighting**: Automatically highlights overdue items in red
### Time Tracking
- **Clock In/Out**: Track time spent on tasks with 'i' (clock in) and 'o' (clock out)
- **Duration Display**: See current and total time tracked per task
- **Effort Estimates**: Set estimated effort (e.g., 8h, 2d, 1w)
- **Automatic Logging**: All clock entries are logged in LOGBOOK drawer
### Notes & Documentation
- **Rich Notes**: Add detailed notes to any task with Enter key
- **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
| Key | Action |
|-----|--------|
| `↑/k`, `↓/j` | Navigate up/down |
| `←/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` | Force save |
| `?` | Toggle help |
| `q` or `ctrl+c` | Quit |
**Note**: All keybindings can be customized in the configuration file.
### Auto-save
Changes are automatically saved when you quit the application.
## Screenshots
### List view
![list view](./.imgs/list_view.png)
### Editing notes
![editing](./.imgs/editing.png)
### Prompts
![capture](./.imgs/capture_prompt.png)
![delete](./.imgs/delete_prompt.png)
![priority](./.imgs/priority_prompt.png)
### Settings
![tags](./.imgs/settings_tags.png)
![states](./.imgs/settings_states.png)
![keybinds](./.imgs/settings_keybinds.png)
## Configuration
The application can be configured using a TOML configuration file located at:
- Linux/macOS: `~/.config/org/config.toml`
- Windows: `%APPDATA%\org\config.toml`
The configuration file is automatically created with default values on first run.
### Configuration Structure
#### Tags
Define custom tags with colors:
```toml
[tags]
enabled = true
default_tag = "work"
[[tags.tags]]
name = "work"
color = "99" # Blue
[[tags.tags]]
name = "personal"
color = "141" # Purple
[[tags.tags]]
name = "urgent"
color = "196" # Red
```
#### States
Customize TODO states with colors:
```toml
[states]
[[states.states]]
name = "TODO"
color = "202" # Orange
[[states.states]]
name = "PROG"
color = "220" # Yellow
[[states.states]]
name = "BLOCK"
color = "196" # Red
[[states.states]]
name = "DONE"
color = "34" # Green
```
#### Colors
Customize UI colors (using ANSI color codes):
```toml
[colors]
todo = "202" # Orange
progress = "220" # Yellow
blocked = "196" # Red
done = "34" # Green
cursor = "240" # Gray
title = "99" # Blue
scheduled = "141" # Purple
overdue = "196" # Red
status = "241" # Dark gray
note = "246" # Light gray
folded = "243" # Medium gray
```
#### Keybindings
Customize all keybindings (can specify multiple keys per action):
```toml
[keybindings]
up = ["up", "k"]
down = ["down", "j"]
left = ["left", "h"]
right = ["right", "l"]
cycle_state = ["t", " "]
toggle_fold = ["tab"]
edit_notes = ["enter"]
capture = ["c"]
add_subtask = ["s"]
delete = ["D"]
tag_item = ["#"]
settings = [","]
toggle_view = ["a"]
save = ["ctrl+s"]
help = ["?"]
quit = ["q", "ctrl+c"]
# ... and more
```
### Settings UI
Press `,` (comma) to open the settings interface where you can:
#### Tags Tab
- Add new tags with custom colors
- Edit tag names and colors (format: `name,color`)
- Delete tags with `D`
- Reorder tags with `shift+up/down`
#### States Tab
- Add new TODO states with custom colors
- Edit state names and colors (format: `name,color`)
- Delete states with `D`
- Reorder states with `shift+up/down` (affects cycling order)
#### Keybindings Tab
- View all keybindings
- Edit keybindings (format: comma-separated keys, e.g., `up,k`)
- Multiple keys can be bound to the same action
**Navigation**: Use left/right arrows to switch between tabs
**Auto-save**: All changes are automatically saved to the config file
## File Format
The application uses standard Org-mode file format (.org), making it compatible with Emacs Org-mode and other Org-mode tools. Tags are stored in the standard org-mode format:
```org
* TODO Task title :work:urgent:
* DONE Completed task :personal:
```
## License
MIT

View file

@ -1,45 +1,119 @@
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()
// 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]
}
}
}
// Load configuration
cfg, err := config.LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Error loading config, using defaults: %v\n", err)
cfg = config.DefaultConfig()
}
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 getting current directory: %v\n", err)
fmt.Fprintf(os.Stderr, "Error parsing org files: %v\n", err)
os.Exit(1)
}
filePath = filepath.Join(cwd, "todo.org")
}
} 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)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err)
os.Exit(1)
// 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); 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)
}

1
go.mod
View file

@ -3,6 +3,7 @@ module github.com/rwejlgaard/org
go 1.25.3
require (
github.com/BurntSushi/toml v1.5.0
github.com/alecthomas/chroma/v2 v2.20.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10

2
go.sum
View file

@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=

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

@ -0,0 +1,614 @@
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/charmbracelet/bubbles/key"
)
// Config represents the application configuration
type Config struct {
Keybindings KeybindingsConfig `toml:"keybindings"`
Colors ColorsConfig `toml:"colors"`
Tags TagsConfig `toml:"tags"`
States StatesConfig `toml:"states"`
UI UIConfig `toml:"ui"`
}
// KeybindingsConfig holds all keybinding configurations
type KeybindingsConfig struct {
Up []string `toml:"up"`
Down []string `toml:"down"`
Left []string `toml:"left"`
Right []string `toml:"right"`
ShiftUp []string `toml:"shift_up"`
ShiftDown []string `toml:"shift_down"`
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"`
AddSubTask []string `toml:"add_subtask"`
Delete []string `toml:"delete"`
Save []string `toml:"save"`
ToggleReorder []string `toml:"toggle_reorder"`
ClockIn []string `toml:"clock_in"`
ClockOut []string `toml:"clock_out"`
SetDeadline []string `toml:"set_deadline"`
SetScheduled []string `toml:"set_scheduled"`
SetPriority []string `toml:"set_priority"`
SetEffort []string `toml:"set_effort"`
Help []string `toml:"help"`
Quit []string `toml:"quit"`
Settings []string `toml:"settings"`
TagItem []string `toml:"tag_item"`
}
// ColorsConfig holds color configurations
type ColorsConfig struct {
Todo string `toml:"todo"`
Progress string `toml:"progress"`
Blocked string `toml:"blocked"`
Done string `toml:"done"`
Cursor string `toml:"cursor"`
Title string `toml:"title"`
Scheduled string `toml:"scheduled"`
Overdue string `toml:"overdue"`
Status string `toml:"status"`
Note string `toml:"note"`
Folded string `toml:"folded"`
}
// TagConfig represents a single tag configuration
type TagConfig struct {
Name string `toml:"name"`
Color string `toml:"color"`
}
// TagsConfig holds tag configurations
type TagsConfig struct {
Enabled bool `toml:"enabled"`
Tags []TagConfig `toml:"tags"`
DefaultTag string `toml:"default_tag"`
}
// StateConfig represents a single TODO state configuration
type StateConfig struct {
Name string `toml:"name"`
Color string `toml:"color"`
}
// StatesConfig holds TODO state configurations
type StatesConfig struct {
States []StateConfig `toml:"states"`
DefaultNewTaskState string `toml:"default_new_task_state"`
}
// 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"`
OrgSyntaxHighlighting bool `toml:"org_syntax_highlighting"`
ShowIndentationGuides bool `toml:"show_indentation_guides"`
IndentationGuideColor string `toml:"indentation_guide_color"`
}
// DefaultConfig returns the default configuration
func DefaultConfig() *Config {
return &Config{
Keybindings: KeybindingsConfig{
Up: []string{"up", "k"},
Down: []string{"down", "j"},
Left: []string{"left", "h"},
Right: []string{"right", "l"},
ShiftUp: []string{"shift+up"},
ShiftDown: []string{"shift+down"},
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"},
AddSubTask: []string{"s"},
Delete: []string{"D"},
Save: []string{"ctrl+s"},
ToggleReorder: []string{"r"},
ClockIn: []string{"i"},
ClockOut: []string{"o"},
SetDeadline: []string{"d"},
SetScheduled: []string{"S"},
SetPriority: []string{"p"},
SetEffort: []string{"e"},
Help: []string{"?"},
Quit: []string{"q", "ctrl+c"},
Settings: []string{","},
TagItem: []string{"#"},
},
Colors: ColorsConfig{
Todo: "202",
Progress: "220",
Blocked: "196",
Done: "34",
Cursor: "240",
Title: "99",
Scheduled: "141",
Overdue: "196",
Status: "241",
Note: "246",
Folded: "243",
},
Tags: TagsConfig{
Enabled: true,
DefaultTag: "work",
Tags: []TagConfig{
{Name: "work", Color: "99"},
{Name: "personal", Color: "141"},
{Name: "urgent", Color: "196"},
{Name: "important", Color: "220"},
},
},
States: StatesConfig{
States: []StateConfig{
{Name: "TODO", Color: "202"},
{Name: "PROG", Color: "220"},
{Name: "BLOCK", Color: "196"},
{Name: "DONE", Color: "34"},
},
DefaultNewTaskState: "TODO",
},
UI: UIConfig{
HelpTextWidth: 22,
MinTerminalWidth: 40,
AgendaDays: 7,
OrgSyntaxHighlighting: true,
ShowIndentationGuides: true,
IndentationGuideColor: "245",
},
}
}
// GetConfigPath returns the path to the config file
func GetConfigPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
orgConfigDir := filepath.Join(configDir, "org")
configPath := filepath.Join(orgConfigDir, "config.toml")
return configPath, nil
}
// LoadConfig loads the configuration from the config file
func LoadConfig() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
// If config file doesn't exist, create it with defaults
if _, err := os.Stat(configPath); os.IsNotExist(err) {
defaultCfg := DefaultConfig()
if err := defaultCfg.Save(); err != nil {
// If we can't save, just return defaults
return defaultCfg, nil
}
return defaultCfg, nil
}
var config Config
if _, err := toml.DecodeFile(configPath, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// Merge with defaults for any missing values
config.fillDefaults()
return &config, nil
}
// Save saves the configuration to the config file
func (c *Config) Save() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
// Ensure config directory exists
configDir := filepath.Dir(configPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// Create or truncate the file
f, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer f.Close()
// Encode config to TOML
encoder := toml.NewEncoder(f)
if err := encoder.Encode(c); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
// fillDefaults fills in any missing config values with defaults
func (c *Config) fillDefaults() {
defaults := DefaultConfig()
// Fill keybindings if empty
if len(c.Keybindings.Up) == 0 {
c.Keybindings.Up = defaults.Keybindings.Up
}
if len(c.Keybindings.Down) == 0 {
c.Keybindings.Down = defaults.Keybindings.Down
}
if len(c.Keybindings.Left) == 0 {
c.Keybindings.Left = defaults.Keybindings.Left
}
if len(c.Keybindings.Right) == 0 {
c.Keybindings.Right = defaults.Keybindings.Right
}
if len(c.Keybindings.ShiftUp) == 0 {
c.Keybindings.ShiftUp = defaults.Keybindings.ShiftUp
}
if len(c.Keybindings.ShiftDown) == 0 {
c.Keybindings.ShiftDown = defaults.Keybindings.ShiftDown
}
if len(c.Keybindings.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
}
if len(c.Keybindings.ToggleView) == 0 {
c.Keybindings.ToggleView = defaults.Keybindings.ToggleView
}
if len(c.Keybindings.Capture) == 0 {
c.Keybindings.Capture = defaults.Keybindings.Capture
}
if len(c.Keybindings.AddSubTask) == 0 {
c.Keybindings.AddSubTask = defaults.Keybindings.AddSubTask
}
if len(c.Keybindings.Delete) == 0 {
c.Keybindings.Delete = defaults.Keybindings.Delete
}
if len(c.Keybindings.Save) == 0 {
c.Keybindings.Save = defaults.Keybindings.Save
}
if len(c.Keybindings.ToggleReorder) == 0 {
c.Keybindings.ToggleReorder = defaults.Keybindings.ToggleReorder
}
if len(c.Keybindings.ClockIn) == 0 {
c.Keybindings.ClockIn = defaults.Keybindings.ClockIn
}
if len(c.Keybindings.ClockOut) == 0 {
c.Keybindings.ClockOut = defaults.Keybindings.ClockOut
}
if len(c.Keybindings.SetDeadline) == 0 {
c.Keybindings.SetDeadline = defaults.Keybindings.SetDeadline
}
if len(c.Keybindings.SetScheduled) == 0 {
c.Keybindings.SetScheduled = defaults.Keybindings.SetScheduled
}
if len(c.Keybindings.SetPriority) == 0 {
c.Keybindings.SetPriority = defaults.Keybindings.SetPriority
}
if len(c.Keybindings.SetEffort) == 0 {
c.Keybindings.SetEffort = defaults.Keybindings.SetEffort
}
if len(c.Keybindings.Help) == 0 {
c.Keybindings.Help = defaults.Keybindings.Help
}
if len(c.Keybindings.Quit) == 0 {
c.Keybindings.Quit = defaults.Keybindings.Quit
}
if len(c.Keybindings.Settings) == 0 {
c.Keybindings.Settings = defaults.Keybindings.Settings
}
if len(c.Keybindings.TagItem) == 0 {
c.Keybindings.TagItem = defaults.Keybindings.TagItem
}
// Fill colors if empty
if c.Colors.Todo == "" {
c.Colors.Todo = defaults.Colors.Todo
}
if c.Colors.Progress == "" {
c.Colors.Progress = defaults.Colors.Progress
}
if c.Colors.Blocked == "" {
c.Colors.Blocked = defaults.Colors.Blocked
}
if c.Colors.Done == "" {
c.Colors.Done = defaults.Colors.Done
}
if c.Colors.Cursor == "" {
c.Colors.Cursor = defaults.Colors.Cursor
}
if c.Colors.Title == "" {
c.Colors.Title = defaults.Colors.Title
}
if c.Colors.Scheduled == "" {
c.Colors.Scheduled = defaults.Colors.Scheduled
}
if c.Colors.Overdue == "" {
c.Colors.Overdue = defaults.Colors.Overdue
}
if c.Colors.Status == "" {
c.Colors.Status = defaults.Colors.Status
}
if c.Colors.Note == "" {
c.Colors.Note = defaults.Colors.Note
}
if c.Colors.Folded == "" {
c.Colors.Folded = defaults.Colors.Folded
}
// Fill tags if empty
if len(c.Tags.Tags) == 0 {
c.Tags.Tags = defaults.Tags.Tags
}
if c.Tags.DefaultTag == "" {
c.Tags.DefaultTag = defaults.Tags.DefaultTag
}
// Fill states if empty
if len(c.States.States) == 0 {
c.States.States = defaults.States.States
// 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 {
c.UI.HelpTextWidth = defaults.UI.HelpTextWidth
}
if c.UI.MinTerminalWidth == 0 {
c.UI.MinTerminalWidth = defaults.UI.MinTerminalWidth
}
if c.UI.AgendaDays == 0 {
c.UI.AgendaDays = defaults.UI.AgendaDays
}
if c.UI.IndentationGuideColor == "" {
c.UI.IndentationGuideColor = defaults.UI.IndentationGuideColor
}
}
// BuildKeyBinding creates a key.Binding from config
func BuildKeyBinding(keys []string, help string, description string) key.Binding {
return key.NewBinding(
key.WithKeys(keys...),
key.WithHelp(help, description),
)
}
// GetTagColor returns the color for a given tag name
func (c *Config) GetTagColor(tagName string) string {
for _, tag := range c.Tags.Tags {
if tag.Name == tagName {
return tag.Color
}
}
// Return a default color if tag not found
return "99"
}
// AddTag adds a new tag to the configuration
func (c *Config) AddTag(name, color string) {
// Check if tag already exists
for i, tag := range c.Tags.Tags {
if tag.Name == name {
c.Tags.Tags[i].Color = color
return
}
}
c.Tags.Tags = append(c.Tags.Tags, TagConfig{Name: name, Color: color})
}
// RemoveTag removes a tag from the configuration
func (c *Config) RemoveTag(name string) {
for i, tag := range c.Tags.Tags {
if tag.Name == name {
c.Tags.Tags = append(c.Tags.Tags[:i], c.Tags.Tags[i+1:]...)
return
}
}
}
// UpdateTagColor updates the color of an existing tag
func (c *Config) UpdateTagColor(name, color string) {
for i, tag := range c.Tags.Tags {
if tag.Name == name {
c.Tags.Tags[i].Color = color
return
}
}
}
// GetStateColor returns the color for a given state name
func (c *Config) GetStateColor(stateName string) string {
for _, state := range c.States.States {
if state.Name == stateName {
return state.Color
}
}
// Return a default color if state not found
return "99"
}
// AddState adds a new state to the configuration
func (c *Config) AddState(name, color string) {
// Check if state already exists
for i, state := range c.States.States {
if state.Name == name {
c.States.States[i].Color = color
return
}
}
c.States.States = append(c.States.States, StateConfig{Name: name, Color: color})
}
// RemoveState removes a state from the configuration
func (c *Config) RemoveState(name string) {
for i, state := range c.States.States {
if state.Name == name {
c.States.States = append(c.States.States[:i], c.States.States[i+1:]...)
return
}
}
}
// UpdateStateColor updates the color of an existing state
func (c *Config) UpdateStateColor(name, color string) {
for i, state := range c.States.States {
if state.Name == name {
c.States.States[i].Color = color
return
}
}
}
// GetStateNames returns all configured state names
func (c *Config) GetStateNames() []string {
names := make([]string, len(c.States.States))
for i, state := range c.States.States {
names[i] = state.Name
}
return names
}
// UpdateKeybinding updates a keybinding in the configuration
func (c *Config) UpdateKeybinding(action string, keys []string) error {
// Use reflection would be complex, so we handle specific cases
switch action {
case "up":
c.Keybindings.Up = keys
case "down":
c.Keybindings.Down = keys
case "left":
c.Keybindings.Left = keys
case "right":
c.Keybindings.Right = keys
case "cycle_state":
c.Keybindings.CycleState = keys
case "toggle_fold":
c.Keybindings.ToggleFold = keys
case "toggle_fold_all":
c.Keybindings.ToggleFoldAll = keys
case "edit_notes":
c.Keybindings.EditNotes = keys
case "capture":
c.Keybindings.Capture = keys
case "add_subtask":
c.Keybindings.AddSubTask = keys
case "delete":
c.Keybindings.Delete = keys
case "tag_item":
c.Keybindings.TagItem = keys
case "settings":
c.Keybindings.Settings = keys
case "toggle_view":
c.Keybindings.ToggleView = keys
case "save":
c.Keybindings.Save = keys
case "help":
c.Keybindings.Help = keys
case "quit":
c.Keybindings.Quit = keys
default:
return fmt.Errorf("unknown action: %s", action)
}
return nil
}
// GetAllKeybindings returns a map of all keybindings
func (c *Config) GetAllKeybindings() map[string][]string {
return map[string][]string{
"up": c.Keybindings.Up,
"down": c.Keybindings.Down,
"left": c.Keybindings.Left,
"right": c.Keybindings.Right,
"shift_up": c.Keybindings.ShiftUp,
"shift_down": c.Keybindings.ShiftDown,
"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,
}
}
// GetDefaultNewTaskState returns the default state for new tasks
// Returns empty string if configured as "none" or if the configured state doesn't exist
func (c *Config) GetDefaultNewTaskState() string {
// Empty string means no state
if c.States.DefaultNewTaskState == "" {
return ""
}
// Validate that the configured state exists
for _, state := range c.States.States {
if state.Name == c.States.DefaultNewTaskState {
return c.States.DefaultNewTaskState
}
}
// If configured state doesn't exist, fall back to first state or empty
if len(c.States.States) > 0 {
return c.States.States[0].Name
}
return ""
}

View file

@ -2,17 +2,32 @@ package model
import "time"
// Priority represents org-mode priority levels
type Priority string
const (
PriorityNone Priority = ""
PriorityA Priority = "A"
PriorityB Priority = "B"
PriorityC Priority = "C"
)
// Item represents a single org-mode item (heading)
type Item struct {
Level int // Heading level (number of *)
State TodoState // TODO, PROG, BLOCK, DONE, or empty
Priority Priority // Priority: A, B, C, or empty
Title string // The main title text
Tags []string // Tags for this item (e.g., :work:urgent:)
Scheduled *time.Time
Deadline *time.Time
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

View file

@ -3,26 +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+)?(.+)$`)
scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`)
deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`)
clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`)
drawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`)
drawerEnd = regexp.MustCompile(`^\s*:END:\s*$`)
codeBlockStart = regexp.MustCompile(`^\s*#\+BEGIN_SRC`)
codeBlockEnd = regexp.MustCompile(`^\s*#\+END_SRC`)
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`)
)
// 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
@ -40,25 +66,42 @@ func ParseOrgFile(path string) (*model.OrgFile, error) {
var itemStack []*model.Item // Stack to track parent items
var inCodeBlock bool
var inLogbookDrawer bool
var inPropertiesDrawer bool
for scanner.Scan() {
line := scanner.Text()
// Check for drawer boundaries
if drawerStart.MatchString(line) {
if logbookDrawerStart.MatchString(line) {
inLogbookDrawer = true
if currentItem != nil {
currentItem.Notes = append(currentItem.Notes, line)
}
continue
}
if drawerEnd.MatchString(line) && inLogbookDrawer {
inLogbookDrawer = false
if propertiesDrawerStart.MatchString(line) {
inPropertiesDrawer = true
if currentItem != nil {
currentItem.Notes = append(currentItem.Notes, line)
}
continue
}
if drawerEnd.MatchString(line) {
if inLogbookDrawer {
inLogbookDrawer = false
if currentItem != nil {
currentItem.Notes = append(currentItem.Notes, line)
}
continue
}
if inPropertiesDrawer {
inPropertiesDrawer = false
if currentItem != nil {
currentItem.Notes = append(currentItem.Notes, line)
}
continue
}
}
// Check for code block boundaries
if codeBlockStart.MatchString(line) {
@ -88,12 +131,25 @@ func ParseOrgFile(path string) (*model.OrgFile, error) {
if matches := headingPattern.FindStringSubmatch(line); matches != nil {
level := len(matches[1])
state := model.TodoState(matches[2])
title := matches[3]
priority := model.Priority(matches[3])
title := strings.TrimSpace(matches[4])
tagsStr := matches[5]
// Parse tags from :tag1:tag2: format
var tags []string
if tagsStr != "" {
tagsStr = strings.Trim(tagsStr, ":")
if tagsStr != "" {
tags = strings.Split(tagsStr, ":")
}
}
item := &model.Item{
Level: level,
State: state,
Priority: priority,
Title: title,
Tags: tags,
Notes: []string{},
Children: []*model.Item{},
}
@ -132,6 +188,18 @@ 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])
}
// Check for CLOCK (can be inside or outside drawer)
if matches := clockPattern.FindStringSubmatch(line); matches != nil {
if startTime, err := parseClockTimestamp(matches[1]); err == nil {
@ -158,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)
}
}

View file

@ -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
@ -37,7 +109,17 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
if item.State != model.StateNone {
line += " " + string(item.State)
}
line += " " + item.Title + "\n"
if item.Priority != model.PriorityNone {
line += " [#" + string(item.Priority) + "]"
}
line += " " + item.Title
// Add tags if present
if len(item.Tags) > 0 {
line += " :" + strings.Join(item.Tags, ":") + ":"
}
line += "\n"
if _, err := writer.WriteString(line); err != nil {
return err
@ -46,7 +128,9 @@ 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 {
if strings.Contains(note, "SCHEDULED:") {
hasScheduled = true
@ -54,9 +138,22 @@ 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
}
if strings.Contains(note, ":PROPERTIES:") {
hasProperties = true
}
}
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 {
@ -73,6 +170,20 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
}
}
// Write effort in :PROPERTIES: drawer if not already in notes
if item.Effort != "" && !hasProperties {
if _, err := writer.WriteString(":PROPERTIES:\n"); err != nil {
return err
}
effortLine := fmt.Sprintf(":EFFORT: %s\n", item.Effort)
if _, err := writer.WriteString(effortLine); err != nil {
return err
}
if _, err := writer.WriteString(":END:\n"); err != nil {
return err
}
}
// Write clock entries in :LOGBOOK: drawer if not already in notes
if len(item.ClockEntries) > 0 && !hasLogbook {
if _, err := writer.WriteString(":LOGBOOK:\n"); err != nil {

View file

@ -1,12 +1,14 @@
package ui
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/rwejlgaard/org/internal/config"
"github.com/rwejlgaard/org/internal/model"
)
@ -20,26 +22,41 @@ const (
modeCapture
modeAddSubTask
modeSetDeadline
modeSetScheduled
modeSetPriority
modeSetEffort
modeHelp
modeSettings
modeTagEdit
modeRename
)
type uiModel struct {
orgFile *model.OrgFile
cursor int
mode viewMode
help help.Model
keys keyMap
width int
height int
statusMsg string
statusExpiry time.Time
editingItem *model.Item
textarea textarea.Model
textinput textinput.Model
itemToDelete *model.Item
reorderMode bool
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) 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
@ -51,18 +68,29 @@ func initialModel(orgFile *model.OrgFile) 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: keys,
keys: newKeyMapFromConfig(cfg),
styles: newStyleMapFromConfig(cfg),
config: cfg,
textarea: ta,
textinput: ti,
}
}
func (m uiModel) Init() tea.Cmd {
if m.mode == modeCapture {
return textinput.Blink
}
return nil
}
@ -78,9 +106,56 @@ func (m uiModel) getVisibleItems() []*model.Item {
return m.orgFile.GetAllItems()
}
func (m *uiModel) updateScrollOffset(availableHeight int) {
items := m.getVisibleItems()
if len(items) == 0 {
return
}
// Build line count for each item
itemLineCount := make([]int, len(items))
for i, item := range items {
lineCount := 1 // The item itself
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
// 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
}
// Calculate total lines up to cursor
totalLinesBeforeCursor := 0
for i := 0; i < m.cursor && i < len(itemLineCount); i++ {
totalLinesBeforeCursor += itemLineCount[i]
}
// Adjust scroll offset to keep cursor visible
if totalLinesBeforeCursor < m.scrollOffset {
// Cursor is above visible area, scroll up
m.scrollOffset = totalLinesBeforeCursor
} else if totalLinesBeforeCursor >= m.scrollOffset+availableHeight {
// Cursor is below visible area, scroll down
m.scrollOffset = totalLinesBeforeCursor - availableHeight + 1
}
// Ensure scroll offset doesn't go negative
if m.scrollOffset < 0 {
m.scrollOffset = 0
}
}
// RunUI starts the terminal UI
func RunUI(orgFile *model.OrgFile) error {
p := tea.NewProgram(initialModel(orgFile), tea.WithAltScreen())
func RunUI(orgFile *model.OrgFile, cfg *config.Config, 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
}

View file

@ -1,6 +1,11 @@
package ui
import "github.com/charmbracelet/bubbles/key"
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/rwejlgaard/org/internal/config"
)
type keyMap struct {
Up key.Binding
@ -9,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
@ -18,94 +26,163 @@ 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
TagItem key.Binding
}
var keys = keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "move down"),
),
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←/h", "cycle state backward"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→/l", "cycle state forward"),
),
ShiftUp: key.NewBinding(
key.WithKeys("shift+up"),
key.WithHelp("shift+↑", "move item up"),
),
ShiftDown: key.NewBinding(
key.WithKeys("shift+down"),
key.WithHelp("shift+↓", "move item down"),
),
CycleState: key.NewBinding(
key.WithKeys("t", " "),
key.WithHelp("t/space", "cycle todo state"),
),
ToggleFold: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "fold/unfold"),
),
EditNotes: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "edit notes"),
),
ToggleView: key.NewBinding(
key.WithKeys("a"),
key.WithHelp("a", "toggle agenda view"),
),
Capture: key.NewBinding(
key.WithKeys("c"),
key.WithHelp("c", "capture TODO"),
),
AddSubTask: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "add sub-task"),
),
Delete: key.NewBinding(
key.WithKeys("shift+d"),
key.WithHelp("shift+d", "delete item"),
),
Save: key.NewBinding(
key.WithKeys("ctrl+s"),
key.WithHelp("ctrl+s", "save"),
),
ToggleReorder: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "reorder mode"),
),
ClockIn: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "clock in"),
),
ClockOut: key.NewBinding(
key.WithKeys("o"),
key.WithHelp("o", "clock out"),
),
SetDeadline: key.NewBinding(
key.WithKeys("d"),
key.WithHelp("d", "set deadline"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
// newKeyMapFromConfig creates a keyMap from configuration
func newKeyMapFromConfig(cfg *config.Config) keyMap {
kb := cfg.Keybindings
return keyMap{
Up: key.NewBinding(
key.WithKeys(kb.Up...),
key.WithHelp(formatKeyHelp(kb.Up), "move up"),
),
Down: key.NewBinding(
key.WithKeys(kb.Down...),
key.WithHelp(formatKeyHelp(kb.Down), "move down"),
),
Left: key.NewBinding(
key.WithKeys(kb.Left...),
key.WithHelp(formatKeyHelp(kb.Left), "cycle state backward"),
),
Right: key.NewBinding(
key.WithKeys(kb.Right...),
key.WithHelp(formatKeyHelp(kb.Right), "cycle state forward"),
),
ShiftUp: key.NewBinding(
key.WithKeys(kb.ShiftUp...),
key.WithHelp(formatKeyHelp(kb.ShiftUp), "move item up"),
),
ShiftDown: key.NewBinding(
key.WithKeys(kb.ShiftDown...),
key.WithHelp(formatKeyHelp(kb.ShiftDown), "move item down"),
),
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"),
),
ToggleFold: key.NewBinding(
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"),
),
ToggleView: key.NewBinding(
key.WithKeys(kb.ToggleView...),
key.WithHelp(formatKeyHelp(kb.ToggleView), "toggle agenda view"),
),
Capture: key.NewBinding(
key.WithKeys(kb.Capture...),
key.WithHelp(formatKeyHelp(kb.Capture), "capture TODO"),
),
AddSubTask: key.NewBinding(
key.WithKeys(kb.AddSubTask...),
key.WithHelp(formatKeyHelp(kb.AddSubTask), "add sub-task"),
),
Delete: key.NewBinding(
key.WithKeys(kb.Delete...),
key.WithHelp(formatKeyHelp(kb.Delete), "delete item"),
),
Save: key.NewBinding(
key.WithKeys(kb.Save...),
key.WithHelp(formatKeyHelp(kb.Save), "save"),
),
ToggleReorder: key.NewBinding(
key.WithKeys(kb.ToggleReorder...),
key.WithHelp(formatKeyHelp(kb.ToggleReorder), "reorder mode"),
),
ClockIn: key.NewBinding(
key.WithKeys(kb.ClockIn...),
key.WithHelp(formatKeyHelp(kb.ClockIn), "clock in"),
),
ClockOut: key.NewBinding(
key.WithKeys(kb.ClockOut...),
key.WithHelp(formatKeyHelp(kb.ClockOut), "clock out"),
),
SetDeadline: key.NewBinding(
key.WithKeys(kb.SetDeadline...),
key.WithHelp(formatKeyHelp(kb.SetDeadline), "set deadline"),
),
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"),
),
SetEffort: key.NewBinding(
key.WithKeys(kb.SetEffort...),
key.WithHelp(formatKeyHelp(kb.SetEffort), "set effort"),
),
Help: key.NewBinding(
key.WithKeys(kb.Help...),
key.WithHelp(formatKeyHelp(kb.Help), "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys(kb.Quit...),
key.WithHelp(formatKeyHelp(kb.Quit), "quit"),
),
Settings: key.NewBinding(
key.WithKeys(kb.Settings...),
key.WithHelp(formatKeyHelp(kb.Settings), "settings"),
),
TagItem: key.NewBinding(
key.WithKeys(kb.TagItem...),
key.WithHelp(formatKeyHelp(kb.TagItem), "add/edit tags"),
),
}
}
// formatKeyHelp formats a slice of keys for display in help
func formatKeyHelp(keys []string) string {
if len(keys) == 0 {
return ""
}
// Take first two keys for display
if len(keys) == 1 {
return formatKey(keys[0])
}
return formatKey(keys[0]) + "/" + formatKey(keys[1])
}
// formatKey formats a single key for display
func formatKey(k string) string {
// Convert key names to symbols where appropriate
k = strings.ReplaceAll(k, "up", "↑")
k = strings.ReplaceAll(k, "down", "↓")
k = strings.ReplaceAll(k, "left", "←")
k = strings.ReplaceAll(k, "right", "→")
return k
}
func (k keyMap) ShortHelp() []key.Binding {
@ -116,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},
}
@ -126,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.ToggleView, k.Help, k.Quit,
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
View 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
}

File diff suppressed because it is too large Load diff

1038
internal/ui/settings.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,40 @@
package ui
import "github.com/charmbracelet/lipgloss"
// Styles for UI rendering
var (
todoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("202")) // Orange
progStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // Yellow
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red
doneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34")) // Green
cursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("240"))
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99"))
scheduledStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple
overdueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Italic(true)
noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246")).Italic(true)
foldedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
import (
"github.com/charmbracelet/lipgloss"
"github.com/rwejlgaard/org/internal/config"
)
// styleMap holds all the styles used in the UI
type styleMap struct {
todoStyle lipgloss.Style
progStyle lipgloss.Style
blockStyle lipgloss.Style
doneStyle lipgloss.Style
cursorStyle lipgloss.Style
titleStyle lipgloss.Style
scheduledStyle lipgloss.Style
overdueStyle lipgloss.Style
statusStyle lipgloss.Style
noteStyle lipgloss.Style
foldedStyle lipgloss.Style
}
// newStyleMapFromConfig creates a styleMap from configuration
func newStyleMapFromConfig(cfg *config.Config) styleMap {
colors := cfg.Colors
return styleMap{
todoStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Todo)),
progStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Progress)),
blockStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Blocked)),
doneStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Done)),
cursorStyle: lipgloss.NewStyle().Background(lipgloss.Color(colors.Cursor)),
titleStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colors.Title)),
scheduledStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Scheduled)),
overdueStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Overdue)),
statusStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Status)).Italic(true),
noteStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Note)).Italic(true),
foldedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(colors.Folded)),
}
}

View file

@ -32,9 +32,6 @@ func (d dynamicKeyMap) FullHelp() [][]key.Binding {
func (m uiModel) renderFullHelp() string {
bindings := m.keys.getAllBindings()
// Estimate the width needed for each keybinding (key + desc + padding)
// Average is roughly 20-25 chars per binding
const estimatedBindingWidth = 22
const minWidth = 40 // Minimum width before stacking
var columnsPerRow int
@ -81,6 +78,24 @@ 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:
return m.viewSetEffort()
case modeHelp:
return m.viewHelp()
case modeSettings:
return m.viewSettings()
case modeSettingsAddTag:
return m.viewSettingsAddTag()
case modeSettingsAddState:
return m.viewSettingsAddState()
case modeTagEdit:
return m.viewTagEdit()
case modeRename:
return m.viewRename()
}
// Build footer (status + help)
@ -88,7 +103,7 @@ func (m uiModel) View() string {
// Status message
if time.Now().Before(m.statusExpiry) {
footer.WriteString(statusStyle.Render(m.statusMsg))
footer.WriteString(m.styles.statusStyle.Render(m.statusMsg))
footer.WriteString("\n")
}
@ -111,10 +126,10 @@ func (m uiModel) View() string {
}
if m.reorderMode {
reorderIndicator := lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(" [REORDER MODE]")
content.WriteString(titleStyle.Render(title))
content.WriteString(m.styles.titleStyle.Render(title))
content.WriteString(reorderIndicator)
} else {
content.WriteString(titleStyle.Render(title))
content.WriteString(m.styles.titleStyle.Render(title))
}
content.WriteString("\n\n")
@ -130,12 +145,115 @@ func (m uiModel) View() string {
content.WriteString("No items. Press 'c' to capture a new TODO.\n")
}
// Build a map of item index to line count (for scrolling)
itemLineCount := make([]int, len(items))
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)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
lineCount += len(highlightedNotes)
}
itemLineCount[i] = lineCount
}
// Calculate total lines up to cursor
totalLinesBeforeCursor := 0
for i := 0; i < m.cursor && i < len(itemLineCount); i++ {
totalLinesBeforeCursor += itemLineCount[i]
}
// Determine the scroll offset (without modifying m - View should be pure)
scrollOffset := m.scrollOffset
if totalLinesBeforeCursor < scrollOffset {
// Cursor is above visible area, scroll up
scrollOffset = totalLinesBeforeCursor
} else if totalLinesBeforeCursor >= scrollOffset+availableHeight {
// Cursor is below visible area, scroll down
scrollOffset = totalLinesBeforeCursor - availableHeight + 1
}
// Render items starting from scroll offset
itemLines := 0
for i, item := range items {
if itemLines >= availableHeight {
break // Don't render more items than fit
// Calculate which line this item starts at
itemStartLine := 0
for j := 0; j < i; j++ {
itemStartLine += itemLineCount[j]
}
// Skip items before scroll offset
if itemStartLine+itemLineCount[i] <= scrollOffset {
continue
}
// Stop if we've filled the screen
if itemLines >= availableHeight {
break
}
// Skip partial items at the top if needed
if itemStartLine < scrollOffset {
// This item is partially scrolled off the top
linesToSkip := scrollOffset - itemStartLine
if linesToSkip < itemLineCount[i] {
// Render the visible parts
if linesToSkip == 0 {
line := m.renderItem(item, i == m.cursor)
content.WriteString(line)
content.WriteString("\n")
itemLines++
linesToSkip++
}
// Render remaining notes
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 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)
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])
content.WriteString("\n")
itemLines++
}
}
}
continue
}
// Render the full item
line := m.renderItem(item, i == m.cursor)
content.WriteString(line)
content.WriteString("\n")
@ -143,10 +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)
// Filter out LOGBOOK drawer and apply syntax highlighting to notes
// 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
@ -177,8 +309,6 @@ func (m uiModel) View() string {
}
func (m uiModel) viewConfirmDelete() string {
var b strings.Builder
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("196")).
@ -186,7 +316,7 @@ func (m uiModel) viewConfirmDelete() string {
Width(60)
var content strings.Builder
content.WriteString(titleStyle.Render("⚠ Delete Item"))
content.WriteString(m.styles.titleStyle.Render("⚠ Delete Item"))
content.WriteString("\n\n")
if m.itemToDelete != nil {
@ -196,27 +326,17 @@ func (m uiModel) viewConfirmDelete() string {
}
content.WriteString("\n")
content.WriteString(statusStyle.Render("This will delete the item and all sub-tasks."))
content.WriteString(m.styles.statusStyle.Render("This will delete the item and all sub-tasks."))
content.WriteString("\n\n")
content.WriteString("Press Y to confirm • N or ESC to cancel")
dialog := dialogStyle.Render(content.String())
// Center the dialog
if m.height > 0 {
verticalPadding := (m.height - lipgloss.Height(dialog)) / 2
if verticalPadding > 0 {
b.WriteString(strings.Repeat("\n", verticalPadding))
}
}
b.WriteString(dialog)
return b.String()
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewCapture() string {
var b strings.Builder
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("99")).
@ -224,29 +344,19 @@ func (m uiModel) viewCapture() string {
Width(60)
var content strings.Builder
content.WriteString(titleStyle.Render("Capture TODO"))
content.WriteString(m.styles.titleStyle.Render("Capture TODO"))
content.WriteString("\n\n")
content.WriteString(m.textinput.View())
content.WriteString("\n\n")
content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel"))
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog
if m.height > 0 {
verticalPadding := (m.height - lipgloss.Height(dialog)) / 2
if verticalPadding > 0 {
b.WriteString(strings.Repeat("\n", verticalPadding))
}
}
b.WriteString(dialog)
return b.String()
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewAddSubTask() string {
var b strings.Builder
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("99")).
@ -254,33 +364,31 @@ func (m uiModel) viewAddSubTask() string {
Width(60)
var content strings.Builder
content.WriteString(titleStyle.Render("Add Sub-Task"))
content.WriteString(m.styles.titleStyle.Render("Add Sub-Task"))
content.WriteString("\n")
if m.editingItem != nil {
content.WriteString(statusStyle.Render(fmt.Sprintf("Under: %s", m.editingItem.Title)))
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Under: %s", m.editingItem.Title)))
}
content.WriteString("\n\n")
content.WriteString(m.textinput.View())
content.WriteString("\n\n")
content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel"))
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog
if m.height > 0 {
verticalPadding := (m.height - lipgloss.Height(dialog)) / 2
if verticalPadding > 0 {
b.WriteString(strings.Repeat("\n", verticalPadding))
}
}
b.WriteString(dialog)
return b.String()
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewSetDeadline() string {
var b strings.Builder
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")).
@ -288,43 +396,222 @@ func (m uiModel) viewSetDeadline() string {
Width(60)
var content strings.Builder
content.WriteString(titleStyle.Render("Set Deadline"))
content.WriteString(m.styles.titleStyle.Render(title))
content.WriteString("\n")
if m.editingItem != nil {
content.WriteString(statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
}
content.WriteString("\n\n")
content.WriteString(m.textinput.View())
content.WriteString("\n\n")
content.WriteString(statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)"))
content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)"))
content.WriteString("\n")
content.WriteString(statusStyle.Render("Leave empty to clear deadline"))
content.WriteString(m.styles.statusStyle.Render(helpMsg))
content.WriteString("\n")
content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel"))
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog
if m.height > 0 {
verticalPadding := (m.height - lipgloss.Height(dialog)) / 2
if verticalPadding > 0 {
b.WriteString(strings.Repeat("\n", verticalPadding))
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewSetPriority() string {
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("214")).
Padding(1, 2).
Width(60)
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Set Priority"))
content.WriteString("\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
content.WriteString("\n")
if m.editingItem.Priority != model.PriorityNone {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: [#%s]", m.editingItem.Priority)))
}
}
b.WriteString(dialog)
content.WriteString("\n\n")
return b.String()
// Show priority options with styling
priorityAStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
priorityBStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
priorityCStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
content.WriteString(priorityAStyle.Render("[A] High Priority") + "\n")
content.WriteString(priorityBStyle.Render("[B] Medium Priority") + "\n")
content.WriteString(priorityCStyle.Render("[C] Low Priority") + "\n")
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Press Space/Enter to clear priority"))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Press ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewSetEffort() string {
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("141")).
Padding(1, 2).
Width(60)
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Set Effort"))
content.WriteString("\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
content.WriteString("\n")
if m.editingItem.Effort != "" {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: %s", m.editingItem.Effort)))
}
}
content.WriteString("\n\n")
content.WriteString(m.textinput.View())
content.WriteString("\n\n")
content.WriteString(m.styles.statusStyle.Render("Examples: 8h, 2d, 1w, 4h30m"))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Leave empty to clear effort"))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewHelp() string {
// Build the full help content first
var lines []string
// Title
lines = append(lines, m.styles.titleStyle.Render("Keybindings Help"))
lines = append(lines, "")
// Group bindings by category
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.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}
// Helper function to render a binding
renderBinding := func(b key.Binding) string {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("99")).Bold(true)
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
help := b.Help()
return fmt.Sprintf(" %s %s", keyStyle.Render(help.Key), descStyle.Render(help.Desc))
}
// Render categories
categoryStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
lines = append(lines, categoryStyle.Render("Navigation"))
for _, binding := range navigationBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("Item Actions"))
for _, binding := range itemBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("Task Management"))
for _, binding := range taskBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("Time Tracking"))
for _, binding := range timeBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("Organization"))
for _, binding := range organizationBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("View & System"))
for _, binding := range viewBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
// Calculate visible area
footerLines := 2 // Footer text
availableHeight := m.height - footerLines
if availableHeight < 5 {
availableHeight = 5
}
totalLines := len(lines)
// Determine which lines to show based on scroll offset
startLine := m.helpScroll
endLine := startLine + availableHeight
if endLine > totalLines {
endLine = totalLines
}
if startLine >= totalLines {
startLine = totalLines - 1
if startLine < 0 {
startLine = 0
}
}
// Build visible content
var content strings.Builder
for i := startLine; i < endLine && i < len(lines); i++ {
content.WriteString(lines[i])
content.WriteString("\n")
}
// Add scroll indicators and footer
var footer strings.Builder
if startLine > 0 || endLine < totalLines {
scrollInfo := fmt.Sprintf("(Scroll: %d-%d of %d lines)", startLine+1, endLine, totalLines)
footer.WriteString(m.styles.statusStyle.Render(scrollInfo))
footer.WriteString(" ")
}
footer.WriteString(m.styles.statusStyle.Render("↑/↓ scroll • ? or ESC to close"))
// Combine content and footer
var result strings.Builder
result.WriteString(content.String())
// Add padding if needed
currentHeight := lipgloss.Height(content.String())
paddingNeeded := availableHeight - currentHeight
if paddingNeeded > 0 {
result.WriteString(strings.Repeat("\n", paddingNeeded))
}
result.WriteString(footer.String())
return result.String()
}
func (m uiModel) viewEditMode() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Editing Notes"))
b.WriteString(m.styles.titleStyle.Render("Editing Notes"))
b.WriteString("\n")
if m.editingItem != nil {
b.WriteString(fmt.Sprintf("Item: %s\n", m.editingItem.Title))
}
b.WriteString(statusStyle.Render("Press ESC to save and exit"))
b.WriteString(m.styles.statusStyle.Render("Press ESC to save and exit"))
b.WriteString("\n\n")
b.WriteString(m.textarea.View())
@ -332,10 +619,11 @@ func (m uiModel) viewEditMode() string {
return b.String()
}
// filterLogbookDrawer removes LOGBOOK drawer content and scheduling metadata from notes
// filterLogbookDrawer removes LOGBOOK and PROPERTIES drawer content and scheduling metadata from notes
func filterLogbookDrawer(notes []string) []string {
var filtered []string
inLogbook := false
inProperties := false
for _, note := range notes {
trimmed := strings.TrimSpace(note)
@ -346,19 +634,31 @@ func filterLogbookDrawer(notes []string) []string {
continue
}
// Check for end of LOGBOOK drawer
if trimmed == ":END:" && inLogbook {
inLogbook = false
// Check for start of PROPERTIES drawer
if trimmed == ":PROPERTIES:" {
inProperties = true
continue
}
// Skip lines inside LOGBOOK drawer
if inLogbook {
// Check for end of drawer
if trimmed == ":END:" {
if inLogbook {
inLogbook = false
continue
}
if inProperties {
inProperties = false
continue
}
}
// Skip lines inside LOGBOOK or PROPERTIES drawer
if inLogbook || inProperties {
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
}
@ -368,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
}
@ -418,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{}
@ -435,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{}
@ -452,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
@ -482,19 +838,123 @@ 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 {
if item.Folded {
b.WriteString(foldedStyle.Render("▶ "))
b.WriteString(m.styles.foldedStyle.Render("▶ "))
} else {
b.WriteString(foldedStyle.Render("▼ "))
b.WriteString(m.styles.foldedStyle.Render("▼ "))
}
} else {
b.WriteString(" ")
@ -502,24 +962,48 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
// State
stateStr := ""
switch item.State {
case model.StateTODO:
stateStr = todoStyle.Render("[TODO] ")
case model.StatePROG:
stateStr = progStyle.Render("[PROG] ")
case model.StateBLOCK:
stateStr = blockStyle.Render("[BLOCK]")
case model.StateDONE:
stateStr = doneStyle.Render("[DONE] ")
default:
stateStr = " " // Empty space for alignment
if item.State != model.StateNone {
stateColor := m.config.GetStateColor(string(item.State))
stateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(stateColor))
stateStr = stateStyle.Render(fmt.Sprintf("[%s]", item.State))
}
b.WriteString(stateStr)
b.WriteString(" ")
// Priority
if item.Priority != model.PriorityNone {
var priorityStyle lipgloss.Style
switch item.Priority {
case model.PriorityA:
priorityStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
case model.PriorityB:
priorityStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
case model.PriorityC:
priorityStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
}
b.WriteString(priorityStyle.Render(fmt.Sprintf("[#%s] ", item.Priority)))
}
// Title
b.WriteString(item.Title)
// Tags
if len(item.Tags) > 0 {
b.WriteString(" ")
for _, tag := range item.Tags {
tagColor := m.config.GetTagColor(tag)
tagStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(tagColor))
b.WriteString(tagStyle.Render(fmt.Sprintf(":%s:", tag)))
}
}
// Effort
if item.Effort != "" {
effortStr := fmt.Sprintf(" (Effort: %s)", item.Effort)
effortStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple
b.WriteString(effortStyle.Render(effortStr))
}
// Clock status
if item.IsClockedIn() {
duration := item.GetCurrentClockDuration()
@ -554,23 +1038,60 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
if item.Scheduled != nil {
schedStr := fmt.Sprintf(" (Scheduled: %s)", parser.FormatOrgDate(*item.Scheduled))
if item.Scheduled.Before(now) {
b.WriteString(overdueStyle.Render(schedStr))
b.WriteString(m.styles.overdueStyle.Render(schedStr))
} else {
b.WriteString(scheduledStyle.Render(schedStr))
b.WriteString(m.styles.scheduledStyle.Render(schedStr))
}
}
if item.Deadline != nil {
deadlineStr := fmt.Sprintf(" (Deadline: %s)", parser.FormatOrgDate(*item.Deadline))
if item.Deadline.Before(now) {
b.WriteString(overdueStyle.Render(deadlineStr))
b.WriteString(m.styles.overdueStyle.Render(deadlineStr))
} else {
b.WriteString(scheduledStyle.Render(deadlineStr))
b.WriteString(m.styles.scheduledStyle.Render(deadlineStr))
}
}
line := b.String()
if isCursor {
return cursorStyle.Render(line)
return m.styles.cursorStyle.Render(line)
}
return line
}
// viewTagEdit renders the tag editing view
func (m uiModel) viewTagEdit() string {
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Edit Tags") + "\n\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)) + "\n\n")
}
content.WriteString(m.textinput.View() + "\n\n")
content.WriteString(m.styles.statusStyle.Render("Enter tags separated by colons (e.g., work:urgent:important)") + "\n")
content.WriteString(m.styles.statusStyle.Render("Leave empty to remove all tags") + "\n\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel") + "\n")
return content.String()
}
// 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()
}