Compare commits
25 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc00d6891 | ||
|
|
fce607e29d | ||
| c858e70d07 | |||
| 0b88465e21 | |||
| 8ff2b254a4 | |||
| 8ed20e48ff | |||
| 6b404cd722 | |||
| 2e9980e73c | |||
| 5eb672e0c9 | |||
| 5a6fede2d8 | |||
| aaa0ad0f55 | |||
| 23c7095477 | |||
| 015feb3637 | |||
| 2f98d8e0f1 | |||
| eb5f9a16ce | |||
| d45d8fd5c1 | |||
| 6e96d56e77 | |||
| 264f0aa54a | |||
| 3819ce0bce | |||
| 3c7b64417b | |||
| 43573a6e79 | |||
| 8f6ec4a79f | |||
| 097703beda | |||
| 9361e084e5 | |||
| 13e52e2880 |
11
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
## Description
|
||||
|
||||
|
||||
## Important note
|
||||
|
||||
The first commit should be prefixed with one of the following depending on the severity of the changes:
|
||||
|
||||
- `chore:` - non-code changes, such as typos in readme or pipeline changes.
|
||||
- `fix:` - for a small change like a bugfix or other minor things.
|
||||
- `feat:` - for a new feature.
|
||||
- `major:` - for a large refactor or breaking changes.
|
||||
175
.github/workflows/release.yml
vendored
Normal 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
|
After Width: | Height: | Size: 12 KiB |
BIN
.imgs/delete_prompt.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
.imgs/editing.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
.imgs/list_view.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
.imgs/priority_prompt.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
.imgs/settings_keybinds.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.imgs/settings_states.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
.imgs/settings_tags.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
21
LICENSE
Normal 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
|
|
@ -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
|
||||

|
||||
|
||||
### Editing notes
|
||||

|
||||
|
||||
### Prompts
|
||||

|
||||

|
||||

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

