Compare commits
5 Commits
feat/time
...
5cbfc58aa3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cbfc58aa3 | |||
| 72a5c57faa | |||
| b47763034b | |||
| 5de3b646fc | |||
| a846d2f562 |
@ -1,12 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(go fmt:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(go vet:*)",
|
||||
"Bash(timew export:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,3 @@
|
||||
app.log
|
||||
test/taskchampion.sqlite3
|
||||
tasksquire
|
||||
test/*.sqlite3*
|
||||
|
||||
207
AGENTS.md
207
AGENTS.md
@ -1,207 +0,0 @@
|
||||
# Agent Development Guide for TaskSquire
|
||||
|
||||
This guide is for AI coding agents working on TaskSquire, a Go-based TUI (Terminal User Interface) for Taskwarrior.
|
||||
|
||||
## Project Overview
|
||||
|
||||
- **Language**: Go 1.22.2
|
||||
- **Architecture**: Model-View-Update (MVU) pattern using Bubble Tea framework
|
||||
- **Module**: `tasksquire`
|
||||
- **Main Dependencies**: Bubble Tea, Lip Gloss, Huh, Bubbles (Charm ecosystem)
|
||||
|
||||
## Build, Test, and Lint Commands
|
||||
|
||||
### Building and Running
|
||||
```bash
|
||||
# Run directly
|
||||
go run main.go
|
||||
|
||||
# Build binary
|
||||
go build -o tasksquire main.go
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Run tests for a specific package
|
||||
go test ./taskwarrior
|
||||
|
||||
# Run a single test
|
||||
go test ./taskwarrior -run TestTaskSquire_GetContext
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v ./taskwarrior
|
||||
|
||||
# Run tests with coverage
|
||||
go test -cover ./...
|
||||
```
|
||||
|
||||
### Linting and Formatting
|
||||
```bash
|
||||
# Format code (always run before committing)
|
||||
go fmt ./...
|
||||
|
||||
# Lint with golangci-lint (available via nix-shell)
|
||||
golangci-lint run
|
||||
|
||||
# Vet code for suspicious constructs
|
||||
go vet ./...
|
||||
|
||||
# Tidy dependencies
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
```bash
|
||||
# Enter Nix development shell (provides all tools)
|
||||
nix develop
|
||||
|
||||
# Or use direnv (automatically loads .envrc)
|
||||
direnv allow
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
tasksquire/
|
||||
├── main.go # Entry point: initializes TaskSquire, TimeSquire, and Bubble Tea
|
||||
├── common/ # Shared state, components interface, keymaps, styles, utilities
|
||||
├── pages/ # UI pages/views (report, taskEditor, timePage, pickers, etc.)
|
||||
├── components/ # Reusable UI components (input, table, timetable, picker)
|
||||
├── taskwarrior/ # Taskwarrior CLI wrapper, models, config
|
||||
├── timewarrior/ # Timewarrior integration, models, config
|
||||
└── test/ # Test fixtures and data
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports
|
||||
- **Standard Library First**: Group standard library imports, then third-party, then local
|
||||
- **Local Import Pattern**: Use `tasksquire/<package>` for internal imports
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"tasksquire/common"
|
||||
"tasksquire/taskwarrior"
|
||||
)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
- **Exported Types**: PascalCase (e.g., `TaskSquire`, `ReportPage`, `Common`)
|
||||
- **Unexported Fields**: camelCase (e.g., `configLocation`, `activeReport`, `pageStack`)
|
||||
- **Interfaces**: Follow Go convention, often ending in 'er' (e.g., `TaskWarrior`, `TimeWarrior`, `Component`)
|
||||
- **Constants**: PascalCase or SCREAMING_SNAKE_CASE for exported constants
|
||||
- **Test Functions**: `TestFunctionName` or `TestType_Method`
|
||||
|
||||
### Types and Interfaces
|
||||
- **Interface-Based Design**: Use interfaces for main abstractions (see `TaskWarrior`, `TimeWarrior`, `Component`)
|
||||
- **Struct Composition**: Embed common state (e.g., pages embed or reference `*common.Common`)
|
||||
- **Pointer Receivers**: Use pointer receivers for methods that modify state or for consistency
|
||||
- **Generic Types**: Use generics where appropriate (e.g., `Stack[T]` in `common/stack.go`)
|
||||
|
||||
### Error Handling
|
||||
- **Logging Over Panicking**: Use `log/slog` for structured logging, typically continue execution
|
||||
- **Error Returns**: Return errors from functions, don't log and return
|
||||
- **Context**: Errors are often logged with `slog.Error()` or `slog.Warn()` and execution continues
|
||||
```go
|
||||
// Typical pattern
|
||||
if err != nil {
|
||||
slog.Error("Failed to get tasks", "error", err)
|
||||
return nil // or continue with default behavior
|
||||
}
|
||||
```
|
||||
|
||||
### Concurrency and Thread Safety
|
||||
- **Mutex Protection**: Use `sync.Mutex` to protect shared state (see `TaskSquire.mu`)
|
||||
- **Lock Pattern**: Lock before operations, defer unlock
|
||||
```go
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
```
|
||||
|
||||
### Configuration and Environment
|
||||
- **Environment Variables**: Respect `TASKRC` and `TIMEWARRIORDB`
|
||||
- **Fallback Paths**: Check standard locations (`~/.taskrc`, `~/.config/task/taskrc`)
|
||||
- **Config Parsing**: Parse Taskwarrior config format manually (see `taskwarrior/config.go`)
|
||||
|
||||
### MVU Pattern (Bubble Tea)
|
||||
- **Components Implement**: `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() string`
|
||||
- **Custom Messages**: Define custom message types for inter-component communication
|
||||
- **Cmd Chaining**: Return commands from Init/Update to trigger async operations
|
||||
```go
|
||||
type MyMsg struct {
|
||||
data string
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case MyMsg:
|
||||
// Handle custom message
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Styling with Lip Gloss
|
||||
- **Centralized Styles**: Define styles in `common/styles.go`
|
||||
- **Theme Colors**: Parse colors from Taskwarrior config
|
||||
- **Reusable Styles**: Create style functions, not inline styles
|
||||
|
||||
### Testing
|
||||
- **Table-Driven Tests**: Use struct slices for test cases
|
||||
- **Test Setup**: Create helper functions like `TaskWarriorTestSetup()`
|
||||
- **Temp Directories**: Use `t.TempDir()` for isolated test environments
|
||||
- **Prep Functions**: Include `prep func()` in test cases for setup
|
||||
|
||||
### Documentation
|
||||
- **TODO Comments**: Mark future improvements with `// TODO: description`
|
||||
- **Package Comments**: Document package purpose at the top of main files
|
||||
- **Exported Functions**: Document exported functions, types, and methods
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Page Navigation
|
||||
- Pages pushed onto stack via `common.PushPage()`
|
||||
- Pop pages with `common.PopPage()`
|
||||
- Check for subpages with `common.HasSubpages()`
|
||||
|
||||
### Task Operations
|
||||
```go
|
||||
// Get tasks for a report
|
||||
tasks := ts.GetTasks(report, "filter", "args")
|
||||
|
||||
// Import/create task
|
||||
ts.ImportTask(&task)
|
||||
|
||||
// Mark task done
|
||||
ts.SetTaskDone(&task)
|
||||
|
||||
// Start/stop task
|
||||
ts.StartTask(&task)
|
||||
ts.StopTask(&task)
|
||||
```
|
||||
|
||||
### JSON Handling
|
||||
- Custom Marshal/Unmarshal for Task struct to handle UDAs (User Defined Attributes)
|
||||
- Use `json.RawMessage` for flexible field handling
|
||||
|
||||
## Key Files to Reference
|
||||
|
||||
- `common/component.go` - Component interface definition
|
||||
- `common/common.go` - Shared state container
|
||||
- `taskwarrior/taskwarrior.go` - TaskWarrior interface and implementation
|
||||
- `pages/main.go` - Main page router pattern
|
||||
- `taskwarrior/models.go` - Data model examples
|
||||
|
||||
## Development Notes
|
||||
|
||||
- **Logging**: Application logs to `app.log` in current directory
|
||||
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
|
||||
- **Color Parsing**: Custom color parsing from Taskwarrior config format
|
||||
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000
|
||||
236
CLAUDE.md
236
CLAUDE.md
@ -1,236 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
TaskSquire is a Go-based Terminal User Interface (TUI) for Taskwarrior and Timewarrior. It uses the Bubble Tea framework (Model-View-Update pattern) from the Charm ecosystem.
|
||||
|
||||
**Key Technologies:**
|
||||
- Go 1.22.2
|
||||
- Bubble Tea (MVU pattern)
|
||||
- Lip Gloss (styling)
|
||||
- Huh (forms)
|
||||
- Bubbles (components)
|
||||
|
||||
## Build and Development Commands
|
||||
|
||||
### Running and Building
|
||||
```bash
|
||||
# Run directly
|
||||
go run main.go
|
||||
|
||||
# Build binary
|
||||
go build -o tasksquire main.go
|
||||
|
||||
# Format code (always run before committing)
|
||||
go fmt ./...
|
||||
|
||||
# Vet code
|
||||
go vet ./...
|
||||
|
||||
# Tidy dependencies
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run tests for specific package
|
||||
go test ./taskwarrior
|
||||
|
||||
# Run single test
|
||||
go test ./taskwarrior -run TestTaskSquire_GetContext
|
||||
|
||||
# Run with verbose output
|
||||
go test -v ./...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./...
|
||||
```
|
||||
|
||||
### Linting
|
||||
```bash
|
||||
# Lint with golangci-lint (via nix-shell)
|
||||
golangci-lint run
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
The project uses Nix for development environment setup:
|
||||
```bash
|
||||
# Enter Nix development shell
|
||||
nix develop
|
||||
|
||||
# Or use direnv (automatically loads .envrc)
|
||||
direnv allow
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level Structure
|
||||
|
||||
TaskSquire follows the MVU (Model-View-Update) pattern with a component-based architecture:
|
||||
|
||||
1. **Entry Point (`main.go`)**: Initializes TaskSquire and TimeSquire, creates Common state container, and starts Bubble Tea program
|
||||
2. **Common State (`common/`)**: Shared state, components interface, keymaps, styles, and utilities
|
||||
3. **Pages (`pages/`)**: Top-level UI views (report, taskEditor, timePage, pickers)
|
||||
4. **Components (`components/`)**: Reusable UI widgets (input, table, timetable, picker)
|
||||
5. **Business Logic**:
|
||||
- `taskwarrior/`: Wraps Taskwarrior CLI, models, and config parsing
|
||||
- `timewarrior/`: Wraps Timewarrior CLI, models, and config parsing
|
||||
|
||||
### Component System
|
||||
|
||||
All UI elements implement the `Component` interface:
|
||||
```go
|
||||
type Component interface {
|
||||
tea.Model // Init(), Update(tea.Msg), View()
|
||||
SetSize(width int, height int)
|
||||
}
|
||||
```
|
||||
|
||||
Components can be composed hierarchically. The `Common` struct manages a page stack for navigation.
|
||||
|
||||
### Page Navigation Pattern
|
||||
|
||||
- **Main Page** (`pages/main.go`): Root page with tab switching between Tasks and Time views
|
||||
- **Page Stack**: Managed by `common.Common`, allows pushing/popping subpages
|
||||
- `common.PushPage(page)` - push a new page on top
|
||||
- `common.PopPage()` - return to previous page
|
||||
- `common.HasSubpages()` - check if subpages are active
|
||||
- **Tab Switching**: Only works at top level (when no subpages active)
|
||||
|
||||
### State Management
|
||||
|
||||
The `common.Common` struct acts as a shared state container:
|
||||
- `TW`: TaskWarrior interface for task operations
|
||||
- `TimeW`: TimeWarrior interface for time tracking
|
||||
- `Keymap`: Centralized key bindings
|
||||
- `Styles`: Centralized styling (parsed from Taskwarrior config)
|
||||
- `Udas`: User Defined Attributes from Taskwarrior config
|
||||
- `pageStack`: Stack-based page navigation
|
||||
|
||||
### Taskwarrior Integration
|
||||
|
||||
The `TaskWarrior` interface provides all task operations:
|
||||
- Task CRUD: `GetTasks()`, `ImportTask()`, `SetTaskDone()`
|
||||
- Task control: `StartTask()`, `StopTask()`, `DeleteTask()`
|
||||
- Context management: `GetContext()`, `GetContexts()`, `SetContext()`
|
||||
- Reports: `GetReport()`, `GetReports()`
|
||||
- Config parsing: Manual parsing of Taskwarrior config format
|
||||
|
||||
All Taskwarrior operations use `exec.Command()` to call the `task` CLI binary. Results are parsed from JSON output.
|
||||
|
||||
### Timewarrior Integration
|
||||
|
||||
The `TimeWarrior` interface provides time tracking operations:
|
||||
- Interval management: `GetIntervals()`, `ModifyInterval()`, `DeleteInterval()`
|
||||
- Tracking control: `StartTracking()`, `StopTracking()`, `ContinueTracking()`
|
||||
- Tag management: `GetTags()`, `GetTagCombinations()`
|
||||
- Utility: `FillInterval()`, `JoinInterval()`, `Undo()`
|
||||
|
||||
Similar to TaskWarrior, uses `exec.Command()` to call the `timew` CLI binary.
|
||||
|
||||
### Custom JSON Marshaling
|
||||
|
||||
The `Task` struct uses custom `MarshalJSON()` and `UnmarshalJSON()` to handle:
|
||||
- User Defined Attributes (UDAs) stored in `Udas map[string]any`
|
||||
- Dynamic field handling via `json.RawMessage`
|
||||
- Virtual tags (filtered from regular tags)
|
||||
|
||||
### Configuration and Environment
|
||||
|
||||
- **Taskwarrior Config**: Located via `TASKRC` env var, or fallback to `~/.taskrc` or `~/.config/task/taskrc`
|
||||
- **Timewarrior Config**: Located via `TIMEWARRIORDB` env var, or fallback to `~/.timewarrior/timewarrior.cfg`
|
||||
- **Config Parsing**: Custom parser in `taskwarrior/config.go` handles Taskwarrior's config format
|
||||
- **Theme Colors**: Extracted from Taskwarrior config and used in Lip Gloss styles
|
||||
|
||||
### Concurrency
|
||||
|
||||
- Both `TaskSquire` and `TimeSquire` use `sync.Mutex` to protect shared state
|
||||
- Lock pattern: `ts.mu.Lock()` followed by `defer ts.mu.Unlock()`
|
||||
- Operations are synchronous (no goroutines in typical flows)
|
||||
|
||||
### Logging
|
||||
|
||||
- Uses `log/slog` for structured logging
|
||||
- Logs written to `app.log` in current directory
|
||||
- Errors logged but execution typically continues (graceful degradation)
|
||||
- Log pattern: `slog.Error("message", "key", value)`
|
||||
|
||||
## Code Style and Patterns
|
||||
|
||||
### Import Organization
|
||||
Standard library first, then third-party, then local:
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"tasksquire/common"
|
||||
"tasksquire/taskwarrior"
|
||||
)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
- Exported types: `PascalCase` (e.g., `TaskSquire`, `ReportPage`)
|
||||
- Unexported fields: `camelCase` (e.g., `configLocation`, `activeReport`)
|
||||
- Interfaces: Often end in 'er' or describe capability (e.g., `TaskWarrior`, `TimeWarrior`, `Component`)
|
||||
|
||||
### Error Handling
|
||||
- Log errors with `slog.Error()` and continue execution
|
||||
- Don't panic unless fatal initialization error
|
||||
- Return errors from functions, don't log and return
|
||||
|
||||
### MVU Pattern in Bubble Tea
|
||||
Components follow the MVU pattern:
|
||||
- `Init() tea.Cmd`: Initialize and return commands for side effects
|
||||
- `Update(tea.Msg) (tea.Model, tea.Cmd)`: Handle messages, update state, return commands
|
||||
- `View() string`: Render UI as string
|
||||
|
||||
Custom messages for inter-component communication:
|
||||
```go
|
||||
type MyCustomMsg struct {
|
||||
data string
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case MyCustomMsg:
|
||||
// Handle custom message
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Styling with Lip Gloss
|
||||
- Centralized styles in `common/styles.go`
|
||||
- Theme colors parsed from Taskwarrior config
|
||||
- Create reusable style functions, not inline styles
|
||||
|
||||
### Testing Patterns
|
||||
- Table-driven tests with struct slices
|
||||
- Helper functions like `TaskWarriorTestSetup()`
|
||||
- Use `t.TempDir()` for isolated test environments
|
||||
- Include `prep func()` in test cases for setup
|
||||
|
||||
## Important Implementation Details
|
||||
|
||||
### Virtual Tags
|
||||
Taskwarrior has virtual tags (ACTIVE, BLOCKED, etc.) that are filtered out from regular tags. See the `virtualTags` map in `taskwarrior/taskwarrior.go`.
|
||||
|
||||
### Non-Standard Reports
|
||||
Some Taskwarrior reports require special handling (burndown, calendar, etc.). See `nonStandardReports` map.
|
||||
|
||||
### Timestamp Format
|
||||
Taskwarrior uses ISO 8601 format: `20060102T150405Z` (defined as `dtformat` constant)
|
||||
|
||||
### Color Parsing
|
||||
Custom color parsing from Taskwarrior config format in `common/styles.go`
|
||||
|
||||
### VSCode Debugging
|
||||
Launch configuration available for remote debugging on port 43000 (see `.vscode/launch.json`)
|
||||
@ -65,7 +65,3 @@ func (c *Common) PopPage() (Component, error) {
|
||||
component.SetSize(c.width, c.height)
|
||||
return component, nil
|
||||
}
|
||||
|
||||
func (c *Common) HasSubpages() bool {
|
||||
return !c.pageStack.IsEmpty()
|
||||
}
|
||||
|
||||
@ -6,33 +6,29 @@ import (
|
||||
|
||||
// Keymap is a collection of key bindings.
|
||||
type Keymap struct {
|
||||
Quit key.Binding
|
||||
Back key.Binding
|
||||
Ok key.Binding
|
||||
Delete key.Binding
|
||||
Input key.Binding
|
||||
Add key.Binding
|
||||
Edit key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Next key.Binding
|
||||
Prev key.Binding
|
||||
NextPage key.Binding
|
||||
PrevPage key.Binding
|
||||
SetReport key.Binding
|
||||
SetContext key.Binding
|
||||
SetProject key.Binding
|
||||
PickProjectTask key.Binding
|
||||
Select key.Binding
|
||||
Insert key.Binding
|
||||
Tag key.Binding
|
||||
Undo key.Binding
|
||||
Fill key.Binding
|
||||
StartStop key.Binding
|
||||
Join key.Binding
|
||||
ViewDetails key.Binding
|
||||
Quit key.Binding
|
||||
Back key.Binding
|
||||
Ok key.Binding
|
||||
Delete key.Binding
|
||||
Input key.Binding
|
||||
Add key.Binding
|
||||
Edit key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Next key.Binding
|
||||
Prev key.Binding
|
||||
NextPage key.Binding
|
||||
PrevPage key.Binding
|
||||
SetReport key.Binding
|
||||
SetContext key.Binding
|
||||
SetProject key.Binding
|
||||
Select key.Binding
|
||||
Insert key.Binding
|
||||
Tag key.Binding
|
||||
Undo key.Binding
|
||||
StartStop key.Binding
|
||||
}
|
||||
|
||||
// TODO: use config values for key bindings
|
||||
@ -129,11 +125,6 @@ func NewKeymap() *Keymap {
|
||||
key.WithHelp("p", "Set project"),
|
||||
),
|
||||
|
||||
PickProjectTask: key.NewBinding(
|
||||
key.WithKeys("P"),
|
||||
key.WithHelp("P", "Pick project task"),
|
||||
),
|
||||
|
||||
Select: key.NewBinding(
|
||||
key.WithKeys(" "),
|
||||
key.WithHelp("space", "Select"),
|
||||
@ -154,24 +145,9 @@ func NewKeymap() *Keymap {
|
||||
key.WithHelp("undo", "Undo"),
|
||||
),
|
||||
|
||||
Fill: key.NewBinding(
|
||||
key.WithKeys("f"),
|
||||
key.WithHelp("fill", "Fill gaps"),
|
||||
),
|
||||
|
||||
StartStop: key.NewBinding(
|
||||
key.WithKeys("s"),
|
||||
key.WithHelp("start/stop", "Start/Stop"),
|
||||
),
|
||||
|
||||
Join: key.NewBinding(
|
||||
key.WithKeys("J"),
|
||||
key.WithHelp("J", "Join with previous"),
|
||||
),
|
||||
|
||||
ViewDetails: key.NewBinding(
|
||||
key.WithKeys("v"),
|
||||
key.WithHelp("v", "view details"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,10 +38,3 @@ func (s *Stack[T]) Pop() (T, error) {
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Stack[T]) IsEmpty() bool {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
return len(s.items) == 0
|
||||
}
|
||||
|
||||
@ -27,10 +27,6 @@ type Styles struct {
|
||||
Form *huh.Theme
|
||||
TableStyle TableStyle
|
||||
|
||||
Tab lipgloss.Style
|
||||
ActiveTab lipgloss.Style
|
||||
TabBar lipgloss.Style
|
||||
|
||||
ColumnFocused lipgloss.Style
|
||||
ColumnBlurred lipgloss.Style
|
||||
ColumnInsert lipgloss.Style
|
||||
@ -75,19 +71,6 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
||||
|
||||
styles.Form = formTheme
|
||||
|
||||
styles.Tab = lipgloss.NewStyle().
|
||||
Padding(0, 1).
|
||||
Foreground(lipgloss.Color("240"))
|
||||
|
||||
styles.ActiveTab = styles.Tab.
|
||||
Foreground(lipgloss.Color("252")).
|
||||
Bold(true)
|
||||
|
||||
styles.TabBar = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
MarginBottom(1)
|
||||
|
||||
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
||||
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"tasksquire/taskwarrior"
|
||||
"tasksquire/timewarrior"
|
||||
)
|
||||
|
||||
// FindTaskByUUID queries Taskwarrior for a task with the given UUID.
|
||||
// Returns nil if not found.
|
||||
func FindTaskByUUID(tw taskwarrior.TaskWarrior, uuid string) *taskwarrior.Task {
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use empty report to query by UUID filter
|
||||
report := &taskwarrior.Report{Name: ""}
|
||||
tasks := tw.GetTasks(report, "uuid:"+uuid)
|
||||
|
||||
if len(tasks) > 0 {
|
||||
return tasks[0]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncIntervalToTask synchronizes a Timewarrior interval's state to the corresponding Taskwarrior task.
|
||||
// Action should be "start" or "stop".
|
||||
// This function is idempotent and handles edge cases gracefully.
|
||||
func SyncIntervalToTask(interval *timewarrior.Interval, tw taskwarrior.TaskWarrior, action string) {
|
||||
if interval == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract UUID from interval tags
|
||||
uuid := timewarrior.ExtractUUID(interval.Tags)
|
||||
if uuid == "" {
|
||||
slog.Debug("Interval has no UUID tag, skipping task sync",
|
||||
"intervalID", interval.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// Find corresponding task
|
||||
task := FindTaskByUUID(tw, uuid)
|
||||
if task == nil {
|
||||
slog.Warn("Task not found for UUID, skipping sync",
|
||||
"uuid", uuid)
|
||||
return
|
||||
}
|
||||
|
||||
// Perform sync action
|
||||
switch action {
|
||||
case "start":
|
||||
// Start task if it's pending (idempotent - taskwarrior handles already-started tasks)
|
||||
if task.Status == "pending" {
|
||||
slog.Info("Starting Taskwarrior task from interval",
|
||||
"uuid", uuid,
|
||||
"description", task.Description,
|
||||
"alreadyStarted", task.Start != "")
|
||||
tw.StartTask(task)
|
||||
} else {
|
||||
slog.Debug("Task not pending, skipping start",
|
||||
"uuid", uuid,
|
||||
"status", task.Status)
|
||||
}
|
||||
|
||||
case "stop":
|
||||
// Only stop if task is pending and currently started
|
||||
if task.Status == "pending" && task.Start != "" {
|
||||
slog.Info("Stopping Taskwarrior task from interval",
|
||||
"uuid", uuid,
|
||||
"description", task.Description)
|
||||
tw.StopTask(task)
|
||||
} else {
|
||||
slog.Debug("Task not started or not pending, skipping stop",
|
||||
"uuid", uuid,
|
||||
"status", task.Status,
|
||||
"hasStart", task.Start != "")
|
||||
}
|
||||
|
||||
default:
|
||||
slog.Error("Unknown sync action", "action", action)
|
||||
}
|
||||
}
|
||||
@ -1,293 +0,0 @@
|
||||
package autocomplete
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
type Autocomplete struct {
|
||||
input textinput.Model
|
||||
allSuggestions []string // All available suggestions (newest first)
|
||||
filteredSuggestions []string // Currently matching suggestions
|
||||
matchedIndexes [][]int // Matched character positions for each suggestion
|
||||
selectedIndex int // -1 = input focused, 0+ = suggestion selected
|
||||
showSuggestions bool // Whether to display suggestion box
|
||||
maxVisible int // Max suggestions to show
|
||||
minChars int // Min chars before showing suggestions
|
||||
focused bool
|
||||
width int
|
||||
placeholder string
|
||||
}
|
||||
|
||||
// New creates a new autocomplete component
|
||||
func New(suggestions []string, minChars int, maxVisible int) *Autocomplete {
|
||||
ti := textinput.New()
|
||||
ti.Width = 50
|
||||
|
||||
return &Autocomplete{
|
||||
input: ti,
|
||||
allSuggestions: suggestions,
|
||||
selectedIndex: -1,
|
||||
maxVisible: maxVisible,
|
||||
minChars: minChars,
|
||||
width: 50,
|
||||
}
|
||||
}
|
||||
|
||||
// SetValue sets the input value
|
||||
func (a *Autocomplete) SetValue(value string) {
|
||||
a.input.SetValue(value)
|
||||
a.updateFilteredSuggestions()
|
||||
}
|
||||
|
||||
// GetValue returns the current input value
|
||||
func (a *Autocomplete) GetValue() string {
|
||||
return a.input.Value()
|
||||
}
|
||||
|
||||
// Focus focuses the autocomplete input
|
||||
func (a *Autocomplete) Focus() {
|
||||
a.focused = true
|
||||
a.input.Focus()
|
||||
}
|
||||
|
||||
// Blur blurs the autocomplete input
|
||||
func (a *Autocomplete) Blur() {
|
||||
a.focused = false
|
||||
a.input.Blur()
|
||||
a.showSuggestions = false
|
||||
}
|
||||
|
||||
// SetPlaceholder sets the placeholder text
|
||||
func (a *Autocomplete) SetPlaceholder(placeholder string) {
|
||||
a.placeholder = placeholder
|
||||
a.input.Placeholder = placeholder
|
||||
}
|
||||
|
||||
// SetWidth sets the width of the autocomplete
|
||||
func (a *Autocomplete) SetWidth(width int) {
|
||||
a.width = width
|
||||
a.input.Width = width
|
||||
}
|
||||
|
||||
// SetMaxVisible sets the maximum number of visible suggestions
|
||||
func (a *Autocomplete) SetMaxVisible(max int) {
|
||||
a.maxVisible = max
|
||||
}
|
||||
|
||||
// SetMinChars sets the minimum characters required before showing suggestions
|
||||
func (a *Autocomplete) SetMinChars(min int) {
|
||||
a.minChars = min
|
||||
}
|
||||
|
||||
// SetSuggestions updates the available suggestions
|
||||
func (a *Autocomplete) SetSuggestions(suggestions []string) {
|
||||
a.allSuggestions = suggestions
|
||||
a.updateFilteredSuggestions()
|
||||
}
|
||||
|
||||
// HasSuggestions returns true if the autocomplete is currently showing suggestions
|
||||
func (a *Autocomplete) HasSuggestions() bool {
|
||||
return a.showSuggestions && len(a.filteredSuggestions) > 0
|
||||
}
|
||||
|
||||
// Init initializes the autocomplete
|
||||
func (a *Autocomplete) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
// Update handles messages for the autocomplete
|
||||
func (a *Autocomplete) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !a.focused {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
|
||||
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
||||
a.selectedIndex++
|
||||
if a.selectedIndex >= len(a.filteredSuggestions) {
|
||||
a.selectedIndex = 0
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
|
||||
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
||||
a.selectedIndex--
|
||||
if a.selectedIndex < 0 {
|
||||
a.selectedIndex = len(a.filteredSuggestions) - 1
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
if a.showSuggestions && a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
|
||||
// Accept selected suggestion
|
||||
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
|
||||
a.showSuggestions = false
|
||||
a.selectedIndex = -1
|
||||
return a, nil
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
||||
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
||||
// Accept first or selected suggestion
|
||||
if a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
|
||||
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
|
||||
} else {
|
||||
a.input.SetValue(a.filteredSuggestions[0])
|
||||
}
|
||||
a.showSuggestions = false
|
||||
a.selectedIndex = -1
|
||||
return a, nil
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
||||
if a.showSuggestions {
|
||||
a.showSuggestions = false
|
||||
a.selectedIndex = -1
|
||||
return a, nil
|
||||
}
|
||||
|
||||
default:
|
||||
// Handle regular text input
|
||||
prevValue := a.input.Value()
|
||||
a.input, cmd = a.input.Update(msg)
|
||||
|
||||
// Update suggestions if value changed
|
||||
if a.input.Value() != prevValue {
|
||||
a.updateFilteredSuggestions()
|
||||
}
|
||||
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
a.input, cmd = a.input.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
// View renders the autocomplete
|
||||
func (a *Autocomplete) View() string {
|
||||
// Input field
|
||||
inputView := a.input.View()
|
||||
|
||||
if !a.showSuggestions || len(a.filteredSuggestions) == 0 {
|
||||
return inputView
|
||||
}
|
||||
|
||||
// Suggestion box
|
||||
var suggestionViews []string
|
||||
for i, suggestion := range a.filteredSuggestions {
|
||||
if i >= a.maxVisible {
|
||||
break
|
||||
}
|
||||
|
||||
prefix := " "
|
||||
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||
|
||||
if i == a.selectedIndex {
|
||||
// Highlight selected suggestion
|
||||
baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12"))
|
||||
prefix = "→ "
|
||||
}
|
||||
|
||||
// Build suggestion with highlighted matched characters
|
||||
var rendered string
|
||||
if i < len(a.matchedIndexes) {
|
||||
rendered = a.renderWithHighlights(suggestion, a.matchedIndexes[i], i == a.selectedIndex)
|
||||
} else {
|
||||
rendered = suggestion
|
||||
}
|
||||
|
||||
suggestionViews = append(suggestionViews, baseStyle.Render(prefix+rendered))
|
||||
}
|
||||
|
||||
// Box style
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("8")).
|
||||
Width(a.width)
|
||||
|
||||
suggestionsBox := boxStyle.Render(
|
||||
lipgloss.JoinVertical(lipgloss.Left, suggestionViews...),
|
||||
)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, inputView, suggestionsBox)
|
||||
}
|
||||
|
||||
// renderWithHighlights renders a suggestion with matched characters highlighted
|
||||
func (a *Autocomplete) renderWithHighlights(str string, matchedIndexes []int, isSelected bool) string {
|
||||
if len(matchedIndexes) == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
matchedMap := make(map[int]bool)
|
||||
for _, idx := range matchedIndexes {
|
||||
matchedMap[idx] = true
|
||||
}
|
||||
|
||||
// Choose highlight style based on selection state
|
||||
var highlightStyle lipgloss.Style
|
||||
if isSelected {
|
||||
// When selected, use underline to distinguish from selection bold
|
||||
highlightStyle = lipgloss.NewStyle().Underline(true)
|
||||
} else {
|
||||
// When not selected, use bold and accent color
|
||||
highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||
}
|
||||
|
||||
// Build the string with highlights
|
||||
var result string
|
||||
runes := []rune(str)
|
||||
for i, r := range runes {
|
||||
if matchedMap[i] {
|
||||
result += highlightStyle.Render(string(r))
|
||||
} else {
|
||||
result += string(r)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// updateFilteredSuggestions filters suggestions based on current input
|
||||
func (a *Autocomplete) updateFilteredSuggestions() {
|
||||
value := a.input.Value()
|
||||
|
||||
// Only show if >= minChars
|
||||
if len(value) < a.minChars {
|
||||
a.showSuggestions = false
|
||||
a.filteredSuggestions = nil
|
||||
a.matchedIndexes = nil
|
||||
a.selectedIndex = -1
|
||||
return
|
||||
}
|
||||
|
||||
// Fuzzy match using sahilm/fuzzy
|
||||
matches := fuzzy.Find(value, a.allSuggestions)
|
||||
|
||||
var filtered []string
|
||||
var indexes [][]int
|
||||
for _, match := range matches {
|
||||
filtered = append(filtered, match.Str)
|
||||
indexes = append(indexes, match.MatchedIndexes)
|
||||
if len(filtered) >= a.maxVisible {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
a.filteredSuggestions = filtered
|
||||
a.matchedIndexes = indexes
|
||||
a.showSuggestions = len(filtered) > 0 && a.focused
|
||||
a.selectedIndex = -1 // Reset to input
|
||||
}
|
||||
@ -1,174 +0,0 @@
|
||||
package detailsviewer
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"tasksquire/common"
|
||||
"tasksquire/taskwarrior"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// DetailsViewer is a reusable component for displaying task details
|
||||
type DetailsViewer struct {
|
||||
common *common.Common
|
||||
viewport viewport.Model
|
||||
task *taskwarrior.Task
|
||||
focused bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// New creates a new DetailsViewer component
|
||||
func New(com *common.Common) *DetailsViewer {
|
||||
return &DetailsViewer{
|
||||
common: com,
|
||||
viewport: viewport.New(0, 0),
|
||||
focused: false,
|
||||
}
|
||||
}
|
||||
|
||||
// SetTask updates the task to display
|
||||
func (d *DetailsViewer) SetTask(task *taskwarrior.Task) {
|
||||
d.task = task
|
||||
d.updateContent()
|
||||
}
|
||||
|
||||
// Focus sets the component to focused state (for future interactivity)
|
||||
func (d *DetailsViewer) Focus() {
|
||||
d.focused = true
|
||||
}
|
||||
|
||||
// Blur sets the component to blurred state
|
||||
func (d *DetailsViewer) Blur() {
|
||||
d.focused = false
|
||||
}
|
||||
|
||||
// IsFocused returns whether the component is focused
|
||||
func (d *DetailsViewer) IsFocused() bool {
|
||||
return d.focused
|
||||
}
|
||||
|
||||
// SetSize implements common.Component
|
||||
func (d *DetailsViewer) SetSize(width, height int) {
|
||||
d.width = width
|
||||
d.height = height
|
||||
|
||||
// Account for border and padding (4 chars horizontal, 4 lines vertical)
|
||||
d.viewport.Width = max(width-4, 0)
|
||||
d.viewport.Height = max(height-4, 0)
|
||||
|
||||
// Refresh content with new width
|
||||
d.updateContent()
|
||||
}
|
||||
|
||||
// Init implements tea.Model
|
||||
func (d *DetailsViewer) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update implements tea.Model
|
||||
func (d *DetailsViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
d.viewport, cmd = d.viewport.Update(msg)
|
||||
return d, cmd
|
||||
}
|
||||
|
||||
// View implements tea.Model
|
||||
func (d *DetailsViewer) View() string {
|
||||
// Title bar
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("252"))
|
||||
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
|
||||
header := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
titleStyle.Render("Details"),
|
||||
" ",
|
||||
helpStyle.Render("(↑/↓ scroll, v/ESC close)"),
|
||||
)
|
||||
|
||||
// Container style
|
||||
containerStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
Padding(0, 1).
|
||||
Width(d.width).
|
||||
Height(d.height)
|
||||
|
||||
// Optional: highlight border when focused (for future interactivity)
|
||||
if d.focused {
|
||||
containerStyle = containerStyle.
|
||||
BorderForeground(lipgloss.Color("86"))
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
header,
|
||||
d.viewport.View(),
|
||||
)
|
||||
|
||||
return containerStyle.Render(content)
|
||||
}
|
||||
|
||||
// updateContent refreshes the viewport content based on current task
|
||||
func (d *DetailsViewer) updateContent() {
|
||||
if d.task == nil {
|
||||
d.viewport.SetContent("(No task selected)")
|
||||
return
|
||||
}
|
||||
|
||||
detailsValue := ""
|
||||
if details, ok := d.task.Udas["details"]; ok && details != nil {
|
||||
detailsValue = details.(string)
|
||||
}
|
||||
|
||||
if detailsValue == "" {
|
||||
d.viewport.SetContent("(No details for this task)")
|
||||
return
|
||||
}
|
||||
|
||||
// Render markdown with glamour
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(d.viewport.Width),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("failed to create markdown renderer", "error", err)
|
||||
// Fallback to plain text
|
||||
wrapped := lipgloss.NewStyle().
|
||||
Width(d.viewport.Width).
|
||||
Render(detailsValue)
|
||||
d.viewport.SetContent(wrapped)
|
||||
d.viewport.GotoTop()
|
||||
return
|
||||
}
|
||||
|
||||
rendered, err := renderer.Render(detailsValue)
|
||||
if err != nil {
|
||||
slog.Error("failed to render markdown", "error", err)
|
||||
// Fallback to plain text
|
||||
wrapped := lipgloss.NewStyle().
|
||||
Width(d.viewport.Width).
|
||||
Render(detailsValue)
|
||||
d.viewport.SetContent(wrapped)
|
||||
d.viewport.GotoTop()
|
||||
return
|
||||
}
|
||||
|
||||
d.viewport.SetContent(rendered)
|
||||
d.viewport.GotoTop()
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@ -637,11 +637,6 @@ func (m *MultiSelect) GetValue() any {
|
||||
return *m.value
|
||||
}
|
||||
|
||||
// IsFiltering returns true if the multi-select is currently filtering.
|
||||
func (m *MultiSelect) IsFiltering() bool {
|
||||
return m.filtering
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
|
||||
@ -13,7 +13,7 @@ type Item struct {
|
||||
text string
|
||||
}
|
||||
|
||||
func NewItem(text string) Item { return Item{text: text} }
|
||||
func NewItem(text string) Item { return Item{text: text} }
|
||||
func (i Item) Title() string { return i.text }
|
||||
func (i Item) Description() string { return "" }
|
||||
func (i Item) FilterValue() string { return i.text }
|
||||
@ -36,9 +36,7 @@ type Picker struct {
|
||||
onCreate func(string) tea.Cmd
|
||||
title string
|
||||
filterByDefault bool
|
||||
defaultValue string
|
||||
baseItems []list.Item
|
||||
focused bool
|
||||
}
|
||||
|
||||
type PickerOption func(*Picker)
|
||||
@ -55,30 +53,6 @@ func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithDefaultValue(value string) PickerOption {
|
||||
return func(p *Picker) {
|
||||
p.defaultValue = value
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Picker) Focus() tea.Cmd {
|
||||
p.focused = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Picker) Blur() tea.Cmd {
|
||||
p.focused = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Picker) GetValue() string {
|
||||
item := p.list.SelectedItem()
|
||||
if item == nil {
|
||||
return ""
|
||||
}
|
||||
return item.FilterValue()
|
||||
}
|
||||
|
||||
func New(
|
||||
c *common.Common,
|
||||
title string,
|
||||
@ -95,7 +69,6 @@ func New(
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(true)
|
||||
l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality
|
||||
|
||||
// Custom key for filtering (insert mode)
|
||||
l.KeyMap.Filter = key.NewBinding(
|
||||
@ -103,26 +76,12 @@ func New(
|
||||
key.WithHelp("i", "filter"),
|
||||
)
|
||||
|
||||
// Disable the quit key binding - we don't want Esc to quit the list
|
||||
// Esc should only cancel filtering mode
|
||||
l.KeyMap.Quit = key.NewBinding(
|
||||
key.WithKeys(), // No keys bound
|
||||
key.WithHelp("", ""),
|
||||
)
|
||||
|
||||
// Also disable force quit
|
||||
l.KeyMap.ForceQuit = key.NewBinding(
|
||||
key.WithKeys(), // No keys bound
|
||||
key.WithHelp("", ""),
|
||||
)
|
||||
|
||||
p := &Picker{
|
||||
common: c,
|
||||
list: l,
|
||||
itemProvider: itemProvider,
|
||||
onSelect: onSelect,
|
||||
title: title,
|
||||
focused: true,
|
||||
}
|
||||
|
||||
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
|
||||
@ -133,24 +92,8 @@ func New(
|
||||
opt(p)
|
||||
}
|
||||
|
||||
// If a default value is provided, don't start in filter mode
|
||||
if p.defaultValue != "" {
|
||||
p.filterByDefault = false
|
||||
}
|
||||
|
||||
if p.filterByDefault {
|
||||
// Manually trigger filter mode on the list so it doesn't require a global key press
|
||||
p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||
}
|
||||
|
||||
// Refresh items after entering filter mode to ensure they're visible
|
||||
p.Refresh()
|
||||
|
||||
// If a default value is provided, select the corresponding item
|
||||
if p.defaultValue != "" {
|
||||
p.SelectItemByFilterValue(p.defaultValue)
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
@ -160,22 +103,18 @@ func (p *Picker) Refresh() tea.Cmd {
|
||||
}
|
||||
|
||||
func (p *Picker) updateListItems() tea.Cmd {
|
||||
return p.updateListItemsWithFilter(p.list.FilterValue())
|
||||
}
|
||||
|
||||
func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd {
|
||||
items := make([]list.Item, 0, len(p.baseItems)+1)
|
||||
|
||||
// First add all base items
|
||||
items = append(items, p.baseItems...)
|
||||
items := p.baseItems
|
||||
filterVal := p.list.FilterValue()
|
||||
|
||||
if p.onCreate != nil && filterVal != "" {
|
||||
// Add the creation item at the end (bottom of the list)
|
||||
newItem := creationItem{
|
||||
text: "(new) " + filterVal,
|
||||
filter: filterVal,
|
||||
}
|
||||
items = append(items, newItem)
|
||||
newItems := make([]list.Item, len(items)+1)
|
||||
copy(newItems, items)
|
||||
newItems[len(items)] = newItem
|
||||
items = newItems
|
||||
}
|
||||
|
||||
return p.list.SetItems(items)
|
||||
@ -195,42 +134,27 @@ func (p *Picker) SetSize(width, height int) {
|
||||
}
|
||||
|
||||
func (p *Picker) Init() tea.Cmd {
|
||||
// Trigger list item update to ensure items are properly displayed,
|
||||
// especially when in filter mode with an empty filter
|
||||
return p.updateListItems()
|
||||
if p.filterByDefault {
|
||||
return func() tea.Msg {
|
||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !p.focused {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
// If filtering, update items with predicted filter before list processes the key
|
||||
// If filtering, let the list handle keys (including Enter to stop filtering)
|
||||
if p.list.FilterState() == list.Filtering {
|
||||
currentFilter := p.list.FilterValue()
|
||||
predictedFilter := currentFilter
|
||||
|
||||
// Predict what the filter will be after this key
|
||||
switch msg.Type {
|
||||
case tea.KeyRunes:
|
||||
predictedFilter = currentFilter + string(msg.Runes)
|
||||
case tea.KeyBackspace:
|
||||
if len(currentFilter) > 0 {
|
||||
predictedFilter = currentFilter[:len(currentFilter)-1]
|
||||
if key.Matches(msg, p.common.Keymap.Ok) {
|
||||
items := p.list.VisibleItems()
|
||||
if len(items) == 1 {
|
||||
return p, p.handleSelect(items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Update items with predicted filter before list processes the message
|
||||
if predictedFilter != currentFilter {
|
||||
preCmd := p.updateListItemsWithFilter(predictedFilter)
|
||||
cmds = append(cmds, preCmd)
|
||||
}
|
||||
|
||||
break // Pass to list.Update
|
||||
}
|
||||
|
||||
@ -244,10 +168,15 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
prevFilter := p.list.FilterValue()
|
||||
p.list, cmd = p.list.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return p, tea.Batch(cmds...)
|
||||
if p.list.FilterValue() != prevFilter {
|
||||
updateCmd := p.updateListItems()
|
||||
return p, tea.Batch(cmd, updateCmd)
|
||||
}
|
||||
|
||||
return p, cmd
|
||||
}
|
||||
|
||||
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
|
||||
@ -260,12 +189,7 @@ func (p *Picker) handleSelect(item list.Item) tea.Cmd {
|
||||
}
|
||||
|
||||
func (p *Picker) View() string {
|
||||
var title string
|
||||
if p.focused {
|
||||
title = p.common.Styles.Form.Focused.Title.Render(p.title)
|
||||
} else {
|
||||
title = p.common.Styles.Form.Blurred.Title.Render(p.title)
|
||||
}
|
||||
title := p.common.Styles.Form.Focused.Title.Render(p.title)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
|
||||
}
|
||||
|
||||
|
||||
@ -1,409 +0,0 @@
|
||||
package timestampeditor
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"tasksquire/common"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const (
|
||||
timeFormat = "20060102T150405Z" // Timewarrior format
|
||||
)
|
||||
|
||||
// Field represents which field is currently focused
|
||||
type Field int
|
||||
|
||||
const (
|
||||
TimeField Field = iota
|
||||
DateField
|
||||
)
|
||||
|
||||
// TimestampEditor is a component for editing timestamps with separate time and date fields
|
||||
type TimestampEditor struct {
|
||||
common *common.Common
|
||||
|
||||
// Current timestamp value
|
||||
timestamp time.Time
|
||||
isEmpty bool // Track if timestamp is unset
|
||||
|
||||
// UI state
|
||||
focused bool
|
||||
currentField Field
|
||||
|
||||
// Dimensions
|
||||
width int
|
||||
height int
|
||||
|
||||
// Title and description
|
||||
title string
|
||||
description string
|
||||
|
||||
// Validation
|
||||
validate func(time.Time) error
|
||||
err error
|
||||
}
|
||||
|
||||
// New creates a new TimestampEditor with no initial timestamp
|
||||
func New(com *common.Common) *TimestampEditor {
|
||||
return &TimestampEditor{
|
||||
common: com,
|
||||
timestamp: time.Time{}, // Zero time
|
||||
isEmpty: true, // Start empty
|
||||
focused: false,
|
||||
currentField: TimeField,
|
||||
validate: func(time.Time) error { return nil },
|
||||
}
|
||||
}
|
||||
|
||||
// Title sets the title of the timestamp editor
|
||||
func (t *TimestampEditor) Title(title string) *TimestampEditor {
|
||||
t.title = title
|
||||
return t
|
||||
}
|
||||
|
||||
// Description sets the description of the timestamp editor
|
||||
func (t *TimestampEditor) Description(description string) *TimestampEditor {
|
||||
t.description = description
|
||||
return t
|
||||
}
|
||||
|
||||
// Value sets the initial timestamp value
|
||||
func (t *TimestampEditor) Value(timestamp time.Time) *TimestampEditor {
|
||||
t.timestamp = timestamp
|
||||
t.isEmpty = timestamp.IsZero()
|
||||
return t
|
||||
}
|
||||
|
||||
// ValueFromString sets the initial timestamp from a timewarrior format string
|
||||
func (t *TimestampEditor) ValueFromString(s string) *TimestampEditor {
|
||||
if s == "" {
|
||||
t.timestamp = time.Time{}
|
||||
t.isEmpty = true
|
||||
return t
|
||||
}
|
||||
|
||||
parsed, err := time.Parse(timeFormat, s)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse timestamp", "error", err)
|
||||
t.timestamp = time.Time{}
|
||||
t.isEmpty = true
|
||||
return t
|
||||
}
|
||||
|
||||
t.timestamp = parsed.Local()
|
||||
t.isEmpty = false
|
||||
return t
|
||||
}
|
||||
|
||||
// GetValue returns the current timestamp
|
||||
func (t *TimestampEditor) GetValue() time.Time {
|
||||
return t.timestamp
|
||||
}
|
||||
|
||||
// GetValueString returns the timestamp in timewarrior format, or empty string if unset
|
||||
func (t *TimestampEditor) GetValueString() string {
|
||||
if t.isEmpty {
|
||||
return ""
|
||||
}
|
||||
return t.timestamp.UTC().Format(timeFormat)
|
||||
}
|
||||
|
||||
// Validate sets the validation function
|
||||
func (t *TimestampEditor) Validate(validate func(time.Time) error) *TimestampEditor {
|
||||
t.validate = validate
|
||||
return t
|
||||
}
|
||||
|
||||
// Error returns the validation error
|
||||
func (t *TimestampEditor) Error() error {
|
||||
return t.err
|
||||
}
|
||||
|
||||
// Focus focuses the timestamp editor
|
||||
func (t *TimestampEditor) Focus() tea.Cmd {
|
||||
t.focused = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Blur blurs the timestamp editor
|
||||
func (t *TimestampEditor) Blur() tea.Cmd {
|
||||
t.focused = false
|
||||
t.err = t.validate(t.timestamp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSize sets the size of the timestamp editor
|
||||
func (t *TimestampEditor) SetSize(width, height int) {
|
||||
t.width = width
|
||||
t.height = height
|
||||
}
|
||||
|
||||
// Init initializes the timestamp editor
|
||||
func (t *TimestampEditor) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the timestamp editor
|
||||
func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !t.focused {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
t.err = nil
|
||||
|
||||
switch msg.String() {
|
||||
// Navigation between fields
|
||||
case "h", "left":
|
||||
t.currentField = TimeField
|
||||
case "l", "right":
|
||||
t.currentField = DateField
|
||||
|
||||
// Time field adjustments (lowercase - 5 minutes)
|
||||
case "j":
|
||||
// Set current time on first edit if empty
|
||||
if t.isEmpty {
|
||||
t.setCurrentTime()
|
||||
}
|
||||
if t.currentField == TimeField {
|
||||
t.adjustTime(-5)
|
||||
} else {
|
||||
t.adjustDate(-1)
|
||||
}
|
||||
case "k":
|
||||
// Set current time on first edit if empty
|
||||
if t.isEmpty {
|
||||
t.setCurrentTime()
|
||||
}
|
||||
if t.currentField == TimeField {
|
||||
t.adjustTime(5)
|
||||
} else {
|
||||
t.adjustDate(1)
|
||||
}
|
||||
|
||||
// Time field adjustments (uppercase - 30 minutes) or date adjustments (week)
|
||||
case "J":
|
||||
// Set current time on first edit if empty
|
||||
if t.isEmpty {
|
||||
t.setCurrentTime()
|
||||
}
|
||||
if t.currentField == TimeField {
|
||||
t.adjustTime(-30)
|
||||
} else {
|
||||
t.adjustDate(-7)
|
||||
}
|
||||
case "K":
|
||||
// Set current time on first edit if empty
|
||||
if t.isEmpty {
|
||||
t.setCurrentTime()
|
||||
}
|
||||
if t.currentField == TimeField {
|
||||
t.adjustTime(30)
|
||||
} else {
|
||||
t.adjustDate(7)
|
||||
}
|
||||
|
||||
// Remove timestamp
|
||||
case "d":
|
||||
t.timestamp = time.Time{}
|
||||
t.isEmpty = true
|
||||
}
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// setCurrentTime sets the timestamp to the current time and marks it as not empty
|
||||
func (t *TimestampEditor) setCurrentTime() {
|
||||
now := time.Now()
|
||||
// Snap to nearest 5 minutes
|
||||
minute := now.Minute()
|
||||
remainder := minute % 5
|
||||
if remainder != 0 {
|
||||
if remainder < 3 {
|
||||
// Round down
|
||||
now = now.Add(-time.Duration(remainder) * time.Minute)
|
||||
} else {
|
||||
// Round up
|
||||
now = now.Add(time.Duration(5-remainder) * time.Minute)
|
||||
}
|
||||
}
|
||||
// Zero out seconds and nanoseconds
|
||||
t.timestamp = time.Date(
|
||||
now.Year(),
|
||||
now.Month(),
|
||||
now.Day(),
|
||||
now.Hour(),
|
||||
now.Minute(),
|
||||
0, 0,
|
||||
now.Location(),
|
||||
)
|
||||
t.isEmpty = false
|
||||
}
|
||||
|
||||
// adjustTime adjusts the time by the given number of minutes and snaps to nearest 5 minutes
|
||||
func (t *TimestampEditor) adjustTime(minutes int) {
|
||||
// Add the minutes
|
||||
t.timestamp = t.timestamp.Add(time.Duration(minutes) * time.Minute)
|
||||
|
||||
// Snap to nearest 5 minutes
|
||||
minute := t.timestamp.Minute()
|
||||
remainder := minute % 5
|
||||
if remainder != 0 {
|
||||
if remainder < 3 {
|
||||
// Round down
|
||||
t.timestamp = t.timestamp.Add(-time.Duration(remainder) * time.Minute)
|
||||
} else {
|
||||
// Round up
|
||||
t.timestamp = t.timestamp.Add(time.Duration(5-remainder) * time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
// Zero out seconds and nanoseconds
|
||||
t.timestamp = time.Date(
|
||||
t.timestamp.Year(),
|
||||
t.timestamp.Month(),
|
||||
t.timestamp.Day(),
|
||||
t.timestamp.Hour(),
|
||||
t.timestamp.Minute(),
|
||||
0, 0,
|
||||
t.timestamp.Location(),
|
||||
)
|
||||
}
|
||||
|
||||
// adjustDate adjusts the date by the given number of days
|
||||
func (t *TimestampEditor) adjustDate(days int) {
|
||||
t.timestamp = t.timestamp.AddDate(0, 0, days)
|
||||
}
|
||||
|
||||
// View renders the timestamp editor
|
||||
func (t *TimestampEditor) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
styles := t.getStyles()
|
||||
|
||||
// Render title if present
|
||||
if t.title != "" {
|
||||
sb.WriteString(styles.title.Render(t.title))
|
||||
if t.err != nil {
|
||||
sb.WriteString(styles.errorIndicator.String())
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Render description if present
|
||||
if t.description != "" {
|
||||
sb.WriteString(styles.description.Render(t.description))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Render the time and date fields side by side
|
||||
var timeStr, dateStr string
|
||||
if t.isEmpty {
|
||||
timeStr = "--:--"
|
||||
dateStr = "--- ----------"
|
||||
} else {
|
||||
timeStr = t.timestamp.Format("15:04")
|
||||
dateStr = t.timestamp.Format("Mon 2006-01-02")
|
||||
}
|
||||
|
||||
var timeField, dateField string
|
||||
if t.currentField == TimeField {
|
||||
timeField = styles.selectedField.Render(timeStr)
|
||||
dateField = styles.unselectedField.Render(dateStr)
|
||||
} else {
|
||||
timeField = styles.unselectedField.Render(timeStr)
|
||||
dateField = styles.selectedField.Render(dateStr)
|
||||
}
|
||||
|
||||
fieldsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeField, " ", dateField)
|
||||
sb.WriteString(fieldsRow)
|
||||
|
||||
return styles.base.Render(sb.String())
|
||||
}
|
||||
|
||||
// getHelpText returns the help text based on the current field
|
||||
func (t *TimestampEditor) getHelpText() string {
|
||||
if t.currentField == TimeField {
|
||||
return "h/l: switch field • j/k: ±5min • J/K: ±30min • d: remove"
|
||||
}
|
||||
return "h/l: switch field • j/k: ±1day • J/K: ±1week • d: remove"
|
||||
}
|
||||
|
||||
// Styles for the timestamp editor
|
||||
type timestampEditorStyles struct {
|
||||
base lipgloss.Style
|
||||
title lipgloss.Style
|
||||
description lipgloss.Style
|
||||
errorIndicator lipgloss.Style
|
||||
selectedField lipgloss.Style
|
||||
unselectedField lipgloss.Style
|
||||
help lipgloss.Style
|
||||
}
|
||||
|
||||
// getStyles returns the styles for the timestamp editor
|
||||
func (t *TimestampEditor) getStyles() timestampEditorStyles {
|
||||
theme := t.common.Styles.Form
|
||||
var styles timestampEditorStyles
|
||||
|
||||
if t.focused {
|
||||
styles.base = lipgloss.NewStyle()
|
||||
styles.title = theme.Focused.Title
|
||||
styles.description = theme.Focused.Description
|
||||
styles.errorIndicator = theme.Focused.ErrorIndicator
|
||||
styles.selectedField = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Padding(0, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("12"))
|
||||
styles.unselectedField = lipgloss.NewStyle().
|
||||
Padding(0, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("8"))
|
||||
styles.help = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("8")).
|
||||
Italic(true)
|
||||
} else {
|
||||
styles.base = lipgloss.NewStyle()
|
||||
styles.title = theme.Blurred.Title
|
||||
styles.description = theme.Blurred.Description
|
||||
styles.errorIndicator = theme.Blurred.ErrorIndicator
|
||||
styles.selectedField = lipgloss.NewStyle().
|
||||
Padding(0, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("8"))
|
||||
styles.unselectedField = lipgloss.NewStyle().
|
||||
Padding(0, 2).
|
||||
Border(lipgloss.HiddenBorder())
|
||||
styles.help = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("8"))
|
||||
}
|
||||
|
||||
return styles
|
||||
}
|
||||
|
||||
// Skip returns whether the timestamp editor should be skipped
|
||||
func (t *TimestampEditor) Skip() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Zoom returns whether the timestamp editor should be zoomed
|
||||
func (t *TimestampEditor) Zoom() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// KeyBinds returns the key bindings for the timestamp editor
|
||||
func (t *TimestampEditor) KeyBinds() []key.Binding {
|
||||
return []key.Binding{
|
||||
t.common.Keymap.Left,
|
||||
t.common.Keymap.Right,
|
||||
t.common.Keymap.Up,
|
||||
t.common.Keymap.Down,
|
||||
}
|
||||
}
|
||||
@ -330,17 +330,11 @@ func (m *Model) UpdateViewport() {
|
||||
}
|
||||
|
||||
// SelectedRow returns the selected row.
|
||||
// Returns nil if cursor is on a gap row or out of bounds.
|
||||
func (m Model) SelectedRow() Row {
|
||||
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Don't return gap rows as selected
|
||||
if m.rows[m.cursor].IsGap {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.rows[m.cursor]
|
||||
}
|
||||
|
||||
@ -394,61 +388,15 @@ func (m Model) Cursor() int {
|
||||
}
|
||||
|
||||
// SetCursor sets the cursor position in the table.
|
||||
// Skips gap rows by moving to the nearest non-gap row.
|
||||
func (m *Model) SetCursor(n int) {
|
||||
m.cursor = clamp(n, 0, len(m.rows)-1)
|
||||
|
||||
// Skip gap rows - try moving down first, then up
|
||||
if m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
|
||||
// Try moving down to find non-gap
|
||||
found := false
|
||||
for i := m.cursor; i < len(m.rows); i++ {
|
||||
if !m.rows[i].IsGap {
|
||||
m.cursor = i
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// If not found down, try moving up
|
||||
if !found {
|
||||
for i := m.cursor; i >= 0; i-- {
|
||||
if !m.rows[i].IsGap {
|
||||
m.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.UpdateViewport()
|
||||
}
|
||||
|
||||
// MoveUp moves the selection up by any number of rows.
|
||||
// It can not go above the first row. Skips gap rows.
|
||||
// It can not go above the first row.
|
||||
func (m *Model) MoveUp(n int) {
|
||||
originalCursor := m.cursor
|
||||
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
||||
|
||||
// Skip gap rows
|
||||
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
|
||||
m.cursor--
|
||||
}
|
||||
|
||||
// If we went past the beginning, find the first non-gap row
|
||||
if m.cursor < 0 {
|
||||
for i := 0; i < len(m.rows); i++ {
|
||||
if !m.rows[i].IsGap {
|
||||
m.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no non-gap row found, restore original cursor
|
||||
if m.cursor < 0 || (m.cursor < len(m.rows) && m.rows[m.cursor].IsGap) {
|
||||
m.cursor = originalCursor
|
||||
}
|
||||
|
||||
switch {
|
||||
case m.start == 0:
|
||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
|
||||
@ -461,31 +409,9 @@ func (m *Model) MoveUp(n int) {
|
||||
}
|
||||
|
||||
// MoveDown moves the selection down by any number of rows.
|
||||
// It can not go below the last row. Skips gap rows.
|
||||
// It can not go below the last row.
|
||||
func (m *Model) MoveDown(n int) {
|
||||
originalCursor := m.cursor
|
||||
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
|
||||
|
||||
// Skip gap rows
|
||||
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
|
||||
m.cursor++
|
||||
}
|
||||
|
||||
// If we went past the end, find the last non-gap row
|
||||
if m.cursor >= len(m.rows) {
|
||||
for i := len(m.rows) - 1; i >= 0; i-- {
|
||||
if !m.rows[i].IsGap {
|
||||
m.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no non-gap row found, restore original cursor
|
||||
if m.cursor >= len(m.rows) || (m.cursor >= 0 && m.rows[m.cursor].IsGap) {
|
||||
m.cursor = originalCursor
|
||||
}
|
||||
|
||||
m.UpdateViewport()
|
||||
|
||||
switch {
|
||||
@ -526,16 +452,6 @@ func (m Model) headersView() string {
|
||||
}
|
||||
|
||||
func (m *Model) renderRow(r int) string {
|
||||
// Special rendering for gap rows
|
||||
if m.rows[r].IsGap {
|
||||
gapText := m.rows[r].GetString("gap_display")
|
||||
gapStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
Align(lipgloss.Center).
|
||||
Width(m.Width())
|
||||
return gapStyle.Render(gapText)
|
||||
}
|
||||
|
||||
var s = make([]string, 0, len(m.cols))
|
||||
for i, col := range m.cols {
|
||||
if m.cols[i].Width <= 0 {
|
||||
|
||||
38
go.mod
38
go.mod
@ -1,51 +1,39 @@
|
||||
module tasksquire
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.12
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.18.0
|
||||
github.com/charmbracelet/bubbletea v0.26.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/huh v0.4.2
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/mattn/go-runewidth v0.0.16
|
||||
github.com/sahilm/fuzzy v0.1.1
|
||||
golang.org/x/term v0.31.0
|
||||
github.com/charmbracelet/lipgloss v0.11.0
|
||||
github.com/mattn/go-runewidth v0.0.15
|
||||
golang.org/x/term v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/ansi v0.1.2 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a // indirect
|
||||
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/charmbracelet/x/input v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/term v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.1.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
)
|
||||
|
||||
75
go.sum
75
go.sum
@ -1,55 +1,33 @@
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
||||
github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40=
|
||||
github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM=
|
||||
github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
|
||||
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
|
||||
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
|
||||
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 h1:6Rw/MHf+Rm7wlUr+euFyaGw1fNKeZPKVh4gkFHUjFsY=
|
||||
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7/go.mod h1:UZyEz89i6+jVx2oW3lgmIsVDNr7qzaKuvPVoSdwqDR0=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4=
|
||||
github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0=
|
||||
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
|
||||
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
|
||||
github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg=
|
||||
github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@ -59,18 +37,16 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@ -79,22 +55,15 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
|
||||
@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
###############################################################################
|
||||
#
|
||||
# Copyright 2016 - 2021, 2023, Gothenburg Bit Factory
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# https://www.opensource.org/licenses/mit-license.php
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Hook should extract all the following for use as Timewarrior tags:
|
||||
# UUID
|
||||
# Project
|
||||
# Tags
|
||||
# Description
|
||||
# UDAs
|
||||
|
||||
try:
|
||||
input_stream = sys.stdin.buffer
|
||||
except AttributeError:
|
||||
input_stream = sys.stdin
|
||||
|
||||
|
||||
def extract_tags_from(json_obj):
|
||||
# Extract attributes for use as tags.
|
||||
tags = [json_obj['description']]
|
||||
|
||||
# Add UUID with prefix for reliable task linking
|
||||
if 'uuid' in json_obj:
|
||||
tags.append('uuid:' + json_obj['uuid'])
|
||||
|
||||
# Add project with prefix for separate column display
|
||||
if 'project' in json_obj:
|
||||
tags.append('project:' + json_obj['project'])
|
||||
|
||||
if 'tags' in json_obj:
|
||||
if type(json_obj['tags']) is str:
|
||||
# Usage of tasklib (e.g. in taskpirate) converts the tag list into a string
|
||||
# If this is the case, convert it back into a list first
|
||||
# See https://github.com/tbabej/taskpirate/issues/11
|
||||
task_tags = [tag for tag in json_obj['tags'].split(',') if tag != 'next']
|
||||
tags.extend(task_tags)
|
||||
else:
|
||||
# Filter out the 'next' tag
|
||||
task_tags = [tag for tag in json_obj['tags'] if tag != 'next']
|
||||
tags.extend(task_tags)
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def extract_annotation_from(json_obj):
|
||||
|
||||
if 'annotations' not in json_obj:
|
||||
return '\'\''
|
||||
|
||||
return json_obj['annotations'][0]['description']
|
||||
|
||||
|
||||
def main(old, new):
|
||||
|
||||
start_or_stop = ''
|
||||
|
||||
# Started task.
|
||||
if 'start' in new and 'start' not in old:
|
||||
start_or_stop = 'start'
|
||||
|
||||
# Stopped task.
|
||||
elif ('start' not in new or 'end' in new) and 'start' in old:
|
||||
start_or_stop = 'stop'
|
||||
|
||||
if start_or_stop:
|
||||
tags = extract_tags_from(new)
|
||||
|
||||
subprocess.call(['timew', start_or_stop] + tags + [':yes'])
|
||||
|
||||
# Modifications to task other than start/stop
|
||||
elif 'start' in new and 'start' in old:
|
||||
old_tags = extract_tags_from(old)
|
||||
new_tags = extract_tags_from(new)
|
||||
|
||||
if old_tags != new_tags:
|
||||
subprocess.call(['timew', 'untag', '@1'] + old_tags + [':yes'])
|
||||
subprocess.call(['timew', 'tag', '@1'] + new_tags + [':yes'])
|
||||
|
||||
old_annotation = extract_annotation_from(old)
|
||||
new_annotation = extract_annotation_from(new)
|
||||
|
||||
if old_annotation != new_annotation:
|
||||
subprocess.call(['timew', 'annotate', '@1', new_annotation])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
old = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
|
||||
new = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
|
||||
print(json.dumps(new))
|
||||
main(old, new)
|
||||
@ -5,18 +5,14 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type MainPage struct {
|
||||
common *common.Common
|
||||
activePage common.Component
|
||||
|
||||
taskPage common.Component
|
||||
timePage common.Component
|
||||
currentTab int
|
||||
width int
|
||||
height int
|
||||
taskPage common.Component
|
||||
timePage common.Component
|
||||
}
|
||||
|
||||
func NewMainPage(common *common.Common) *MainPage {
|
||||
@ -28,7 +24,6 @@ func NewMainPage(common *common.Common) *MainPage {
|
||||
m.timePage = NewTimePage(common)
|
||||
|
||||
m.activePage = m.taskPage
|
||||
m.currentTab = 0
|
||||
|
||||
return m
|
||||
}
|
||||
@ -42,39 +37,16 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.common.SetSize(msg.Width, msg.Height)
|
||||
|
||||
tabHeight := lipgloss.Height(m.renderTabBar())
|
||||
contentHeight := msg.Height - tabHeight
|
||||
if contentHeight < 0 {
|
||||
contentHeight = 0
|
||||
}
|
||||
|
||||
newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
|
||||
activePage, cmd := m.activePage.Update(newMsg)
|
||||
m.activePage = activePage.(common.Component)
|
||||
return m, cmd
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Only handle tab key for page switching when at the top level (no subpages active)
|
||||
if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
|
||||
if key.Matches(msg, m.common.Keymap.Next) {
|
||||
if m.activePage == m.taskPage {
|
||||
m.activePage = m.timePage
|
||||
m.currentTab = 1
|
||||
} else {
|
||||
m.activePage = m.taskPage
|
||||
m.currentTab = 0
|
||||
}
|
||||
|
||||
tabHeight := lipgloss.Height(m.renderTabBar())
|
||||
contentHeight := m.height - tabHeight
|
||||
if contentHeight < 0 {
|
||||
contentHeight = 0
|
||||
}
|
||||
m.activePage.SetSize(m.width, contentHeight)
|
||||
|
||||
// Re-size the new active page just in case
|
||||
m.activePage.SetSize(m.common.Width(), m.common.Height())
|
||||
// Trigger a refresh/init on switch? Maybe not needed if we keep state.
|
||||
// But we might want to refresh data.
|
||||
return m, m.activePage.Init()
|
||||
@ -87,22 +59,6 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *MainPage) renderTabBar() string {
|
||||
var tabs []string
|
||||
headers := []string{"Tasks", "Time"}
|
||||
|
||||
for i, header := range headers {
|
||||
style := m.common.Styles.Tab
|
||||
if m.currentTab == i {
|
||||
style = m.common.Styles.ActiveTab
|
||||
}
|
||||
tabs = append(tabs, style.Render(header))
|
||||
}
|
||||
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
|
||||
}
|
||||
|
||||
func (m *MainPage) View() string {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
|
||||
return m.activePage.View()
|
||||
}
|
||||
|
||||
@ -82,7 +82,3 @@ func doTick() tea.Cmd {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
type TaskPickedMsg struct {
|
||||
Task *taskwarrior.Task
|
||||
}
|
||||
|
||||
@ -1,465 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"tasksquire/common"
|
||||
"tasksquire/components/picker"
|
||||
"tasksquire/taskwarrior"
|
||||
"tasksquire/timewarrior"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type ProjectTaskPickerPage struct {
|
||||
common *common.Common
|
||||
|
||||
// Both pickers visible simultaneously
|
||||
projectPicker *picker.Picker
|
||||
taskPicker *picker.Picker
|
||||
selectedProject string
|
||||
selectedTask *taskwarrior.Task
|
||||
|
||||
// Focus tracking: 0 = project picker, 1 = task picker
|
||||
focusedPicker int
|
||||
}
|
||||
|
||||
type projectTaskPickerProjectSelectedMsg struct {
|
||||
project string
|
||||
}
|
||||
|
||||
type projectTaskPickerTaskSelectedMsg struct {
|
||||
task *taskwarrior.Task
|
||||
}
|
||||
|
||||
func NewProjectTaskPickerPage(com *common.Common) *ProjectTaskPickerPage {
|
||||
p := &ProjectTaskPickerPage{
|
||||
common: com,
|
||||
focusedPicker: 0,
|
||||
}
|
||||
|
||||
// Create project picker
|
||||
projectItemProvider := func() []list.Item {
|
||||
projects := com.TW.GetProjects()
|
||||
items := make([]list.Item, 0, len(projects))
|
||||
|
||||
for _, proj := range projects {
|
||||
items = append(items, picker.NewItem(proj))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
projectOnSelect := func(item list.Item) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return projectTaskPickerProjectSelectedMsg{project: item.FilterValue()}
|
||||
}
|
||||
}
|
||||
|
||||
p.projectPicker = picker.New(
|
||||
com,
|
||||
"Projects",
|
||||
projectItemProvider,
|
||||
projectOnSelect,
|
||||
)
|
||||
|
||||
// Initialize with the first project's tasks
|
||||
projects := com.TW.GetProjects()
|
||||
if len(projects) > 0 {
|
||||
p.selectedProject = projects[0]
|
||||
p.createTaskPicker(projects[0])
|
||||
} else {
|
||||
// No projects - create empty task picker
|
||||
p.createTaskPicker("")
|
||||
}
|
||||
|
||||
p.SetSize(com.Width(), com.Height())
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *ProjectTaskPickerPage) createTaskPicker(project string) {
|
||||
// Build filters for tasks
|
||||
filters := []string{"+track", "status:pending"}
|
||||
|
||||
if project != "" {
|
||||
// Tasks in the selected project
|
||||
filters = append(filters, "project:"+project)
|
||||
}
|
||||
|
||||
taskItemProvider := func() []list.Item {
|
||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
||||
items := make([]list.Item, 0, len(tasks))
|
||||
|
||||
for i := range tasks {
|
||||
// Just use the description as the item text
|
||||
// picker.NewItem creates a simple item with title and filter value
|
||||
items = append(items, picker.NewItem(tasks[i].Description))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
taskOnSelect := func(item list.Item) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Find the task by description
|
||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
||||
for _, task := range tasks {
|
||||
if task.Description == item.FilterValue() {
|
||||
// tasks is already []*Task, so task is already *Task
|
||||
return projectTaskPickerTaskSelectedMsg{task: task}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
title := "Tasks with +track"
|
||||
if project != "" {
|
||||
title = fmt.Sprintf("Tasks: %s", project)
|
||||
}
|
||||
|
||||
p.taskPicker = picker.New(
|
||||
p.common,
|
||||
title,
|
||||
taskItemProvider,
|
||||
taskOnSelect,
|
||||
picker.WithFilterByDefault(false), // Start in list mode, not filter mode
|
||||
)
|
||||
}
|
||||
|
||||
func (p *ProjectTaskPickerPage) Init() tea.Cmd {
|
||||
// Focus the project picker initially
|
||||
p.projectPicker.Focus()
|
||||
return tea.Batch(p.projectPicker.Init(), p.taskPicker.Init())
|
||||
}
|
||||
|
||||
func (p *ProjectTaskPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.SetSize(msg.Width, msg.Height)
|
||||
|
||||
case projectTaskPickerProjectSelectedMsg:
|
||||
// Project selected - update task picker
|
||||
p.selectedProject = msg.project
|
||||
p.createTaskPicker(msg.project)
|
||||
// Move focus to task picker
|
||||
p.projectPicker.Blur()
|
||||
p.taskPicker.Focus()
|
||||
p.focusedPicker = 1
|
||||
p.SetSize(p.common.Width(), p.common.Height())
|
||||
return p, p.taskPicker.Init()
|
||||
|
||||
case projectTaskPickerTaskSelectedMsg:
|
||||
// Task selected - emit TaskPickedMsg and return to parent
|
||||
p.selectedTask = msg.task
|
||||
model, err := p.common.PopPage()
|
||||
if err != nil {
|
||||
return p, tea.Quit
|
||||
}
|
||||
return model, func() tea.Msg {
|
||||
return TaskPickedMsg{Task: p.selectedTask}
|
||||
}
|
||||
|
||||
case UpdatedTasksMsg:
|
||||
// Task was edited - refresh the task list and recreate the task picker
|
||||
if p.selectedProject != "" {
|
||||
p.createTaskPicker(p.selectedProject)
|
||||
p.SetSize(p.common.Width(), p.common.Height())
|
||||
// Keep the task picker focused
|
||||
p.taskPicker.Focus()
|
||||
p.focusedPicker = 1
|
||||
return p, p.taskPicker.Init()
|
||||
}
|
||||
return p, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Check if the focused picker is in filtering mode BEFORE handling any keys
|
||||
var focusedPickerFiltering bool
|
||||
if p.focusedPicker == 0 {
|
||||
focusedPickerFiltering = p.projectPicker.IsFiltering()
|
||||
} else {
|
||||
focusedPickerFiltering = p.taskPicker.IsFiltering()
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, p.common.Keymap.Back):
|
||||
// If the focused picker is filtering, let it handle the escape key to dismiss the filter
|
||||
// and don't exit the page
|
||||
if focusedPickerFiltering {
|
||||
// Don't handle the Back key - let it fall through to the picker
|
||||
break
|
||||
}
|
||||
// Exit picker completely
|
||||
model, err := p.common.PopPage()
|
||||
if err != nil {
|
||||
return p, tea.Quit
|
||||
}
|
||||
return model, BackCmd
|
||||
|
||||
case key.Matches(msg, p.common.Keymap.Add):
|
||||
// Don't handle 'a' if focused picker is filtering - let the picker handle it for typing
|
||||
if focusedPickerFiltering {
|
||||
break
|
||||
}
|
||||
// Create new task with selected project and track tag pre-filled
|
||||
newTask := taskwarrior.NewTask()
|
||||
newTask.Project = p.selectedProject
|
||||
newTask.Tags = []string{"track"}
|
||||
|
||||
// Open task editor with pre-populated task
|
||||
taskEditor := NewTaskEditorPage(p.common, newTask)
|
||||
p.common.PushPage(p)
|
||||
return taskEditor, taskEditor.Init()
|
||||
|
||||
case key.Matches(msg, p.common.Keymap.Edit):
|
||||
// Don't handle 'e' if focused picker is filtering - let the picker handle it for typing
|
||||
if focusedPickerFiltering {
|
||||
break
|
||||
}
|
||||
// Edit task when task picker is focused and a task is selected
|
||||
if p.focusedPicker == 1 && p.selectedProject != "" {
|
||||
// Get the currently highlighted task
|
||||
selectedItemText := p.taskPicker.GetValue()
|
||||
if selectedItemText != "" {
|
||||
// Find the task by description
|
||||
filters := []string{"+track", "status:pending"}
|
||||
filters = append(filters, "project:"+p.selectedProject)
|
||||
|
||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
||||
for _, task := range tasks {
|
||||
if task.Description == selectedItemText {
|
||||
// Found the task - open editor
|
||||
p.selectedTask = task
|
||||
taskEditor := NewTaskEditorPage(p.common, *task)
|
||||
p.common.PushPage(p)
|
||||
return taskEditor, taskEditor.Init()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
|
||||
case key.Matches(msg, p.common.Keymap.Tag):
|
||||
// Don't handle 't' if focused picker is filtering - let the picker handle it for typing
|
||||
if focusedPickerFiltering {
|
||||
break
|
||||
}
|
||||
// Open time editor with task pre-filled when task picker is focused
|
||||
if p.focusedPicker == 1 && p.selectedProject != "" {
|
||||
// Get the currently highlighted task
|
||||
selectedItemText := p.taskPicker.GetValue()
|
||||
if selectedItemText != "" {
|
||||
// Find the task by description
|
||||
filters := []string{"+track", "status:pending"}
|
||||
filters = append(filters, "project:"+p.selectedProject)
|
||||
|
||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
||||
for _, task := range tasks {
|
||||
if task.Description == selectedItemText {
|
||||
// Found the task - create new interval with task pre-filled
|
||||
interval := createIntervalFromTask(task)
|
||||
|
||||
// Open time editor with pre-populated interval
|
||||
timeEditor := NewTimeEditorPage(p.common, interval)
|
||||
p.common.PushPage(p)
|
||||
return timeEditor, timeEditor.Init()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
|
||||
case key.Matches(msg, p.common.Keymap.Next):
|
||||
// Tab: switch focus between pickers
|
||||
if p.focusedPicker == 0 {
|
||||
p.projectPicker.Blur()
|
||||
p.taskPicker.Focus()
|
||||
p.focusedPicker = 1
|
||||
} else {
|
||||
p.taskPicker.Blur()
|
||||
p.projectPicker.Focus()
|
||||
p.focusedPicker = 0
|
||||
}
|
||||
return p, nil
|
||||
|
||||
case key.Matches(msg, p.common.Keymap.Prev):
|
||||
// Shift+Tab: switch focus between pickers (reverse)
|
||||
if p.focusedPicker == 1 {
|
||||
p.taskPicker.Blur()
|
||||
p.projectPicker.Focus()
|
||||
p.focusedPicker = 0
|
||||
} else {
|
||||
p.projectPicker.Blur()
|
||||
p.taskPicker.Focus()
|
||||
p.focusedPicker = 1
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update the focused picker
|
||||
var cmd tea.Cmd
|
||||
if p.focusedPicker == 0 {
|
||||
// Track the previous project selection
|
||||
previousProject := p.selectedProject
|
||||
|
||||
_, cmd = p.projectPicker.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Check if the highlighted project changed
|
||||
currentProject := p.projectPicker.GetValue()
|
||||
if currentProject != previousProject && currentProject != "" {
|
||||
// Update the selected project and refresh task picker
|
||||
p.selectedProject = currentProject
|
||||
p.createTaskPicker(currentProject)
|
||||
p.SetSize(p.common.Width(), p.common.Height())
|
||||
cmds = append(cmds, p.taskPicker.Init())
|
||||
}
|
||||
} else {
|
||||
_, cmd = p.taskPicker.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *ProjectTaskPickerPage) View() string {
|
||||
// Render both pickers (they handle their own focused/blurred styling)
|
||||
projectView := p.projectPicker.View()
|
||||
taskView := p.taskPicker.View()
|
||||
|
||||
// Create distinct styling for focused vs blurred pickers
|
||||
var projectStyled, taskStyled string
|
||||
|
||||
if p.focusedPicker == 0 {
|
||||
// Project picker is focused
|
||||
projectStyled = lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderForeground(lipgloss.Color("6")). // Cyan for focused
|
||||
Padding(0, 1).
|
||||
Render(projectView)
|
||||
|
||||
taskStyled = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")). // Gray for blurred
|
||||
Padding(0, 1).
|
||||
Render(taskView)
|
||||
} else {
|
||||
// Task picker is focused
|
||||
projectStyled = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")). // Gray for blurred
|
||||
Padding(0, 1).
|
||||
Render(projectView)
|
||||
|
||||
taskStyled = lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderForeground(lipgloss.Color("6")). // Cyan for focused
|
||||
Padding(0, 1).
|
||||
Render(taskView)
|
||||
}
|
||||
|
||||
// Layout side by side if width permits, otherwise stack vertically
|
||||
var content string
|
||||
if p.common.Width() >= 100 {
|
||||
// Side by side layout
|
||||
content = lipgloss.JoinHorizontal(lipgloss.Top, projectStyled, " ", taskStyled)
|
||||
} else {
|
||||
// Vertical stack layout
|
||||
content = lipgloss.JoinVertical(lipgloss.Left, projectStyled, "", taskStyled)
|
||||
}
|
||||
|
||||
// Add help text
|
||||
helpText := "Tab/Shift+Tab: switch focus • Enter: select • a: add task • e: edit • t: track time • Esc: cancel"
|
||||
helpStyled := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Italic(true).
|
||||
Render(helpText)
|
||||
|
||||
fullContent := lipgloss.JoinVertical(lipgloss.Left, content, "", helpStyled)
|
||||
|
||||
return lipgloss.Place(
|
||||
p.common.Width(),
|
||||
p.common.Height(),
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
fullContent,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *ProjectTaskPickerPage) SetSize(width, height int) {
|
||||
p.common.SetSize(width, height)
|
||||
|
||||
// Calculate sizes based on layout
|
||||
var projectWidth, taskWidth, listHeight int
|
||||
|
||||
if width >= 100 {
|
||||
// Side by side layout
|
||||
projectWidth = 30
|
||||
taskWidth = width - projectWidth - 10 // Account for margins and padding
|
||||
if taskWidth > 60 {
|
||||
taskWidth = 60
|
||||
}
|
||||
} else {
|
||||
// Vertical stack layout
|
||||
projectWidth = width - 8
|
||||
taskWidth = width - 8
|
||||
if projectWidth > 60 {
|
||||
projectWidth = 60
|
||||
}
|
||||
if taskWidth > 60 {
|
||||
taskWidth = 60
|
||||
}
|
||||
}
|
||||
|
||||
// Height for each picker
|
||||
listHeight = height - 10 // Account for help text and padding
|
||||
if listHeight > 25 {
|
||||
listHeight = 25
|
||||
}
|
||||
if listHeight < 10 {
|
||||
listHeight = 10
|
||||
}
|
||||
|
||||
if p.projectPicker != nil {
|
||||
p.projectPicker.SetSize(projectWidth, listHeight)
|
||||
}
|
||||
|
||||
if p.taskPicker != nil {
|
||||
p.taskPicker.SetSize(taskWidth, listHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// createIntervalFromTask creates a new time interval pre-filled with task metadata
|
||||
func createIntervalFromTask(task *taskwarrior.Task) *timewarrior.Interval {
|
||||
interval := timewarrior.NewInterval()
|
||||
|
||||
// Set start time to now (UTC format)
|
||||
interval.Start = time.Now().UTC().Format("20060102T150405Z")
|
||||
// Leave End empty for active tracking
|
||||
interval.End = ""
|
||||
|
||||
// Build tags from task metadata
|
||||
tags := []string{}
|
||||
|
||||
// Add UUID tag for task linking
|
||||
if task.Uuid != "" {
|
||||
tags = append(tags, "uuid:"+task.Uuid)
|
||||
}
|
||||
|
||||
// Add project tag
|
||||
if task.Project != "" {
|
||||
tags = append(tags, "project:"+task.Project)
|
||||
}
|
||||
|
||||
// Add existing task tags (excluding virtual tags)
|
||||
tags = append(tags, task.Tags...)
|
||||
|
||||
interval.Tags = tags
|
||||
|
||||
return interval
|
||||
}
|
||||
151
pages/report.go
151
pages/report.go
@ -3,13 +3,13 @@ package pages
|
||||
|
||||
import (
|
||||
"tasksquire/common"
|
||||
"tasksquire/components/detailsviewer"
|
||||
"tasksquire/components/table"
|
||||
"tasksquire/taskwarrior"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
|
||||
// "github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type ReportPage struct {
|
||||
@ -25,10 +25,6 @@ type ReportPage struct {
|
||||
|
||||
taskTable table.Model
|
||||
|
||||
// Details panel state
|
||||
detailsPanelActive bool
|
||||
detailsViewer *detailsviewer.DetailsViewer
|
||||
|
||||
subpage common.Component
|
||||
}
|
||||
|
||||
@ -42,54 +38,25 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
||||
// }
|
||||
|
||||
p := &ReportPage{
|
||||
common: com,
|
||||
activeReport: report,
|
||||
activeContext: com.TW.GetActiveContext(),
|
||||
activeProject: "",
|
||||
taskTable: table.New(com),
|
||||
detailsPanelActive: false,
|
||||
detailsViewer: detailsviewer.New(com),
|
||||
common: com,
|
||||
activeReport: report,
|
||||
activeContext: com.TW.GetActiveContext(),
|
||||
activeProject: "",
|
||||
taskTable: table.New(com),
|
||||
}
|
||||
|
||||
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
|
||||
p.subpage.Init()
|
||||
p.common.PushPage(p)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *ReportPage) SetSize(width int, height int) {
|
||||
p.common.SetSize(width, height)
|
||||
|
||||
baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize()
|
||||
baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize()
|
||||
|
||||
var tableHeight int
|
||||
if p.detailsPanelActive {
|
||||
// Allocate 60% for table, 40% for details panel
|
||||
// Minimum 5 lines for details, minimum 10 lines for table
|
||||
detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
||||
tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
||||
|
||||
// Set component size (component handles its own border/padding)
|
||||
p.detailsViewer.SetSize(baseWidth, detailsHeight)
|
||||
} else {
|
||||
tableHeight = baseHeight
|
||||
}
|
||||
|
||||
p.taskTable.SetWidth(baseWidth)
|
||||
p.taskTable.SetHeight(tableHeight)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
p.taskTable.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
|
||||
p.taskTable.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
|
||||
}
|
||||
|
||||
func (p *ReportPage) Init() tea.Cmd {
|
||||
@ -119,23 +86,9 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case UpdateProjectMsg:
|
||||
p.activeProject = string(msg)
|
||||
cmds = append(cmds, p.getTasks())
|
||||
case TaskPickedMsg:
|
||||
if msg.Task != nil && msg.Task.Status == "pending" {
|
||||
p.common.TW.StopActiveTasks()
|
||||
p.common.TW.StartTask(msg.Task)
|
||||
}
|
||||
cmds = append(cmds, p.getTasks())
|
||||
case UpdatedTasksMsg:
|
||||
cmds = append(cmds, p.getTasks())
|
||||
case tea.KeyMsg:
|
||||
// Handle ESC when details panel is active
|
||||
if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) {
|
||||
p.detailsPanelActive = false
|
||||
p.detailsViewer.Blur()
|
||||
p.SetSize(p.common.Width(), p.common.Height())
|
||||
return p, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, p.common.Keymap.Quit):
|
||||
return p, tea.Quit
|
||||
@ -170,11 +123,6 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmd := p.subpage.Init()
|
||||
p.common.PushPage(p)
|
||||
return p.subpage, cmd
|
||||
case key.Matches(msg, p.common.Keymap.PickProjectTask):
|
||||
p.subpage = NewProjectTaskPickerPage(p.common)
|
||||
cmd := p.subpage.Init()
|
||||
p.common.PushPage(p)
|
||||
return p.subpage, cmd
|
||||
case key.Matches(msg, p.common.Keymap.Tag):
|
||||
if p.selectedTask != nil {
|
||||
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
||||
@ -193,70 +141,34 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
||||
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
|
||||
if p.selectedTask.Start == "" {
|
||||
p.common.TW.StopActiveTasks()
|
||||
p.common.TW.StartTask(p.selectedTask)
|
||||
} else {
|
||||
p.common.TW.StopTask(p.selectedTask)
|
||||
}
|
||||
return p, p.getTasks()
|
||||
}
|
||||
case key.Matches(msg, p.common.Keymap.ViewDetails):
|
||||
if p.selectedTask != nil {
|
||||
// Toggle details panel
|
||||
p.detailsPanelActive = !p.detailsPanelActive
|
||||
if p.detailsPanelActive {
|
||||
p.detailsViewer.SetTask(p.selectedTask)
|
||||
p.detailsViewer.Focus()
|
||||
} else {
|
||||
p.detailsViewer.Blur()
|
||||
}
|
||||
p.SetSize(p.common.Width(), p.common.Height())
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
p.taskTable, cmd = p.taskTable.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Route keyboard messages to details viewer when panel is active
|
||||
if p.detailsPanelActive {
|
||||
var viewerCmd tea.Cmd
|
||||
var viewerModel tea.Model
|
||||
viewerModel, viewerCmd = p.detailsViewer.Update(msg)
|
||||
p.detailsViewer = viewerModel.(*detailsviewer.DetailsViewer)
|
||||
cmds = append(cmds, viewerCmd)
|
||||
if p.tasks != nil && len(p.tasks) > 0 {
|
||||
p.selectedTask = p.tasks[p.taskTable.Cursor()]
|
||||
} else {
|
||||
// Route to table when details panel not active
|
||||
p.taskTable, cmd = p.taskTable.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
if p.tasks != nil && len(p.tasks) > 0 {
|
||||
p.selectedTask = p.tasks[p.taskTable.Cursor()]
|
||||
} else {
|
||||
p.selectedTask = nil
|
||||
}
|
||||
p.selectedTask = nil
|
||||
}
|
||||
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *ReportPage) View() string {
|
||||
// return p.common.Styles.Main.Render(p.taskTable.View()) + "\n"
|
||||
if p.tasks == nil || len(p.tasks) == 0 {
|
||||
return p.common.Styles.Base.Render("No tasks found")
|
||||
}
|
||||
|
||||
tableView := p.taskTable.View()
|
||||
|
||||
if !p.detailsPanelActive {
|
||||
return tableView
|
||||
}
|
||||
|
||||
// Combine table and details panel vertically
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
tableView,
|
||||
p.detailsViewer.View(),
|
||||
)
|
||||
return p.taskTable.View()
|
||||
}
|
||||
|
||||
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||
@ -277,27 +189,13 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||
selected = len(tasks) - 1
|
||||
}
|
||||
|
||||
// Calculate proper dimensions based on whether details panel is active
|
||||
baseHeight := p.common.Height() - p.common.Styles.Base.GetVerticalFrameSize()
|
||||
baseWidth := p.common.Width() - p.common.Styles.Base.GetHorizontalFrameSize()
|
||||
|
||||
var tableHeight int
|
||||
if p.detailsPanelActive {
|
||||
// Allocate 60% for table, 40% for details panel
|
||||
// Minimum 5 lines for details, minimum 10 lines for table
|
||||
detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
||||
tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
||||
} else {
|
||||
tableHeight = baseHeight
|
||||
}
|
||||
|
||||
p.taskTable = table.New(
|
||||
p.common,
|
||||
table.WithReport(p.activeReport),
|
||||
table.WithTasks(tasks),
|
||||
table.WithFocused(true),
|
||||
table.WithWidth(baseWidth),
|
||||
table.WithHeight(tableHeight),
|
||||
table.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
|
||||
table.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()-1),
|
||||
table.WithStyles(p.common.Styles.TableStyle),
|
||||
)
|
||||
|
||||
@ -309,11 +207,6 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||
} else {
|
||||
p.taskTable.SetCursor(len(p.tasks) - 1)
|
||||
}
|
||||
|
||||
// Refresh details content if panel is active
|
||||
if p.detailsPanelActive && p.selectedTask != nil {
|
||||
p.detailsViewer.SetTask(p.selectedTask)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReportPage) getTasks() tea.Cmd {
|
||||
|
||||
@ -8,8 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"tasksquire/components/input"
|
||||
"tasksquire/components/picker"
|
||||
"tasksquire/components/timestampeditor"
|
||||
"tasksquire/taskwarrior"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
@ -42,8 +40,6 @@ type TaskEditorPage struct {
|
||||
area int
|
||||
areaPicker *areaPicker
|
||||
areas []area
|
||||
|
||||
infoViewport viewport.Model
|
||||
}
|
||||
|
||||
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
||||
@ -59,7 +55,7 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
|
||||
tagOptions := p.common.TW.GetTags()
|
||||
|
||||
p.areas = []area{
|
||||
NewTaskEdit(p.common, &p.task, p.task.Uuid == ""),
|
||||
NewTaskEdit(p.common, &p.task),
|
||||
NewTagEdit(p.common, &p.task.Tags, tagOptions),
|
||||
NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
|
||||
NewDetailsEdit(p.common, &p.task),
|
||||
@ -71,11 +67,6 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
|
||||
|
||||
p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"})
|
||||
|
||||
p.infoViewport = viewport.New(0, 0)
|
||||
if p.task.Uuid != "" {
|
||||
p.infoViewport.SetContent(p.common.TW.GetInformation(&p.task))
|
||||
}
|
||||
|
||||
p.columnCursor = 1
|
||||
if p.task.Uuid == "" {
|
||||
p.mode = modeInsert
|
||||
@ -102,20 +93,10 @@ func (p *TaskEditorPage) SetSize(width, height int) {
|
||||
} else {
|
||||
p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
|
||||
}
|
||||
|
||||
p.infoViewport.Width = width - p.colWidth - p.common.Styles.ColumnFocused.GetHorizontalFrameSize()*2 - 5
|
||||
if p.infoViewport.Width < 0 {
|
||||
p.infoViewport.Width = 0
|
||||
}
|
||||
p.infoViewport.Height = p.colHeight
|
||||
}
|
||||
|
||||
func (p *TaskEditorPage) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for _, a := range p.areas {
|
||||
cmds = append(cmds, a.Init())
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@ -128,20 +109,12 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
p.mode = mode(msg)
|
||||
case prevColumnMsg:
|
||||
p.columnCursor--
|
||||
maxCols := 2
|
||||
if p.task.Uuid != "" {
|
||||
maxCols = 3
|
||||
}
|
||||
if p.columnCursor < 0 {
|
||||
p.columnCursor = maxCols - 1
|
||||
p.columnCursor = len(p.areas) - 1
|
||||
}
|
||||
case nextColumnMsg:
|
||||
p.columnCursor++
|
||||
maxCols := 2
|
||||
if p.task.Uuid != "" {
|
||||
maxCols = 3
|
||||
}
|
||||
if p.columnCursor >= maxCols {
|
||||
if p.columnCursor > len(p.areas)-1 {
|
||||
p.columnCursor = 0
|
||||
}
|
||||
case prevAreaMsg:
|
||||
@ -192,26 +165,20 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
picker, cmd := p.areaPicker.Update(msg)
|
||||
p.areaPicker = picker.(*areaPicker)
|
||||
return p, cmd
|
||||
} else if p.columnCursor == 1 {
|
||||
} else {
|
||||
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
||||
p.areas[p.area] = model.(area)
|
||||
return p, cmd
|
||||
} else if p.columnCursor == 2 {
|
||||
p.infoViewport.LineUp(1)
|
||||
return p, nil
|
||||
}
|
||||
case key.Matches(msg, p.common.Keymap.Down):
|
||||
if p.columnCursor == 0 {
|
||||
picker, cmd := p.areaPicker.Update(msg)
|
||||
p.areaPicker = picker.(*areaPicker)
|
||||
return p, cmd
|
||||
} else if p.columnCursor == 1 {
|
||||
} else {
|
||||
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
||||
p.areas[p.area] = model.(area)
|
||||
return p, cmd
|
||||
} else if p.columnCursor == 2 {
|
||||
p.infoViewport.LineDown(1)
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -244,31 +211,25 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
picker, cmd := p.areaPicker.Update(msg)
|
||||
p.areaPicker = picker.(*areaPicker)
|
||||
return p, cmd
|
||||
} else if p.columnCursor == 1 {
|
||||
} else {
|
||||
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
||||
p.areas[p.area] = model.(area)
|
||||
return p, cmd
|
||||
}
|
||||
return p, nil
|
||||
case key.Matches(msg, p.common.Keymap.Next):
|
||||
if p.columnCursor == 0 {
|
||||
picker, cmd := p.areaPicker.Update(msg)
|
||||
p.areaPicker = picker.(*areaPicker)
|
||||
return p, cmd
|
||||
} else if p.columnCursor == 1 {
|
||||
} else {
|
||||
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
||||
p.areas[p.area] = model.(area)
|
||||
return p, cmd
|
||||
}
|
||||
return p, nil
|
||||
case key.Matches(msg, p.common.Keymap.Ok):
|
||||
isFiltering := p.areas[p.area].IsFiltering()
|
||||
model, cmd := p.areas[p.area].Update(msg)
|
||||
if p.area != 3 {
|
||||
p.areas[p.area] = model.(area)
|
||||
if isFiltering {
|
||||
return p, cmd
|
||||
}
|
||||
return p, tea.Batch(cmd, nextField())
|
||||
}
|
||||
return p, cmd
|
||||
@ -279,10 +240,6 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
picker, cmd := p.areaPicker.Update(msg)
|
||||
p.areaPicker = picker.(*areaPicker)
|
||||
return p, cmd
|
||||
} else if p.columnCursor == 2 {
|
||||
var cmd tea.Cmd
|
||||
p.infoViewport, cmd = p.infoViewport.Update(msg)
|
||||
return p, cmd
|
||||
} else {
|
||||
model, cmd := p.areas[p.area].Update(msg)
|
||||
p.areas[p.area] = model.(area)
|
||||
@ -295,31 +252,29 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (p *TaskEditorPage) View() string {
|
||||
var focusedStyle, blurredStyle lipgloss.Style
|
||||
if p.mode == modeInsert {
|
||||
focusedStyle = p.common.Styles.ColumnInsert
|
||||
focusedStyle = p.common.Styles.ColumnInsert.Width(p.colWidth).Height(p.colHeight)
|
||||
} else {
|
||||
focusedStyle = p.common.Styles.ColumnFocused
|
||||
focusedStyle = p.common.Styles.ColumnFocused.Width(p.colWidth).Height(p.colHeight)
|
||||
}
|
||||
blurredStyle = p.common.Styles.ColumnBlurred
|
||||
|
||||
blurredStyle = p.common.Styles.ColumnBlurred.Width(p.colWidth).Height(p.colHeight)
|
||||
// var picker, area string
|
||||
var area string
|
||||
if p.columnCursor == 1 {
|
||||
area = focusedStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
|
||||
if p.columnCursor == 0 {
|
||||
// picker = focusedStyle.Render(p.areaPicker.View())
|
||||
area = blurredStyle.Render(p.areas[p.area].View())
|
||||
} else {
|
||||
area = blurredStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
|
||||
// picker = blurredStyle.Render(p.areaPicker.View())
|
||||
area = focusedStyle.Render(p.areas[p.area].View())
|
||||
|
||||
}
|
||||
|
||||
if p.task.Uuid != "" {
|
||||
var infoView string
|
||||
if p.columnCursor == 2 {
|
||||
infoView = focusedStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
|
||||
} else {
|
||||
infoView = blurredStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
|
||||
}
|
||||
area = lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
area,
|
||||
infoView,
|
||||
p.common.Styles.ColumnFocused.Render(p.common.TW.GetInformation(&p.task)),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
tabs := ""
|
||||
@ -349,11 +304,8 @@ type area interface {
|
||||
tea.Model
|
||||
SetCursor(c int)
|
||||
GetName() string
|
||||
IsFiltering() bool
|
||||
}
|
||||
|
||||
type focusMsg struct{}
|
||||
|
||||
type areaPicker struct {
|
||||
common *common.Common
|
||||
list list.Model
|
||||
@ -425,66 +377,26 @@ func (a *areaPicker) View() string {
|
||||
return a.list.View()
|
||||
}
|
||||
|
||||
type EditableField interface {
|
||||
tea.Model
|
||||
Focus() tea.Cmd
|
||||
Blur() tea.Cmd
|
||||
}
|
||||
|
||||
type taskEdit struct {
|
||||
common *common.Common
|
||||
fields []EditableField
|
||||
fields []huh.Field
|
||||
cursor int
|
||||
|
||||
projectPicker *picker.Picker
|
||||
// newProjectName *string
|
||||
newAnnotation *string
|
||||
udaValues map[string]*string
|
||||
}
|
||||
|
||||
func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEdit {
|
||||
func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
|
||||
// newProject := ""
|
||||
projectOptions := append([]string{"(none)"}, com.TW.GetProjects()...)
|
||||
if task.Project == "" {
|
||||
task.Project = "(none)"
|
||||
}
|
||||
|
||||
itemProvider := func() []list.Item {
|
||||
projects := com.TW.GetProjects()
|
||||
items := []list.Item{picker.NewItem("(none)")}
|
||||
for _, proj := range projects {
|
||||
items = append(items, picker.NewItem(proj))
|
||||
}
|
||||
return items
|
||||
}
|
||||
onSelect := func(item list.Item) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
onCreate := func(newProject string) tea.Cmd {
|
||||
// The new project name will be used as the project value
|
||||
// when the task is saved
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := []picker.PickerOption{picker.WithOnCreate(onCreate)}
|
||||
|
||||
// Check if task has a pre-filled project (e.g., from ProjectTaskPickerPage)
|
||||
hasPrefilledProject := task.Project != "" && task.Project != "(none)"
|
||||
|
||||
if isNew && !hasPrefilledProject {
|
||||
// New task with no project → start in filter mode for quick project search
|
||||
opts = append(opts, picker.WithFilterByDefault(true))
|
||||
} else {
|
||||
// Either existing task OR new task with pre-filled project → show list with project selected
|
||||
opts = append(opts, picker.WithDefaultValue(task.Project))
|
||||
}
|
||||
|
||||
projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...)
|
||||
projPicker.SetSize(70, 8)
|
||||
projPicker.Blur()
|
||||
|
||||
defaultKeymap := huh.NewDefaultKeyMap()
|
||||
|
||||
fields := []EditableField{
|
||||
fields := []huh.Field{
|
||||
huh.NewInput().
|
||||
Title("Task").
|
||||
Value(&task.Description).
|
||||
@ -498,7 +410,12 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd
|
||||
Prompt(": ").
|
||||
WithTheme(com.Styles.Form),
|
||||
|
||||
projPicker,
|
||||
input.NewSelect(com).
|
||||
Options(true, input.NewOptions(projectOptions...)...).
|
||||
Title("Project").
|
||||
Value(&task.Project).
|
||||
WithKeyMap(defaultKeymap).
|
||||
WithTheme(com.Styles.Form),
|
||||
|
||||
// huh.NewInput().
|
||||
// Title("New Project").
|
||||
@ -591,9 +508,8 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd
|
||||
WithTheme(com.Styles.Form))
|
||||
|
||||
t := taskEdit{
|
||||
common: com,
|
||||
fields: fields,
|
||||
projectPicker: projPicker,
|
||||
common: com,
|
||||
fields: fields,
|
||||
|
||||
udaValues: udaValues,
|
||||
|
||||
@ -610,13 +526,6 @@ func (t *taskEdit) GetName() string {
|
||||
return "Task"
|
||||
}
|
||||
|
||||
func (t *taskEdit) IsFiltering() bool {
|
||||
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
|
||||
return f.IsFiltering()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *taskEdit) SetCursor(c int) {
|
||||
t.fields[t.cursor].Blur()
|
||||
if c < 0 {
|
||||
@ -628,25 +537,11 @@ func (t *taskEdit) SetCursor(c int) {
|
||||
}
|
||||
|
||||
func (t *taskEdit) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
// Ensure focus on the active field (especially for the first one)
|
||||
if len(t.fields) > 0 {
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return focusMsg{}
|
||||
})
|
||||
}
|
||||
for _, f := range t.fields {
|
||||
cmds = append(cmds, f.Init())
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.(type) {
|
||||
case focusMsg:
|
||||
if len(t.fields) > 0 {
|
||||
return t, t.fields[t.cursor].Focus()
|
||||
}
|
||||
case nextFieldMsg:
|
||||
if t.cursor == len(t.fields)-1 {
|
||||
t.fields[t.cursor].Blur()
|
||||
@ -665,7 +560,7 @@ func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
t.fields[t.cursor].Focus()
|
||||
default:
|
||||
field, cmd := t.fields[t.cursor].Update(msg)
|
||||
t.fields[t.cursor] = field.(EditableField)
|
||||
t.fields[t.cursor] = field.(huh.Field)
|
||||
return t, cmd
|
||||
}
|
||||
|
||||
@ -728,13 +623,6 @@ func (t *tagEdit) GetName() string {
|
||||
return "Tags"
|
||||
}
|
||||
|
||||
func (t *tagEdit) IsFiltering() bool {
|
||||
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
|
||||
return f.IsFiltering()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *tagEdit) SetCursor(c int) {
|
||||
t.fields[t.cursor].Blur()
|
||||
if c < 0 {
|
||||
@ -788,56 +676,45 @@ func (t tagEdit) View() string {
|
||||
|
||||
type timeEdit struct {
|
||||
common *common.Common
|
||||
fields []*timestampeditor.TimestampEditor
|
||||
fields []huh.Field
|
||||
|
||||
cursor int
|
||||
|
||||
// Store task field pointers to update them
|
||||
due *string
|
||||
scheduled *string
|
||||
wait *string
|
||||
until *string
|
||||
}
|
||||
|
||||
func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit {
|
||||
// Create timestamp editors for each date field
|
||||
dueEditor := timestampeditor.New(common).
|
||||
Title("Due").
|
||||
Description("When the task is due").
|
||||
ValueFromString(*due)
|
||||
|
||||
scheduledEditor := timestampeditor.New(common).
|
||||
Title("Scheduled").
|
||||
Description("When to start working on the task").
|
||||
ValueFromString(*scheduled)
|
||||
|
||||
waitEditor := timestampeditor.New(common).
|
||||
Title("Wait").
|
||||
Description("Hide task until this date").
|
||||
ValueFromString(*wait)
|
||||
|
||||
untilEditor := timestampeditor.New(common).
|
||||
Title("Until").
|
||||
Description("Task expires after this date").
|
||||
ValueFromString(*until)
|
||||
|
||||
// defaultKeymap := huh.NewDefaultKeyMap()
|
||||
t := timeEdit{
|
||||
common: common,
|
||||
fields: []*timestampeditor.TimestampEditor{
|
||||
dueEditor,
|
||||
scheduledEditor,
|
||||
waitEditor,
|
||||
untilEditor,
|
||||
fields: []huh.Field{
|
||||
huh.NewInput().
|
||||
Title("Due").
|
||||
Value(due).
|
||||
Validate(taskwarrior.ValidateDate).
|
||||
Inline(true).
|
||||
Prompt(": ").
|
||||
WithTheme(common.Styles.Form),
|
||||
huh.NewInput().
|
||||
Title("Scheduled").
|
||||
Value(scheduled).
|
||||
Validate(taskwarrior.ValidateDate).
|
||||
Inline(true).
|
||||
Prompt(": ").
|
||||
WithTheme(common.Styles.Form),
|
||||
huh.NewInput().
|
||||
Title("Wait").
|
||||
Value(wait).
|
||||
Validate(taskwarrior.ValidateDate).
|
||||
Inline(true).
|
||||
Prompt(": ").
|
||||
WithTheme(common.Styles.Form),
|
||||
huh.NewInput().
|
||||
Title("Until").
|
||||
Value(until).
|
||||
Validate(taskwarrior.ValidateDate).
|
||||
Inline(true).
|
||||
Prompt(": ").
|
||||
WithTheme(common.Styles.Form),
|
||||
},
|
||||
due: due,
|
||||
scheduled: scheduled,
|
||||
wait: wait,
|
||||
until: until,
|
||||
}
|
||||
|
||||
// Focus the first field
|
||||
if len(t.fields) > 0 {
|
||||
t.fields[0].Focus()
|
||||
}
|
||||
|
||||
return &t
|
||||
@ -847,26 +724,13 @@ func (t *timeEdit) GetName() string {
|
||||
return "Dates"
|
||||
}
|
||||
|
||||
func (t *timeEdit) IsFiltering() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *timeEdit) SetCursor(c int) {
|
||||
if len(t.fields) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Blur the current field
|
||||
t.fields[t.cursor].Blur()
|
||||
|
||||
// Set new cursor position
|
||||
if c < 0 {
|
||||
t.cursor = len(t.fields) - 1
|
||||
} else {
|
||||
t.cursor = c
|
||||
}
|
||||
|
||||
// Focus the new field
|
||||
t.fields[t.cursor].Focus()
|
||||
}
|
||||
|
||||
@ -875,71 +739,42 @@ func (t *timeEdit) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
switch msg.(type) {
|
||||
case nextFieldMsg:
|
||||
if t.cursor == len(t.fields)-1 {
|
||||
// Update task field before moving to next area
|
||||
t.syncToTaskFields()
|
||||
t.fields[t.cursor].Blur()
|
||||
return t, nextArea()
|
||||
}
|
||||
t.fields[t.cursor].Blur()
|
||||
t.cursor++
|
||||
t.fields[t.cursor].Focus()
|
||||
return t, nil
|
||||
case prevFieldMsg:
|
||||
if t.cursor == 0 {
|
||||
// Update task field before moving to previous area
|
||||
t.syncToTaskFields()
|
||||
t.fields[t.cursor].Blur()
|
||||
return t, prevArea()
|
||||
}
|
||||
t.fields[t.cursor].Blur()
|
||||
t.cursor--
|
||||
t.fields[t.cursor].Focus()
|
||||
return t, nil
|
||||
default:
|
||||
// Update the current timestamp editor
|
||||
model, cmd := t.fields[t.cursor].Update(msg)
|
||||
t.fields[t.cursor] = model.(*timestampeditor.TimestampEditor)
|
||||
field, cmd := t.fields[t.cursor].Update(msg)
|
||||
t.fields[t.cursor] = field.(huh.Field)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *timeEdit) View() string {
|
||||
views := make([]string, len(t.fields))
|
||||
|
||||
for i, field := range t.fields {
|
||||
views[i] = field.View()
|
||||
if i < len(t.fields)-1 {
|
||||
views[i] += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
views...,
|
||||
)
|
||||
}
|
||||
|
||||
// syncToTaskFields converts the timestamp editor values back to task field strings
|
||||
func (t *timeEdit) syncToTaskFields() {
|
||||
// Update the task fields with values from the timestamp editors
|
||||
// GetValueString() returns empty string for unset timestamps
|
||||
if len(t.fields) > 0 {
|
||||
*t.due = t.fields[0].GetValueString()
|
||||
}
|
||||
if len(t.fields) > 1 {
|
||||
*t.scheduled = t.fields[1].GetValueString()
|
||||
}
|
||||
if len(t.fields) > 2 {
|
||||
*t.wait = t.fields[2].GetValueString()
|
||||
}
|
||||
if len(t.fields) > 3 {
|
||||
*t.until = t.fields[3].GetValueString()
|
||||
}
|
||||
}
|
||||
|
||||
type detailsEdit struct {
|
||||
com *common.Common
|
||||
vp viewport.Model
|
||||
@ -959,12 +794,11 @@ func NewDetailsEdit(com *common.Common, task *taskwarrior.Task) *detailsEdit {
|
||||
// return nil
|
||||
// }
|
||||
|
||||
vp := viewport.New(com.Width(), 40-com.Styles.ColumnFocused.GetVerticalFrameSize())
|
||||
vp := viewport.New(40, 30)
|
||||
ta := textarea.New()
|
||||
ta.SetWidth(70)
|
||||
ta.SetHeight(40 - com.Styles.ColumnFocused.GetVerticalFrameSize() - 2)
|
||||
ta.SetWidth(40)
|
||||
ta.SetHeight(30)
|
||||
ta.ShowLineNumbers = false
|
||||
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
||||
ta.Focus()
|
||||
if task.Udas["details"] != nil {
|
||||
ta.SetValue(task.Udas["details"].(string))
|
||||
@ -983,10 +817,6 @@ func (d *detailsEdit) GetName() string {
|
||||
return "Details"
|
||||
}
|
||||
|
||||
func (d *detailsEdit) IsFiltering() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *detailsEdit) SetCursor(c int) {
|
||||
}
|
||||
|
||||
@ -1154,8 +984,6 @@ func (d *detailsEdit) View() string {
|
||||
// }
|
||||
|
||||
func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
||||
p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue()
|
||||
|
||||
if p.task.Project == "(none)" {
|
||||
p.task.Project = ""
|
||||
}
|
||||
@ -1180,9 +1008,6 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync timestamp fields from the timeEdit area (area 2)
|
||||
p.areas[2].(*timeEdit).syncToTaskFields()
|
||||
|
||||
if *(p.areas[0].(*taskEdit).newAnnotation) != "" {
|
||||
p.task.Annotations = append(p.task.Annotations, taskwarrior.Annotation{
|
||||
Entry: time.Now().Format("20060102T150405Z"),
|
||||
|
||||
@ -4,362 +4,103 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"tasksquire/common"
|
||||
"tasksquire/components/autocomplete"
|
||||
"tasksquire/components/picker"
|
||||
"tasksquire/components/timestampeditor"
|
||||
"tasksquire/timewarrior"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
type TimeEditorPage struct {
|
||||
common *common.Common
|
||||
interval *timewarrior.Interval
|
||||
form *huh.Form
|
||||
|
||||
// Fields
|
||||
projectPicker *picker.Picker
|
||||
startEditor *timestampeditor.TimestampEditor
|
||||
endEditor *timestampeditor.TimestampEditor
|
||||
tagsInput *autocomplete.Autocomplete
|
||||
adjust bool
|
||||
|
||||
// State
|
||||
selectedProject string
|
||||
currentField int
|
||||
totalFields int
|
||||
uuid string // Preserved UUID tag
|
||||
track string // Preserved track tag (if present)
|
||||
}
|
||||
|
||||
type timeEditorProjectSelectedMsg struct {
|
||||
project string
|
||||
startStr string
|
||||
endStr string
|
||||
tagsStr string
|
||||
}
|
||||
|
||||
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
|
||||
// Extract special tags (uuid, project, track) and display tags
|
||||
uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags)
|
||||
|
||||
// If UUID exists, fetch the task and add its title to display tags
|
||||
if uuid != "" {
|
||||
tasks := com.TW.GetTasks(nil, "uuid:"+uuid)
|
||||
if len(tasks) > 0 {
|
||||
taskTitle := tasks[0].Description
|
||||
// Add to display tags if not already present
|
||||
// Note: formatTags() will handle quoting for display, so we store the raw title
|
||||
displayTags = ensureTagPresent(displayTags, taskTitle)
|
||||
}
|
||||
}
|
||||
|
||||
// Create project picker with onCreate support for new projects
|
||||
projectItemProvider := func() []list.Item {
|
||||
projects := com.TW.GetProjects()
|
||||
items := make([]list.Item, len(projects))
|
||||
for i, proj := range projects {
|
||||
items[i] = picker.NewItem(proj)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
projectOnSelect := func(item list.Item) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return timeEditorProjectSelectedMsg{project: item.FilterValue()}
|
||||
}
|
||||
}
|
||||
|
||||
projectOnCreate := func(name string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return timeEditorProjectSelectedMsg{project: name}
|
||||
}
|
||||
}
|
||||
|
||||
opts := []picker.PickerOption{
|
||||
picker.WithOnCreate(projectOnCreate),
|
||||
}
|
||||
if selectedProject != "" {
|
||||
opts = append(opts, picker.WithDefaultValue(selectedProject))
|
||||
} else {
|
||||
opts = append(opts, picker.WithFilterByDefault(true))
|
||||
}
|
||||
|
||||
projectPicker := picker.New(
|
||||
com,
|
||||
"Project",
|
||||
projectItemProvider,
|
||||
projectOnSelect,
|
||||
opts...,
|
||||
)
|
||||
projectPicker.SetSize(50, 10) // Compact size for inline use
|
||||
|
||||
// Create start timestamp editor
|
||||
startEditor := timestampeditor.New(com).
|
||||
Title("Start").
|
||||
ValueFromString(interval.Start)
|
||||
|
||||
// Create end timestamp editor
|
||||
endEditor := timestampeditor.New(com).
|
||||
Title("End").
|
||||
ValueFromString(interval.End)
|
||||
|
||||
// Get tag combinations filtered by selected project
|
||||
tagCombinations := filterTagCombinationsByProject(
|
||||
com.TimeW.GetTagCombinations(),
|
||||
selectedProject,
|
||||
)
|
||||
|
||||
tagsInput := autocomplete.New(tagCombinations, 3, 10)
|
||||
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces")
|
||||
tagsInput.SetValue(formatTags(displayTags)) // Use display tags only (no uuid, project, track)
|
||||
tagsInput.SetWidth(50)
|
||||
|
||||
p := &TimeEditorPage{
|
||||
common: com,
|
||||
interval: interval,
|
||||
projectPicker: projectPicker,
|
||||
startEditor: startEditor,
|
||||
endEditor: endEditor,
|
||||
tagsInput: tagsInput,
|
||||
adjust: true, // Enable :adjust by default
|
||||
selectedProject: selectedProject,
|
||||
currentField: 0,
|
||||
totalFields: 5, // Now 5 fields: project, tags, start, end, adjust
|
||||
uuid: uuid,
|
||||
track: track,
|
||||
common: com,
|
||||
interval: interval,
|
||||
startStr: interval.Start,
|
||||
endStr: interval.End,
|
||||
tagsStr: formatTags(interval.Tags),
|
||||
}
|
||||
|
||||
p.form = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Start").
|
||||
Value(&p.startStr).
|
||||
Validate(func(s string) error {
|
||||
return timewarrior.ValidateDate(s)
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("End").
|
||||
Value(&p.endStr).
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return nil // End can be empty (active)
|
||||
}
|
||||
return timewarrior.ValidateDate(s)
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("Tags").
|
||||
Value(&p.tagsStr).
|
||||
Description("Space separated, use \"\" for tags with spaces"),
|
||||
),
|
||||
).WithTheme(com.Styles.Form)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *TimeEditorPage) Init() tea.Cmd {
|
||||
// Focus the first field (project picker)
|
||||
p.currentField = 0
|
||||
return p.projectPicker.Init()
|
||||
return p.form.Init()
|
||||
}
|
||||
|
||||
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case timeEditorProjectSelectedMsg:
|
||||
// Update selected project
|
||||
p.selectedProject = msg.project
|
||||
// Blur current field (project picker)
|
||||
p.blurCurrentField()
|
||||
// Advance to tags field
|
||||
p.currentField = 1
|
||||
// Refresh tag autocomplete with filtered combinations
|
||||
cmds = append(cmds, p.updateTagSuggestions())
|
||||
// Focus tags input
|
||||
cmds = append(cmds, p.focusCurrentField())
|
||||
return p, tea.Batch(cmds...)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, p.common.Keymap.Back):
|
||||
if key.Matches(msg, p.common.Keymap.Back) {
|
||||
model, err := p.common.PopPage()
|
||||
if err != nil {
|
||||
slog.Error("page stack empty")
|
||||
return nil, tea.Quit
|
||||
}
|
||||
return model, BackCmd
|
||||
|
||||
case key.Matches(msg, p.common.Keymap.Ok):
|
||||
// Handle Enter based on current field
|
||||
if p.currentField == 0 {
|
||||
// Project picker - let it handle Enter (will trigger projectSelectedMsg)
|
||||
break
|
||||
}
|
||||
|
||||
if p.currentField == 1 {
|
||||
// Tags field
|
||||
if p.tagsInput.HasSuggestions() {
|
||||
// Let autocomplete handle suggestion selection
|
||||
break
|
||||
}
|
||||
// Tags confirmed without suggestions - advance to start timestamp
|
||||
p.blurCurrentField()
|
||||
p.currentField = 2
|
||||
cmds = append(cmds, p.focusCurrentField())
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// For all other fields (2-4: start, end, adjust), save and exit
|
||||
p.saveInterval()
|
||||
model, err := p.common.PopPage()
|
||||
if err != nil {
|
||||
slog.Error("page stack empty")
|
||||
return nil, tea.Quit
|
||||
}
|
||||
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
|
||||
|
||||
case key.Matches(msg, p.common.Keymap.Next):
|
||||
// Move to next field
|
||||
p.blurCurrentField()
|
||||
p.currentField = (p.currentField + 1) % p.totalFields
|
||||
cmds = append(cmds, p.focusCurrentField())
|
||||
|
||||
case key.Matches(msg, p.common.Keymap.Prev):
|
||||
// Move to previous field
|
||||
p.blurCurrentField()
|
||||
p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields
|
||||
cmds = append(cmds, p.focusCurrentField())
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
p.SetSize(msg.Width, msg.Height)
|
||||
}
|
||||
|
||||
// Update the currently focused field
|
||||
var cmd tea.Cmd
|
||||
switch p.currentField {
|
||||
case 0: // Project picker
|
||||
var model tea.Model
|
||||
model, cmd = p.projectPicker.Update(msg)
|
||||
if pk, ok := model.(*picker.Picker); ok {
|
||||
p.projectPicker = pk
|
||||
}
|
||||
case 1: // Tags (was 0)
|
||||
var model tea.Model
|
||||
model, cmd = p.tagsInput.Update(msg)
|
||||
if ac, ok := model.(*autocomplete.Autocomplete); ok {
|
||||
p.tagsInput = ac
|
||||
}
|
||||
case 2: // Start (was 1)
|
||||
var model tea.Model
|
||||
model, cmd = p.startEditor.Update(msg)
|
||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||
p.startEditor = editor
|
||||
}
|
||||
case 3: // End (was 2)
|
||||
var model tea.Model
|
||||
model, cmd = p.endEditor.Update(msg)
|
||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||
p.endEditor = editor
|
||||
}
|
||||
case 4: // Adjust (was 3)
|
||||
// Handle adjust toggle with space/enter
|
||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||
if msg.String() == " " || msg.String() == "enter" {
|
||||
p.adjust = !p.adjust
|
||||
}
|
||||
}
|
||||
form, cmd := p.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
p.form = f
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
if p.form.State == huh.StateCompleted {
|
||||
p.saveInterval()
|
||||
|
||||
model, err := p.common.PopPage()
|
||||
if err != nil {
|
||||
slog.Error("page stack empty")
|
||||
return nil, tea.Quit
|
||||
}
|
||||
// Return with a command to refresh the intervals
|
||||
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
|
||||
}
|
||||
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
||||
switch p.currentField {
|
||||
case 0:
|
||||
return p.projectPicker.Init() // Picker doesn't have explicit Focus()
|
||||
case 1:
|
||||
p.tagsInput.Focus()
|
||||
return p.tagsInput.Init()
|
||||
case 2:
|
||||
return p.startEditor.Focus()
|
||||
case 3:
|
||||
return p.endEditor.Focus()
|
||||
case 4:
|
||||
// Adjust checkbox doesn't need focus action
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TimeEditorPage) blurCurrentField() {
|
||||
switch p.currentField {
|
||||
case 0:
|
||||
// Picker doesn't have explicit Blur(), state handled by Update
|
||||
case 1:
|
||||
p.tagsInput.Blur()
|
||||
case 2:
|
||||
p.startEditor.Blur()
|
||||
case 3:
|
||||
p.endEditor.Blur()
|
||||
case 4:
|
||||
// Adjust checkbox doesn't need blur action
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TimeEditorPage) View() string {
|
||||
var sections []string
|
||||
|
||||
// Title
|
||||
titleStyle := p.common.Styles.Form.Focused.Title
|
||||
sections = append(sections, titleStyle.Render("Edit Time Interval"))
|
||||
sections = append(sections, "")
|
||||
|
||||
// Project picker (field 0)
|
||||
if p.currentField == 0 {
|
||||
sections = append(sections, p.projectPicker.View())
|
||||
} else {
|
||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||
sections = append(sections, blurredLabelStyle.Render("Project"))
|
||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedProject))
|
||||
}
|
||||
|
||||
sections = append(sections, "")
|
||||
sections = append(sections, "")
|
||||
|
||||
// Tags input (now field 1, was first)
|
||||
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
||||
tagsLabel := tagsLabelStyle.Render("Tags")
|
||||
if p.currentField == 1 { // Changed from 0
|
||||
sections = append(sections, tagsLabel)
|
||||
sections = append(sections, p.tagsInput.View())
|
||||
descStyle := p.common.Styles.Form.Focused.Description
|
||||
sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces"))
|
||||
} else {
|
||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||
sections = append(sections, blurredLabelStyle.Render("Tags"))
|
||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.GetValue()))
|
||||
}
|
||||
|
||||
sections = append(sections, "")
|
||||
sections = append(sections, "")
|
||||
|
||||
// Start editor
|
||||
sections = append(sections, p.startEditor.View())
|
||||
sections = append(sections, "")
|
||||
|
||||
// End editor
|
||||
sections = append(sections, p.endEditor.View())
|
||||
sections = append(sections, "")
|
||||
|
||||
// Adjust checkbox (now field 4, was 3)
|
||||
adjustLabelStyle := p.common.Styles.Form.Focused.Title
|
||||
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
|
||||
|
||||
var checkbox string
|
||||
if p.adjust {
|
||||
checkbox = "[X]"
|
||||
} else {
|
||||
checkbox = "[ ]"
|
||||
}
|
||||
|
||||
if p.currentField == 4 { // Changed from 3
|
||||
focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||
sections = append(sections, adjustLabel)
|
||||
sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals"))
|
||||
descStyle := p.common.Styles.Form.Focused.Description
|
||||
sections = append(sections, descStyle.Render("Press space to toggle"))
|
||||
} else {
|
||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||
sections = append(sections, blurredLabelStyle.Render("Adjust overlaps"))
|
||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(checkbox+" Auto-adjust overlapping intervals"))
|
||||
}
|
||||
|
||||
sections = append(sections, "")
|
||||
sections = append(sections, "")
|
||||
|
||||
// Help text
|
||||
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel"))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||
return p.form.View()
|
||||
}
|
||||
|
||||
func (p *TimeEditorPage) SetSize(width, height int) {
|
||||
@ -376,34 +117,13 @@ func (p *TimeEditorPage) saveInterval() {
|
||||
}
|
||||
}
|
||||
|
||||
p.interval.Start = p.startEditor.GetValueString()
|
||||
p.interval.End = p.endEditor.GetValueString()
|
||||
p.interval.Start = p.startStr
|
||||
p.interval.End = p.endStr
|
||||
|
||||
// Parse display tags from input
|
||||
displayTags := parseTags(p.tagsInput.GetValue())
|
||||
// Parse tags
|
||||
p.interval.Tags = parseTags(p.tagsStr)
|
||||
|
||||
// Reconstruct full tags array by combining special tags and display tags
|
||||
var tags []string
|
||||
|
||||
// Add preserved special tags first
|
||||
if p.uuid != "" {
|
||||
tags = append(tags, "uuid:"+p.uuid)
|
||||
}
|
||||
if p.track != "" {
|
||||
tags = append(tags, p.track)
|
||||
}
|
||||
|
||||
// Add project tag
|
||||
if p.selectedProject != "" {
|
||||
tags = append(tags, "project:"+p.selectedProject)
|
||||
}
|
||||
|
||||
// Add display tags (user-entered tags from the input field)
|
||||
tags = append(tags, displayTags...)
|
||||
|
||||
p.interval.Tags = tags
|
||||
|
||||
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
|
||||
err := p.common.TimeW.ModifyInterval(p.interval)
|
||||
if err != nil {
|
||||
slog.Error("Failed to modify interval", "err", err)
|
||||
}
|
||||
@ -445,93 +165,4 @@ func formatTags(tags []string) string {
|
||||
return strings.Join(formatted, " ")
|
||||
}
|
||||
|
||||
// extractSpecialTags separates special tags (uuid, project, track) from display tags
|
||||
// Returns uuid, project, track as separate strings, and displayTags for user editing
|
||||
func extractSpecialTags(tags []string) (uuid, project, track string, displayTags []string) {
|
||||
for _, tag := range tags {
|
||||
if strings.HasPrefix(tag, "uuid:") {
|
||||
uuid = strings.TrimPrefix(tag, "uuid:")
|
||||
} else if strings.HasPrefix(tag, "project:") {
|
||||
project = strings.TrimPrefix(tag, "project:")
|
||||
} else if tag == "track" {
|
||||
track = tag
|
||||
} else {
|
||||
displayTags = append(displayTags, tag)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractProjectFromTags finds and removes the first tag that matches a known project
|
||||
// Returns the found project (or empty string) and the remaining tags
|
||||
// This is kept for backward compatibility but now uses extractSpecialTags internally
|
||||
func extractProjectFromTags(tags []string, projects []string) (string, []string) {
|
||||
_, project, _, remaining := extractSpecialTags(tags)
|
||||
return project, remaining
|
||||
}
|
||||
|
||||
// ensureTagPresent adds a tag to the list if not already present
|
||||
func ensureTagPresent(tags []string, tag string) []string {
|
||||
for _, t := range tags {
|
||||
if t == tag {
|
||||
return tags // Already present
|
||||
}
|
||||
}
|
||||
return append(tags, tag)
|
||||
}
|
||||
|
||||
// filterTagCombinationsByProject filters tag combinations to only show those
|
||||
// containing the exact project tag, and removes the project from the displayed combination
|
||||
func filterTagCombinationsByProject(combinations []string, project string) []string {
|
||||
if project == "" {
|
||||
return combinations
|
||||
}
|
||||
|
||||
projectTag := "project:" + project
|
||||
|
||||
var filtered []string
|
||||
for _, combo := range combinations {
|
||||
// Parse the combination into individual tags
|
||||
tags := parseTags(combo)
|
||||
|
||||
// Check if project exists in this combination
|
||||
for _, tag := range tags {
|
||||
if tag == projectTag {
|
||||
// Found the project - now remove it from display
|
||||
var displayTags []string
|
||||
for _, t := range tags {
|
||||
if t != projectTag {
|
||||
displayTags = append(displayTags, t)
|
||||
}
|
||||
}
|
||||
if len(displayTags) > 0 {
|
||||
filtered = append(filtered, formatTags(displayTags))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// updateTagSuggestions refreshes the tag autocomplete with filtered combinations
|
||||
func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd {
|
||||
combinations := filterTagCombinationsByProject(
|
||||
p.common.TimeW.GetTagCombinations(),
|
||||
p.selectedProject,
|
||||
)
|
||||
|
||||
// Update autocomplete suggestions
|
||||
currentValue := p.tagsInput.GetValue()
|
||||
p.tagsInput.SetSuggestions(combinations)
|
||||
p.tagsInput.SetValue(currentValue)
|
||||
|
||||
// If tags field is focused, refocus it
|
||||
if p.currentField == 1 {
|
||||
p.tagsInput.Focus()
|
||||
return p.tagsInput.Init()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"tasksquire/common"
|
||||
@ -11,7 +9,6 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type TimePage struct {
|
||||
@ -21,170 +18,31 @@ type TimePage struct {
|
||||
data timewarrior.Intervals
|
||||
|
||||
shouldSelectActive bool
|
||||
pendingSyncAction string // "start", "stop", or "" (empty means no pending action)
|
||||
|
||||
selectedTimespan string
|
||||
subpage common.Component
|
||||
}
|
||||
|
||||
func NewTimePage(com *common.Common) *TimePage {
|
||||
p := &TimePage{
|
||||
common: com,
|
||||
selectedTimespan: ":day",
|
||||
common: com,
|
||||
}
|
||||
|
||||
p.populateTable(timewarrior.Intervals{})
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *TimePage) isMultiDayTimespan() bool {
|
||||
switch p.selectedTimespan {
|
||||
case ":day", ":yesterday":
|
||||
return false
|
||||
case ":week", ":lastweek", ":month", ":lastmonth", ":year":
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TimePage) getTimespanLabel() string {
|
||||
switch p.selectedTimespan {
|
||||
case ":day":
|
||||
return "Today"
|
||||
case ":yesterday":
|
||||
return "Yesterday"
|
||||
case ":week":
|
||||
return "Week"
|
||||
case ":lastweek":
|
||||
return "Last Week"
|
||||
case ":month":
|
||||
return "Month"
|
||||
case ":lastmonth":
|
||||
return "Last Month"
|
||||
case ":year":
|
||||
return "Year"
|
||||
default:
|
||||
return p.selectedTimespan
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TimePage) getTimespanDateRange() (start, end time.Time) {
|
||||
now := time.Now()
|
||||
|
||||
switch p.selectedTimespan {
|
||||
case ":day":
|
||||
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
end = start.AddDate(0, 0, 1)
|
||||
case ":yesterday":
|
||||
start = time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, now.Location())
|
||||
end = start.AddDate(0, 0, 1)
|
||||
case ":week":
|
||||
// Find the start of the week (Monday)
|
||||
offset := int(time.Monday - now.Weekday())
|
||||
if offset > 0 {
|
||||
offset = -6
|
||||
}
|
||||
start = time.Date(now.Year(), now.Month(), now.Day()+offset, 0, 0, 0, 0, now.Location())
|
||||
end = start.AddDate(0, 0, 7)
|
||||
case ":lastweek":
|
||||
// Find the start of last week
|
||||
offset := int(time.Monday - now.Weekday())
|
||||
if offset > 0 {
|
||||
offset = -6
|
||||
}
|
||||
start = time.Date(now.Year(), now.Month(), now.Day()+offset-7, 0, 0, 0, 0, now.Location())
|
||||
end = start.AddDate(0, 0, 7)
|
||||
case ":month":
|
||||
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
end = start.AddDate(0, 1, 0)
|
||||
case ":lastmonth":
|
||||
start = time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location())
|
||||
end = start.AddDate(0, 1, 0)
|
||||
case ":year":
|
||||
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
||||
end = start.AddDate(1, 0, 0)
|
||||
default:
|
||||
start = now
|
||||
end = now
|
||||
}
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
func (p *TimePage) renderHeader() string {
|
||||
label := p.getTimespanLabel()
|
||||
start, end := p.getTimespanDateRange()
|
||||
|
||||
var headerText string
|
||||
if p.isMultiDayTimespan() {
|
||||
// Multi-day format: "Week: Feb 02 - Feb 08, 2026"
|
||||
if start.Year() == end.AddDate(0, 0, -1).Year() {
|
||||
headerText = fmt.Sprintf("%s: %s - %s, %d",
|
||||
label,
|
||||
start.Format("Jan 02"),
|
||||
end.AddDate(0, 0, -1).Format("Jan 02"),
|
||||
start.Year())
|
||||
} else {
|
||||
headerText = fmt.Sprintf("%s: %s, %d - %s, %d",
|
||||
label,
|
||||
start.Format("Jan 02"),
|
||||
start.Year(),
|
||||
end.AddDate(0, 0, -1).Format("Jan 02"),
|
||||
end.AddDate(0, 0, -1).Year())
|
||||
}
|
||||
} else {
|
||||
// Single-day format: "Today (Mon, Feb 02, 2026)"
|
||||
headerText = fmt.Sprintf("%s (%s, %s, %d)",
|
||||
label,
|
||||
start.Format("Mon"),
|
||||
start.Format("Jan 02"),
|
||||
start.Year())
|
||||
}
|
||||
|
||||
slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan)
|
||||
// Make header bold and prominent
|
||||
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
||||
return headerStyle.Render(headerText)
|
||||
}
|
||||
|
||||
func (p *TimePage) Init() tea.Cmd {
|
||||
return tea.Batch(p.getIntervals(), doTick())
|
||||
}
|
||||
|
||||
func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.SetSize(msg.Width, msg.Height)
|
||||
case UpdateTimespanMsg:
|
||||
p.selectedTimespan = string(msg)
|
||||
cmds = append(cmds, p.getIntervals())
|
||||
case intervalsMsg:
|
||||
p.data = timewarrior.Intervals(msg)
|
||||
p.populateTable(p.data)
|
||||
|
||||
// If we have a pending sync action (from continuing an interval),
|
||||
// execute it now that intervals are refreshed
|
||||
if p.pendingSyncAction != "" {
|
||||
action := p.pendingSyncAction
|
||||
p.pendingSyncAction = ""
|
||||
cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action))
|
||||
}
|
||||
case TaskPickedMsg:
|
||||
if msg.Task != nil && msg.Task.Status == "pending" {
|
||||
p.common.TW.StopActiveTasks()
|
||||
p.common.TW.StartTask(msg.Task)
|
||||
cmds = append(cmds, p.getIntervals())
|
||||
cmds = append(cmds, doTick())
|
||||
}
|
||||
case RefreshIntervalsMsg:
|
||||
cmds = append(cmds, p.getIntervals())
|
||||
cmds = append(cmds, doTick())
|
||||
case BackMsg:
|
||||
// Restart tick loop when returning from subpage
|
||||
cmds = append(cmds, doTick())
|
||||
case tickMsg:
|
||||
cmds = append(cmds, p.getIntervals())
|
||||
cmds = append(cmds, doTick())
|
||||
@ -192,48 +50,17 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, p.common.Keymap.Quit):
|
||||
return p, tea.Quit
|
||||
case key.Matches(msg, p.common.Keymap.SetReport):
|
||||
// Use 'r' key to show timespan picker
|
||||
p.subpage = NewTimespanPickerPage(p.common, p.selectedTimespan)
|
||||
cmd := p.subpage.Init()
|
||||
p.common.PushPage(p)
|
||||
return p.subpage, cmd
|
||||
case key.Matches(msg, p.common.Keymap.PickProjectTask):
|
||||
p.subpage = NewProjectTaskPickerPage(p.common)
|
||||
cmd := p.subpage.Init()
|
||||
p.common.PushPage(p)
|
||||
return p.subpage, cmd
|
||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
||||
row := p.intervals.SelectedRow()
|
||||
if row != nil {
|
||||
interval := (*timewarrior.Interval)(row)
|
||||
|
||||
// Validate interval before proceeding
|
||||
if interval.IsGap {
|
||||
slog.Debug("Cannot start/stop gap interval")
|
||||
return p, nil
|
||||
}
|
||||
|
||||
if interval.IsActive() {
|
||||
// Stop tracking
|
||||
p.common.TimeW.StopTracking()
|
||||
// Sync: stop corresponding Taskwarrior task immediately (interval has UUID)
|
||||
common.SyncIntervalToTask(interval, p.common.TW, "stop")
|
||||
} else {
|
||||
// Continue tracking - creates a NEW interval
|
||||
slog.Info("Continuing interval for task sync",
|
||||
"intervalID", interval.ID,
|
||||
"hasUUID", timewarrior.ExtractUUID(interval.Tags) != "",
|
||||
"uuid", timewarrior.ExtractUUID(interval.Tags))
|
||||
p.common.TimeW.ContinueInterval(interval.ID)
|
||||
p.shouldSelectActive = true
|
||||
// Set pending sync action instead of syncing immediately
|
||||
// This ensures we sync AFTER intervals are refreshed
|
||||
p.pendingSyncAction = "start"
|
||||
}
|
||||
cmds = append(cmds, p.getIntervals())
|
||||
cmds = append(cmds, doTick())
|
||||
return p, tea.Batch(cmds...)
|
||||
return p, tea.Batch(p.getIntervals(), doTick())
|
||||
}
|
||||
case key.Matches(msg, p.common.Keymap.Delete):
|
||||
row := p.intervals.SelectedRow()
|
||||
@ -256,26 +83,6 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
editor := NewTimeEditorPage(p.common, interval)
|
||||
p.common.PushPage(p)
|
||||
return editor, editor.Init()
|
||||
case key.Matches(msg, p.common.Keymap.Fill):
|
||||
row := p.intervals.SelectedRow()
|
||||
if row != nil {
|
||||
interval := (*timewarrior.Interval)(row)
|
||||
p.common.TimeW.FillInterval(interval.ID)
|
||||
return p, tea.Batch(p.getIntervals(), doTick())
|
||||
}
|
||||
case key.Matches(msg, p.common.Keymap.Undo):
|
||||
p.common.TimeW.Undo()
|
||||
return p, tea.Batch(p.getIntervals(), doTick())
|
||||
case key.Matches(msg, p.common.Keymap.Join):
|
||||
row := p.intervals.SelectedRow()
|
||||
if row != nil {
|
||||
interval := (*timewarrior.Interval)(row)
|
||||
// Don't join if this is the last (oldest) interval
|
||||
if interval.ID < len(p.data) {
|
||||
p.common.TimeW.JoinInterval(interval.ID)
|
||||
return p, tea.Batch(p.getIntervals(), doTick())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -293,133 +100,49 @@ func refreshIntervals() tea.Msg {
|
||||
}
|
||||
|
||||
func (p *TimePage) View() string {
|
||||
header := p.renderHeader()
|
||||
if len(p.data) == 0 {
|
||||
noDataMsg := p.common.Styles.Base.Render("No intervals found")
|
||||
content := header + "\n\n" + noDataMsg
|
||||
return lipgloss.Place(
|
||||
p.common.Width(),
|
||||
p.common.Height(),
|
||||
lipgloss.Left,
|
||||
lipgloss.Top,
|
||||
content,
|
||||
)
|
||||
return p.common.Styles.Base.Render("No intervals found for today")
|
||||
}
|
||||
|
||||
tableView := p.intervals.View()
|
||||
content := header + "\n\n" + tableView
|
||||
|
||||
contentHeight := lipgloss.Height(content)
|
||||
tableHeight := lipgloss.Height(tableView)
|
||||
headerHeight := lipgloss.Height(header)
|
||||
|
||||
slog.Info("TimePage View rendered",
|
||||
"headerLen", len(header),
|
||||
"dataCount", len(p.data),
|
||||
"headerHeight", headerHeight,
|
||||
"tableHeight", tableHeight,
|
||||
"contentHeight", contentHeight,
|
||||
"termHeight", p.common.Height())
|
||||
|
||||
return content
|
||||
return p.intervals.View()
|
||||
}
|
||||
|
||||
func (p *TimePage) SetSize(width int, height int) {
|
||||
p.common.SetSize(width, height)
|
||||
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
|
||||
tableHeight := height - frameSize - 3
|
||||
slog.Info("TimePage SetSize", "totalHeight", height, "frameSize", frameSize, "tableHeight", tableHeight)
|
||||
p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
|
||||
// Subtract 3: 1 for header line, 1 for empty line, 1 for safety margin
|
||||
p.intervals.SetHeight(tableHeight)
|
||||
}
|
||||
|
||||
// insertGaps inserts gap intervals between actual intervals where there is untracked time.
|
||||
// Gaps are not inserted before the first interval or after the last interval.
|
||||
// Note: intervals are in reverse chronological order (newest first), so we need to account for that.
|
||||
func insertGaps(intervals timewarrior.Intervals) timewarrior.Intervals {
|
||||
if len(intervals) <= 1 {
|
||||
return intervals
|
||||
}
|
||||
|
||||
result := make(timewarrior.Intervals, 0, len(intervals)*2)
|
||||
|
||||
for i := 0; i < len(intervals); i++ {
|
||||
result = append(result, intervals[i])
|
||||
|
||||
// Don't add gap after the last interval
|
||||
if i < len(intervals)-1 {
|
||||
// Since intervals are reversed (newest first), the gap is between
|
||||
// the end of the NEXT interval and the start of the CURRENT interval
|
||||
currentStart := intervals[i].GetStartTime()
|
||||
nextEnd := intervals[i+1].GetEndTime()
|
||||
|
||||
// Calculate gap duration
|
||||
gap := currentStart.Sub(nextEnd)
|
||||
|
||||
// Only insert gap if there is untracked time
|
||||
if gap > 0 {
|
||||
gapInterval := timewarrior.NewGapInterval(nextEnd, currentStart)
|
||||
result = append(result, gapInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
p.intervals.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
|
||||
}
|
||||
|
||||
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
||||
var selectedStart string
|
||||
currentIdx := p.intervals.Cursor()
|
||||
if row := p.intervals.SelectedRow(); row != nil {
|
||||
selectedStart = row.Start
|
||||
}
|
||||
|
||||
// Insert gap intervals between actual intervals
|
||||
intervalsWithGaps := insertGaps(intervals)
|
||||
|
||||
// Determine column configuration based on timespan
|
||||
var startEndWidth int
|
||||
var startField, endField string
|
||||
if p.isMultiDayTimespan() {
|
||||
startEndWidth = 16 // "2006-01-02 15:04"
|
||||
startField = "start"
|
||||
endField = "end"
|
||||
} else {
|
||||
startEndWidth = 5 // "15:04"
|
||||
startField = "start_time"
|
||||
endField = "end_time"
|
||||
}
|
||||
|
||||
columns := []timetable.Column{
|
||||
{Title: "ID", Name: "id", Width: 4},
|
||||
{Title: "Weekday", Name: "weekday", Width: 9},
|
||||
{Title: "Start", Name: startField, Width: startEndWidth},
|
||||
{Title: "End", Name: endField, Width: startEndWidth},
|
||||
{Title: "Start", Name: "start", Width: 16},
|
||||
{Title: "End", Name: "end", Width: 16},
|
||||
{Title: "Duration", Name: "duration", Width: 10},
|
||||
{Title: "Project", Name: "project", Width: 0}, // flexible width
|
||||
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
||||
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
||||
}
|
||||
|
||||
// Calculate table height: total height - header (1 line) - blank line (1) - safety (1)
|
||||
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
|
||||
tableHeight := p.common.Height() - frameSize - 3
|
||||
|
||||
p.intervals = timetable.New(
|
||||
p.common,
|
||||
timetable.WithColumns(columns),
|
||||
timetable.WithIntervals(intervalsWithGaps),
|
||||
timetable.WithIntervals(intervals),
|
||||
timetable.WithFocused(true),
|
||||
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()),
|
||||
timetable.WithHeight(tableHeight),
|
||||
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
|
||||
timetable.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()),
|
||||
timetable.WithStyles(p.common.Styles.TableStyle),
|
||||
)
|
||||
|
||||
if len(intervalsWithGaps) > 0 {
|
||||
if len(intervals) > 0 {
|
||||
newIdx := -1
|
||||
|
||||
if p.shouldSelectActive {
|
||||
for i, interval := range intervalsWithGaps {
|
||||
if !interval.IsGap && interval.IsActive() {
|
||||
for i, interval := range intervals {
|
||||
if interval.IsActive() {
|
||||
newIdx = i
|
||||
break
|
||||
}
|
||||
@ -428,8 +151,8 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
||||
}
|
||||
|
||||
if newIdx == -1 && selectedStart != "" {
|
||||
for i, interval := range intervalsWithGaps {
|
||||
if !interval.IsGap && interval.Start == selectedStart {
|
||||
for i, interval := range intervals {
|
||||
if interval.Start == selectedStart {
|
||||
newIdx = i
|
||||
break
|
||||
}
|
||||
@ -437,17 +160,11 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
||||
}
|
||||
|
||||
if newIdx == -1 {
|
||||
// Default to first non-gap interval
|
||||
for i, interval := range intervalsWithGaps {
|
||||
if !interval.IsGap {
|
||||
newIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
newIdx = currentIdx
|
||||
}
|
||||
|
||||
if newIdx >= len(intervalsWithGaps) {
|
||||
newIdx = len(intervalsWithGaps) - 1
|
||||
if newIdx >= len(intervals) {
|
||||
newIdx = len(intervals) - 1
|
||||
}
|
||||
if newIdx < 0 {
|
||||
newIdx = 0
|
||||
@ -461,38 +178,8 @@ type intervalsMsg timewarrior.Intervals
|
||||
|
||||
func (p *TimePage) getIntervals() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
intervals := p.common.TimeW.GetIntervals(p.selectedTimespan)
|
||||
// ":day" is a timewarrior hint for "today"
|
||||
intervals := p.common.TimeW.GetIntervals(":day")
|
||||
return intervalsMsg(intervals)
|
||||
}
|
||||
}
|
||||
|
||||
// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior
|
||||
func (p *TimePage) syncActiveInterval(action string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Get the currently active interval
|
||||
activeInterval := p.common.TimeW.GetActive()
|
||||
if activeInterval != nil {
|
||||
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed
|
||||
// to ensure we're working with current data
|
||||
func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// At this point, intervals have been refreshed, so GetActive() will work
|
||||
activeInterval := p.common.TimeW.GetActive()
|
||||
if activeInterval != nil {
|
||||
slog.Info("Syncing active interval to task after refresh",
|
||||
"action", action,
|
||||
"intervalID", activeInterval.ID,
|
||||
"hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "")
|
||||
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
||||
} else {
|
||||
slog.Warn("No active interval found after refresh, cannot sync to task")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,146 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tasksquire/timewarrior"
|
||||
)
|
||||
|
||||
func TestInsertGaps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
intervals timewarrior.Intervals
|
||||
expectedCount int
|
||||
expectedGaps int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "empty intervals",
|
||||
intervals: timewarrior.Intervals{},
|
||||
expectedCount: 0,
|
||||
expectedGaps: 0,
|
||||
description: "Should return empty list for empty input",
|
||||
},
|
||||
{
|
||||
name: "single interval",
|
||||
intervals: timewarrior.Intervals{
|
||||
{
|
||||
ID: 1,
|
||||
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||
Tags: []string{"test"},
|
||||
},
|
||||
},
|
||||
expectedCount: 1,
|
||||
expectedGaps: 0,
|
||||
description: "Should return single interval without gaps",
|
||||
},
|
||||
{
|
||||
name: "two intervals with gap (reverse chronological)",
|
||||
intervals: timewarrior.Intervals{
|
||||
{
|
||||
ID: 1,
|
||||
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||
Tags: []string{"test2"},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
Tags: []string{"test1"},
|
||||
},
|
||||
},
|
||||
expectedCount: 3,
|
||||
expectedGaps: 1,
|
||||
description: "Should insert one gap between two intervals (newest first order)",
|
||||
},
|
||||
{
|
||||
name: "three intervals with two gaps (reverse chronological)",
|
||||
intervals: timewarrior.Intervals{
|
||||
{
|
||||
ID: 1,
|
||||
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||
Tags: []string{"test3"},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
Tags: []string{"test2"},
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Start: time.Now().Add(-5 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
End: time.Now().Add(-4 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
Tags: []string{"test1"},
|
||||
},
|
||||
},
|
||||
expectedCount: 5,
|
||||
expectedGaps: 2,
|
||||
description: "Should insert two gaps between three intervals (newest first order)",
|
||||
},
|
||||
{
|
||||
name: "consecutive intervals with no gap (reverse chronological)",
|
||||
intervals: timewarrior.Intervals{
|
||||
{
|
||||
ID: 1,
|
||||
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||
Tags: []string{"test2"},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Start: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
End: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||
Tags: []string{"test1"},
|
||||
},
|
||||
},
|
||||
expectedCount: 2,
|
||||
expectedGaps: 0,
|
||||
description: "Should not insert gap when intervals are consecutive (newest first order)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := insertGaps(tt.intervals)
|
||||
|
||||
if len(result) != tt.expectedCount {
|
||||
t.Errorf("insertGaps() returned %d intervals, expected %d. %s",
|
||||
len(result), tt.expectedCount, tt.description)
|
||||
}
|
||||
|
||||
gapCount := 0
|
||||
for _, interval := range result {
|
||||
if interval.IsGap {
|
||||
gapCount++
|
||||
}
|
||||
}
|
||||
|
||||
if gapCount != tt.expectedGaps {
|
||||
t.Errorf("insertGaps() created %d gaps, expected %d. %s",
|
||||
gapCount, tt.expectedGaps, tt.description)
|
||||
}
|
||||
|
||||
// Verify gaps are properly interleaved with intervals
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
if result[i].IsGap && result[i+1].IsGap {
|
||||
t.Errorf("insertGaps() created consecutive gap rows at indices %d and %d", i, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify first and last items are never gaps
|
||||
if len(result) > 0 {
|
||||
if result[0].IsGap {
|
||||
t.Errorf("insertGaps() created gap as first item")
|
||||
}
|
||||
if result[len(result)-1].IsGap {
|
||||
t.Errorf("insertGaps() created gap as last item")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,128 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"tasksquire/common"
|
||||
"tasksquire/components/picker"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type TimespanPickerPage struct {
|
||||
common *common.Common
|
||||
picker *picker.Picker
|
||||
selectedTimespan string
|
||||
}
|
||||
|
||||
func NewTimespanPickerPage(common *common.Common, currentTimespan string) *TimespanPickerPage {
|
||||
p := &TimespanPickerPage{
|
||||
common: common,
|
||||
selectedTimespan: currentTimespan,
|
||||
}
|
||||
|
||||
timespanOptions := []list.Item{
|
||||
picker.NewItem(":day"),
|
||||
picker.NewItem(":yesterday"),
|
||||
picker.NewItem(":week"),
|
||||
picker.NewItem(":lastweek"),
|
||||
picker.NewItem(":month"),
|
||||
picker.NewItem(":lastmonth"),
|
||||
picker.NewItem(":year"),
|
||||
}
|
||||
|
||||
itemProvider := func() []list.Item {
|
||||
return timespanOptions
|
||||
}
|
||||
|
||||
onSelect := func(item list.Item) tea.Cmd {
|
||||
return func() tea.Msg { return timespanSelectedMsg{item: item} }
|
||||
}
|
||||
|
||||
p.picker = picker.New(common, "Select Timespan", itemProvider, onSelect)
|
||||
|
||||
// Select the current timespan in the picker
|
||||
p.picker.SelectItemByFilterValue(currentTimespan)
|
||||
|
||||
p.SetSize(common.Width(), common.Height())
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *TimespanPickerPage) SetSize(width, height int) {
|
||||
p.common.SetSize(width, height)
|
||||
|
||||
// Set list size with some padding/limits to look like a picker
|
||||
listWidth := width - 4
|
||||
if listWidth > 40 {
|
||||
listWidth = 40
|
||||
}
|
||||
listHeight := height - 6
|
||||
if listHeight > 20 {
|
||||
listHeight = 20
|
||||
}
|
||||
p.picker.SetSize(listWidth, listHeight)
|
||||
}
|
||||
|
||||
func (p *TimespanPickerPage) Init() tea.Cmd {
|
||||
return p.picker.Init()
|
||||
}
|
||||
|
||||
type timespanSelectedMsg struct {
|
||||
item list.Item
|
||||
}
|
||||
|
||||
func (p *TimespanPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.SetSize(msg.Width, msg.Height)
|
||||
case timespanSelectedMsg:
|
||||
timespan := msg.item.FilterValue()
|
||||
|
||||
model, err := p.common.PopPage()
|
||||
if err != nil {
|
||||
slog.Error("page stack empty")
|
||||
return nil, tea.Quit
|
||||
}
|
||||
return model, func() tea.Msg { return UpdateTimespanMsg(timespan) }
|
||||
case tea.KeyMsg:
|
||||
if !p.picker.IsFiltering() {
|
||||
switch {
|
||||
case key.Matches(msg, p.common.Keymap.Back):
|
||||
model, err := p.common.PopPage()
|
||||
if err != nil {
|
||||
slog.Error("page stack empty")
|
||||
return nil, tea.Quit
|
||||
}
|
||||
return model, BackCmd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, cmd = p.picker.Update(msg)
|
||||
return p, cmd
|
||||
}
|
||||
|
||||
func (p *TimespanPickerPage) View() string {
|
||||
width := p.common.Width() - 4
|
||||
if width > 40 {
|
||||
width = 40
|
||||
}
|
||||
|
||||
content := p.picker.View()
|
||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
||||
|
||||
return lipgloss.Place(
|
||||
p.common.Width(),
|
||||
p.common.Height(),
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
p.common.Styles.Base.Render(styledContent),
|
||||
)
|
||||
}
|
||||
|
||||
type UpdateTimespanMsg string
|
||||
@ -43,11 +43,11 @@ func (a Annotation) String() string {
|
||||
type Tasks []*Task
|
||||
|
||||
type Task struct {
|
||||
Id int64 `json:"id,omitempty"`
|
||||
Uuid string `json:"uuid,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Project string `json:"project"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Id int64 `json:"id,omitempty"`
|
||||
Uuid string `json:"uuid,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Project string `json:"project"`
|
||||
// Priority string `json:"priority"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
VirtualTags []string `json:"-"`
|
||||
@ -120,25 +120,19 @@ func (t *Task) GetString(fieldWFormat string) string {
|
||||
}
|
||||
}
|
||||
|
||||
if t.Udas["details"] != nil && t.Udas["details"] != "" {
|
||||
return fmt.Sprintf("%s [D]", t.Description)
|
||||
} else {
|
||||
if len(t.Annotations) == 0 {
|
||||
return t.Description
|
||||
} else {
|
||||
// var annotations []string
|
||||
// for _, a := range t.Annotations {
|
||||
// annotations = append(annotations, a.String())
|
||||
// }
|
||||
// return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
|
||||
|
||||
// TODO enable support for multiline in table
|
||||
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||
}
|
||||
|
||||
// if len(t.Annotations) == 0 {
|
||||
// return t.Description
|
||||
// } else {
|
||||
// // var annotations []string
|
||||
// // for _, a := range t.Annotations {
|
||||
// // annotations = append(annotations, a.String())
|
||||
// // }
|
||||
// // return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
|
||||
|
||||
// // TODO enable support for multiline in table
|
||||
// return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||
// }
|
||||
|
||||
case "project":
|
||||
switch format {
|
||||
case "parent":
|
||||
@ -149,8 +143,8 @@ func (t *Task) GetString(fieldWFormat string) string {
|
||||
}
|
||||
return t.Project
|
||||
|
||||
case "priority":
|
||||
return t.Priority
|
||||
// case "priority":
|
||||
// return t.Priority
|
||||
|
||||
case "status":
|
||||
return t.Status
|
||||
@ -233,33 +227,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (t *Task) GetDate(field string) time.Time {
|
||||
var dateString string
|
||||
switch field {
|
||||
case "due":
|
||||
dateString = t.Due
|
||||
case "wait":
|
||||
dateString = t.Wait
|
||||
case "scheduled":
|
||||
dateString = t.Scheduled
|
||||
case "until":
|
||||
dateString = t.Until
|
||||
case "start":
|
||||
dateString = t.Start
|
||||
case "end":
|
||||
dateString = t.End
|
||||
case "entry":
|
||||
dateString = t.Entry
|
||||
case "modified":
|
||||
dateString = t.Modified
|
||||
default:
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
if dateString == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (t *Task) GetDate(dateString string) time.Time {
|
||||
dt, err := time.Parse(dtformat, dateString)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time:", err)
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTask_GetString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task Task
|
||||
fieldWFormat string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Priority",
|
||||
task: Task{
|
||||
Priority: "H",
|
||||
},
|
||||
fieldWFormat: "priority",
|
||||
want: "H",
|
||||
},
|
||||
{
|
||||
name: "Description",
|
||||
task: Task{
|
||||
Description: "Buy milk",
|
||||
},
|
||||
fieldWFormat: "description.desc",
|
||||
want: "Buy milk",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.task.GetString(tt.fieldWFormat); got != tt.want {
|
||||
t.Errorf("Task.GetString() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_GetDate(t *testing.T) {
|
||||
validDate := "20230101T120000Z"
|
||||
parsedValid, _ := time.Parse("20060102T150405Z", validDate)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
task Task
|
||||
field string
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "Due date valid",
|
||||
task: Task{
|
||||
Due: validDate,
|
||||
},
|
||||
field: "due",
|
||||
want: parsedValid,
|
||||
},
|
||||
{
|
||||
name: "Due date empty",
|
||||
task: Task{},
|
||||
field: "due",
|
||||
want: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "Unknown field",
|
||||
task: Task{Due: validDate},
|
||||
field: "unknown",
|
||||
want: time.Time{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.task.GetDate(tt.field); !got.Equal(tt.want) {
|
||||
t.Errorf("Task.GetDate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -98,7 +98,6 @@ type TaskWarrior interface {
|
||||
DeleteTask(task *Task)
|
||||
StartTask(task *Task)
|
||||
StopTask(task *Task)
|
||||
StopActiveTasks()
|
||||
GetInformation(task *Task) string
|
||||
AddTaskAnnotation(uuid string, annotation string)
|
||||
|
||||
@ -147,7 +146,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
||||
|
||||
args := ts.defaultArgs
|
||||
|
||||
if report != nil && report.Context {
|
||||
if report.Context {
|
||||
for _, context := range ts.contexts {
|
||||
if context.Active && context.Name != "none" {
|
||||
args = append(args, context.ReadFilter)
|
||||
@ -160,12 +159,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
||||
args = append(args, filter...)
|
||||
}
|
||||
|
||||
exportArgs := []string{"export"}
|
||||
if report != nil && report.Name != "" {
|
||||
exportArgs = append(exportArgs, report.Name)
|
||||
}
|
||||
|
||||
cmd := exec.Command(twBinary, append(args, exportArgs...)...)
|
||||
cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting report:", err)
|
||||
@ -496,33 +490,6 @@ func (ts *TaskSquire) StopTask(task *Task) {
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskSquire) StopActiveTasks() {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting active tasks:", "error", err, "output", string(output))
|
||||
return
|
||||
}
|
||||
|
||||
tasks := make(Tasks, 0)
|
||||
err = json.Unmarshal(output, &tasks)
|
||||
if err != nil {
|
||||
slog.Error("Failed unmarshalling active tasks:", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed stopping task:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskSquire) GetInformation(task *Task) string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
Binary file not shown.
@ -20,7 +20,6 @@ type Interval struct {
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
IsGap bool `json:"-"` // True if this represents an untracked time gap
|
||||
}
|
||||
|
||||
func NewInterval() *Interval {
|
||||
@ -29,31 +28,7 @@ func NewInterval() *Interval {
|
||||
}
|
||||
}
|
||||
|
||||
// NewGapInterval creates a new gap interval representing untracked time.
|
||||
// start and end are the times between which the gap occurred.
|
||||
func NewGapInterval(start, end time.Time) *Interval {
|
||||
return &Interval{
|
||||
ID: -1, // Gap intervals have no real ID
|
||||
Start: start.UTC().Format(dtformat),
|
||||
End: end.UTC().Format(dtformat),
|
||||
Tags: make([]string, 0),
|
||||
IsGap: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Interval) GetString(field string) string {
|
||||
// Special handling for gap intervals
|
||||
if i.IsGap {
|
||||
switch field {
|
||||
case "duration":
|
||||
return i.GetDuration()
|
||||
case "gap_display":
|
||||
return fmt.Sprintf("--- Untracked: %s ---", i.GetDuration())
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
switch field {
|
||||
case "id":
|
||||
return strconv.Itoa(i.ID)
|
||||
@ -61,38 +36,17 @@ func (i *Interval) GetString(field string) string {
|
||||
case "start":
|
||||
return formatDate(i.Start, "formatted")
|
||||
|
||||
case "start_time":
|
||||
return formatDate(i.Start, "time")
|
||||
|
||||
case "end":
|
||||
if i.End == "" {
|
||||
return "now"
|
||||
}
|
||||
return formatDate(i.End, "formatted")
|
||||
|
||||
case "end_time":
|
||||
if i.End == "" {
|
||||
return "now"
|
||||
}
|
||||
return formatDate(i.End, "time")
|
||||
|
||||
case "weekday":
|
||||
return formatDate(i.Start, "weekday")
|
||||
|
||||
case "tags":
|
||||
if len(i.Tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Extract and filter special tags (uuid:, project:)
|
||||
_, _, displayTags := ExtractSpecialTags(i.Tags)
|
||||
return strings.Join(displayTags, " ")
|
||||
|
||||
case "project":
|
||||
project := ExtractProject(i.Tags)
|
||||
if project == "" {
|
||||
return "(none)"
|
||||
}
|
||||
return project
|
||||
return strings.Join(i.Tags, " ")
|
||||
|
||||
case "duration":
|
||||
return i.GetDuration()
|
||||
@ -200,8 +154,6 @@ func formatDate(date string, format string) string {
|
||||
return dt.Format("15:04")
|
||||
case "date":
|
||||
return dt.Format("2006-01-02")
|
||||
case "weekday":
|
||||
return dt.Format("Mon")
|
||||
case "iso":
|
||||
return dt.Format("2006-01-02T150405Z")
|
||||
case "epoch":
|
||||
@ -221,7 +173,10 @@ func formatDuration(d time.Duration) string {
|
||||
minutes := int(d.Minutes()) % 60
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
func parseDurationVague(d time.Duration) string {
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
package timewarrior
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Special tag prefixes for metadata
|
||||
const (
|
||||
UUIDPrefix = "uuid:"
|
||||
ProjectPrefix = "project:"
|
||||
)
|
||||
|
||||
// ExtractSpecialTags parses tags and separates special prefixed tags from display tags.
|
||||
// Returns: uuid, project, and remaining display tags (description + user tags)
|
||||
func ExtractSpecialTags(tags []string) (uuid string, project string, displayTags []string) {
|
||||
displayTags = make([]string, 0, len(tags))
|
||||
|
||||
for _, tag := range tags {
|
||||
switch {
|
||||
case strings.HasPrefix(tag, UUIDPrefix):
|
||||
uuid = strings.TrimPrefix(tag, UUIDPrefix)
|
||||
case strings.HasPrefix(tag, ProjectPrefix):
|
||||
project = strings.TrimPrefix(tag, ProjectPrefix)
|
||||
case tag == "track":
|
||||
// Skip the "track" tag - it's internal metadata
|
||||
continue
|
||||
default:
|
||||
// Regular tag (description or user tag)
|
||||
displayTags = append(displayTags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
return uuid, project, displayTags
|
||||
}
|
||||
|
||||
// ExtractUUID extracts just the UUID from tags (for sync operations)
|
||||
func ExtractUUID(tags []string) string {
|
||||
for _, tag := range tags {
|
||||
if strings.HasPrefix(tag, UUIDPrefix) {
|
||||
return strings.TrimPrefix(tag, UUIDPrefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractProject extracts just the project name from tags
|
||||
func ExtractProject(tags []string) string {
|
||||
for _, tag := range tags {
|
||||
if strings.HasPrefix(tag, ProjectPrefix) {
|
||||
return strings.TrimPrefix(tag, ProjectPrefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -22,7 +22,6 @@ type TimeWarrior interface {
|
||||
GetConfig() *TWConfig
|
||||
|
||||
GetTags() []string
|
||||
GetTagCombinations() []string
|
||||
|
||||
GetIntervals(filter ...string) Intervals
|
||||
StartTracking(tags []string) error
|
||||
@ -31,9 +30,7 @@ type TimeWarrior interface {
|
||||
ContinueInterval(id int) error
|
||||
CancelTracking() error
|
||||
DeleteInterval(id int) error
|
||||
FillInterval(id int) error
|
||||
JoinInterval(id int) error
|
||||
ModifyInterval(interval *Interval, adjust bool) error
|
||||
ModifyInterval(interval *Interval) error
|
||||
GetSummary(filter ...string) string
|
||||
GetActive() *Interval
|
||||
|
||||
@ -102,49 +99,10 @@ func (ts *TimeSquire) GetTags() []string {
|
||||
return tags
|
||||
}
|
||||
|
||||
// GetTagCombinations returns unique tag combinations from intervals,
|
||||
// ordered newest first (most recent intervals' tags appear first).
|
||||
// Returns formatted strings like "dev client-work meeting".
|
||||
func (ts *TimeSquire) GetTagCombinations() []string {
|
||||
intervals := ts.GetIntervals() // Already sorted newest first
|
||||
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
// Track unique combinations while preserving order
|
||||
seen := make(map[string]bool)
|
||||
var combinations []string
|
||||
|
||||
for _, interval := range intervals {
|
||||
if len(interval.Tags) == 0 {
|
||||
continue // Skip intervals with no tags
|
||||
}
|
||||
|
||||
// Format tags (handles spaces with quotes)
|
||||
combo := formatTagsForCombination(interval.Tags)
|
||||
|
||||
if !seen[combo] {
|
||||
seen[combo] = true
|
||||
combinations = append(combinations, combo)
|
||||
}
|
||||
}
|
||||
|
||||
return combinations
|
||||
}
|
||||
|
||||
// formatTagsForCombination formats tags consistently for display
|
||||
func formatTagsForCombination(tags []string) string {
|
||||
var formatted []string
|
||||
for _, t := range tags {
|
||||
if strings.Contains(t, " ") {
|
||||
formatted = append(formatted, "\""+t+"\"")
|
||||
} else {
|
||||
formatted = append(formatted, t)
|
||||
}
|
||||
}
|
||||
return strings.Join(formatted, " ")
|
||||
}
|
||||
|
||||
// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
|
||||
// Caller must hold ts.mutex.
|
||||
func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
|
||||
args := append(ts.defaultArgs, "export")
|
||||
if filter != nil {
|
||||
args = append(args, filter...)
|
||||
@ -175,14 +133,6 @@ func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
|
||||
return intervals
|
||||
}
|
||||
|
||||
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
|
||||
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
return ts.getIntervalsUnlocked(filter...)
|
||||
}
|
||||
|
||||
func (ts *TimeSquire) StartTracking(tags []string) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
@ -268,35 +218,7 @@ func (ts *TimeSquire) DeleteInterval(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimeSquire) FillInterval(id int) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed filling interval:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimeSquire) JoinInterval(id int) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
// Join the current interval with the previous one
|
||||
// The previous interval has id+1 (since intervals are ordered newest first)
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed joining interval:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
||||
func (ts *TimeSquire) ModifyInterval(interval *Interval) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
@ -307,14 +229,8 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build import command with optional :adjust hint
|
||||
args := append(ts.defaultArgs, "import")
|
||||
if adjust {
|
||||
args = append(args, ":adjust")
|
||||
}
|
||||
|
||||
// Import the modified interval
|
||||
cmd := exec.Command(twBinary, args...)
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import"}...)...)
|
||||
cmd.Stdin = bytes.NewBuffer(intervals)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@ -354,8 +270,8 @@ func (ts *TimeSquire) GetActive() *Interval {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the active interval using unlocked version (we already hold the mutex)
|
||||
intervals := ts.getIntervalsUnlocked()
|
||||
// Get the active interval
|
||||
intervals := ts.GetIntervals()
|
||||
for _, interval := range intervals {
|
||||
if interval.End == "" {
|
||||
return interval
|
||||
|
||||
Reference in New Issue
Block a user