|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
|
@ -1,28 +1,101 @@
|
|||
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
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to ./todo.org if no file specified
|
||||
// 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 parsing org files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
// Single file mode (default)
|
||||
if filePath == "" {
|
||||
// Default to ./todo.org
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
|
||||
|
|
@ -32,14 +105,15 @@ func main() {
|
|||
}
|
||||
|
||||
// Parse the org file
|
||||
orgFile, err := parser.ParseOrgFile(filePath)
|
||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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*<([^>]+)>`)
|
||||
closedPattern = regexp.MustCompile(`CLOSED:\s*\[([^\]]+)\]`)
|
||||
clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`)
|
||||
drawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,14 +22,25 @@ const (
|
|||
modeCapture
|
||||
modeAddSubTask
|
||||
modeSetDeadline
|
||||
modeSetScheduled
|
||||
modeSetPriority
|
||||
modeSetEffort
|
||||
modeHelp
|
||||
modeSettings
|
||||
modeTagEdit
|
||||
modeRename
|
||||
)
|
||||
|
||||
type uiModel struct {
|
||||
orgFile *model.OrgFile
|
||||
cursor int
|
||||
scrollOffset int // Track the scroll position
|
||||
helpScroll int // Track scroll position in help mode
|
||||
mode viewMode
|
||||
help help.Model
|
||||
keys keyMap
|
||||
styles styleMap
|
||||
config *config.Config
|
||||
width int
|
||||
height int
|
||||
statusMsg string
|
||||
|
|
@ -37,9 +50,13 @@ type uiModel struct {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
// newKeyMapFromConfig creates a keyMap from configuration
|
||||
func newKeyMapFromConfig(cfg *config.Config) keyMap {
|
||||
kb := cfg.Keybindings
|
||||
|
||||
return keyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "k"),
|
||||
key.WithHelp("↑/k", "move up"),
|
||||
key.WithKeys(kb.Up...),
|
||||
key.WithHelp(formatKeyHelp(kb.Up), "move up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "j"),
|
||||
key.WithHelp("↓/j", "move down"),
|
||||
key.WithKeys(kb.Down...),
|
||||
key.WithHelp(formatKeyHelp(kb.Down), "move down"),
|
||||
),
|
||||
Left: key.NewBinding(
|
||||
key.WithKeys("left", "h"),
|
||||
key.WithHelp("←/h", "cycle state backward"),
|
||||
key.WithKeys(kb.Left...),
|
||||
key.WithHelp(formatKeyHelp(kb.Left), "cycle state backward"),
|
||||
),
|
||||
Right: key.NewBinding(
|
||||
key.WithKeys("right", "l"),
|
||||
key.WithHelp("→/l", "cycle state forward"),
|
||||
key.WithKeys(kb.Right...),
|
||||
key.WithHelp(formatKeyHelp(kb.Right), "cycle state forward"),
|
||||
),
|
||||
ShiftUp: key.NewBinding(
|
||||
key.WithKeys("shift+up"),
|
||||
key.WithHelp("shift+↑", "move item up"),
|
||||
key.WithKeys(kb.ShiftUp...),
|
||||
key.WithHelp(formatKeyHelp(kb.ShiftUp), "move item up"),
|
||||
),
|
||||
ShiftDown: key.NewBinding(
|
||||
key.WithKeys("shift+down"),
|
||||
key.WithHelp("shift+↓", "move item down"),
|
||||
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("t", " "),
|
||||
key.WithHelp("t/space", "cycle todo state"),
|
||||
key.WithKeys(kb.CycleState...),
|
||||
key.WithHelp(formatKeyHelp(kb.CycleState), "cycle todo state"),
|
||||
),
|
||||
ToggleFold: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "fold/unfold"),
|
||||
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("enter"),
|
||||
key.WithHelp("enter", "edit notes"),
|
||||
key.WithKeys(kb.EditNotes...),
|
||||
key.WithHelp(formatKeyHelp(kb.EditNotes), "edit notes"),
|
||||
),
|
||||
ToggleView: key.NewBinding(
|
||||
key.WithKeys("a"),
|
||||
key.WithHelp("a", "toggle agenda view"),
|
||||
key.WithKeys(kb.ToggleView...),
|
||||
key.WithHelp(formatKeyHelp(kb.ToggleView), "toggle agenda view"),
|
||||
),
|
||||
Capture: key.NewBinding(
|
||||
key.WithKeys("c"),
|
||||
key.WithHelp("c", "capture TODO"),
|
||||
key.WithKeys(kb.Capture...),
|
||||
key.WithHelp(formatKeyHelp(kb.Capture), "capture TODO"),
|
||||
),
|
||||
AddSubTask: key.NewBinding(
|
||||
key.WithKeys("s"),
|
||||
key.WithHelp("s", "add sub-task"),
|
||||
key.WithKeys(kb.AddSubTask...),
|
||||
key.WithHelp(formatKeyHelp(kb.AddSubTask), "add sub-task"),
|
||||
),
|
||||
Delete: key.NewBinding(
|
||||
key.WithKeys("shift+d"),
|
||||
key.WithHelp("shift+d", "delete item"),
|
||||
key.WithKeys(kb.Delete...),
|
||||
key.WithHelp(formatKeyHelp(kb.Delete), "delete item"),
|
||||
),
|
||||
Save: key.NewBinding(
|
||||
key.WithKeys("ctrl+s"),
|
||||
key.WithHelp("ctrl+s", "save"),
|
||||
key.WithKeys(kb.Save...),
|
||||
key.WithHelp(formatKeyHelp(kb.Save), "save"),
|
||||
),
|
||||
ToggleReorder: key.NewBinding(
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("r", "reorder mode"),
|
||||
key.WithKeys(kb.ToggleReorder...),
|
||||
key.WithHelp(formatKeyHelp(kb.ToggleReorder), "reorder mode"),
|
||||
),
|
||||
ClockIn: key.NewBinding(
|
||||
key.WithKeys("i"),
|
||||
key.WithHelp("i", "clock in"),
|
||||
key.WithKeys(kb.ClockIn...),
|
||||
key.WithHelp(formatKeyHelp(kb.ClockIn), "clock in"),
|
||||
),
|
||||
ClockOut: key.NewBinding(
|
||||
key.WithKeys("o"),
|
||||
key.WithHelp("o", "clock out"),
|
||||
key.WithKeys(kb.ClockOut...),
|
||||
key.WithHelp(formatKeyHelp(kb.ClockOut), "clock out"),
|
||||
),
|
||||
SetDeadline: key.NewBinding(
|
||||
key.WithKeys("d"),
|
||||
key.WithHelp("d", "set deadline"),
|
||||
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("?"),
|
||||
key.WithHelp("?", "toggle help"),
|
||||
key.WithKeys(kb.Help...),
|
||||
key.WithHelp(formatKeyHelp(kb.Help), "toggle help"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
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
|
|
@ -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
|
||||
}
|
||||
1014
internal/ui/modes.go
1038
internal/ui/settings.go
Normal 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)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
itemLines := 0
|
||||
// Build a map of item index to line count (for scrolling)
|
||||
itemLineCount := make([]int, len(items))
|
||||
for i, item := range items {
|
||||
if itemLines >= availableHeight {
|
||||
break // Don't render more items than fit
|
||||
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 {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
b.WriteString(dialog)
|
||||
|
||||
return b.String()
|
||||
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)))
|
||||
}
|
||||
}
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// 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 {
|
||||
// Check for start of PROPERTIES drawer
|
||||
if trimmed == ":PROPERTIES:" {
|
||||
inProperties = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for end of drawer
|
||||
if trimmed == ":END:" {
|
||||
if inLogbook {
|
||||
inLogbook = false
|
||||
continue
|
||||
}
|
||||
if inProperties {
|
||||
inProperties = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip lines inside LOGBOOK drawer
|
||||
if inLogbook {
|
||||
// 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 {
|
||||
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)
|
||||
highlightedLines := strings.Split(highlighted, "\n")
|
||||
result = append(result, highlightedLines...)
|
||||
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 {
|
||||
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)
|
||||
highlightedLines := strings.Split(highlighted, "\n")
|
||||
result = append(result, highlightedLines...)
|
||||
processedLines = strings.Split(highlighted, "\n")
|
||||
}
|
||||
result = append(result, processedLines...)
|
||||
}
|
||||
result = append(result, note) // Keep the delimiter visible
|
||||
codeLines = []string{}
|
||||
|
|
@ -451,16 +792,31 @@ func renderNotesWithHighlighting(notes []string) []string {
|
|||
// If in code block, accumulate lines
|
||||
if inCodeBlock {
|
||||
codeLines = append(codeLines, note)
|
||||
} else {
|
||||
// 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 {
|
||||
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)
|
||||
highlightedLines := strings.Split(highlighted, "\n")
|
||||
result = append(result, highlightedLines...)
|
||||
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()
|
||||
}
|
||||
|
|
|
|||