31 Commits

Author SHA1 Message Date
02fa2e503a Add things 2026-02-04 13:13:04 +01:00
474bb3dc07 Add project tracking picker 2026-02-03 20:59:47 +01:00
1ffcf42773 Fix bugs 2026-02-03 20:13:09 +01:00
44ddbc0f47 Add syncing 2026-02-03 16:04:47 +01:00
2e33893e29 Merge branch 'feat/taskedit' into feat/time 2026-02-03 07:40:11 +01:00
46ce91196a Merge branch 'feat/task' into feat/time 2026-02-03 07:39:59 +01:00
2b31d9bc2b Add autocompletion 2026-02-03 07:39:26 +01:00
70b6ee9bc7 Add picker to task edit 2026-02-02 20:43:08 +01:00
2baf3859fd Add tab bar 2026-02-02 19:47:18 +01:00
2940711b26 Make task details scrollable 2026-02-02 19:39:02 +01:00
f5d297e6ab Add proper fuzzy matching for time tags 2026-02-02 15:54:39 +01:00
938ed177f1 Add fuzzy matching for time tags 2026-02-02 15:41:53 +01:00
81b9d87935 Add niceties to time page 2026-02-02 12:44:12 +01:00
9940316ace Add time undo and fill 2026-02-02 11:12:09 +01:00
fc8e9481c3 Add timestamp editor 2026-02-02 10:55:47 +01:00
7032d0fa54 Add time editing 2026-02-02 10:04:54 +01:00
681ed7e635 Add time page 2026-02-02 10:04:54 +01:00
effd95f6c1 Refactor picker 2026-02-02 10:04:54 +01:00
4767a6cd91 Integrate timewarrior 2026-02-02 10:04:54 +01:00
ce193c336c Add README 2026-02-02 10:04:54 +01:00
f19767fb10 Minor fixes 2026-02-02 10:04:31 +01:00
82c41a22d2 Update db 2024-06-24 16:47:54 +02:00
73d51b956a Fix pickers; Add new select option 2024-06-24 16:36:11 +02:00
fac7ff81dd Add tasks 2024-06-12 07:42:54 +02:00
0d55a3b119 Add new options in multiselect 2024-06-11 21:48:13 +02:00
c660b6cbb1 Update tasks 2024-06-09 21:55:45 +02:00
98d2d041d6 Add details editing 2024-06-09 21:46:39 +02:00
bafd8958d4 Handle UDAs for editing; Fix layout; Add annotations 2024-06-09 17:55:56 +02:00
3e1cb9d1bc Next/Prev task edit 2024-06-05 16:29:47 +02:00
0572763e31 Fixes 2024-06-04 16:45:57 +02:00
9aa7b04b98 Fix UDA colors 2024-05-31 13:40:49 +02:00
46 changed files with 8344 additions and 788 deletions

View File

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(ls:*)",
"Bash(go fmt:*)",
"Bash(go build:*)",
"Bash(go vet:*)",
"Bash(timew export:*)"
]
}
}

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.DS_Store
app.log
test/taskchampion.sqlite3
tasksquire
test/*.sqlite3*

207
AGENTS.md Normal file
View File

@ -0,0 +1,207 @@
# 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 Normal file
View File

@ -0,0 +1,236 @@
# 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`)

71
GEMINI.md Normal file
View File

@ -0,0 +1,71 @@
# Tasksquire
## Project Overview
Tasksquire is a Terminal User Interface (TUI) for [Taskwarrior](https://taskwarrior.org/), built using Go and the [Charm](https://charm.sh/) ecosystem (Bubble Tea, Lip Gloss, Huh). It provides a visual and interactive way to manage your tasks, contexts, and reports directly from the terminal.
The application functions as a wrapper around the `task` command-line tool, parsing its output (JSON, config) and executing commands to read and modify task data.
## Architecture
The project follows the standard [Bubble Tea](https://github.com/charmbracelet/bubbletea) Model-View-Update (MVU) architecture.
### Key Directories & Files
* **`main.go`**: The entry point of the application. It initializes the `TaskSquire` wrapper, sets up logging, and starts the Bubble Tea program with the `MainPage`.
* **`taskwarrior/`**: Contains the logic for interacting with the Taskwarrior CLI.
* `taskwarrior.go`: The core wrapper (`TaskSquire` struct) that executes `task` commands (`export`, `add`, `modify`, etc.) and parses results.
* `models.go`: Defines the Go structs matching Taskwarrior's data model (Tasks, Reports, Config).
* **`pages/`**: Contains the different views of the application.
* `main.go`: The top-level component (`MainPage`) that manages routing/switching between different pages.
* `report.go`: Displays lists of tasks (Taskwarrior reports).
* `taskEditor.go`: UI for creating or editing tasks.
* **`common/`**: Shared utilities, global state, and data structures used across the application.
* **`components/`**: Reusable UI components (e.g., inputs, tables).
* **`timewarrior/`**: Contains logic for integration with Timewarrior (likely in progress or planned).
## Building and Running
### Prerequisites
* **Go**: Version 1.22 or higher.
* **Taskwarrior**: The `task` binary must be installed and available in your system's `PATH`.
### Commands
To run the application directly:
```bash
go run main.go
```
To build a binary:
```bash
go build -o tasksquire main.go
```
### Nix Support
This project includes a `flake.nix` for users of the Nix package manager. You can enter a development shell with all dependencies (Go, tools) by running:
```bash
nix develop
```
## Configuration
Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It looks for the configuration file in the following order:
1. `TASKRC` environment variable.
2. `$HOME/.taskrc`
3. `$HOME/.config/task/taskrc`
Logging is written to `app.log` in the current working directory.
## Development Conventions
* **UI Framework**: Uses [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI loop.
* **Styling**: Uses [Lip Gloss](https://github.com/charmbracelet/lipgloss) for terminal styling.
* **Forms**: Uses [Huh](https://github.com/charmbracelet/huh) for form inputs.
* **Logging**: Uses `log/slog` for structured logging.

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# TODO
- [>] Add default tags
- Default tags should be defined in the config and always displayed in the tag picker
- [ ] Add project manager
- [ ] Add projects that are always displayed in the project picker
- Saved in config
- [ ] Remove/archive projects
- [ ] Integrate timewarrior
- [ ] Add default timetracking items when addind projects
- [ ] Create interface for timewarrior input
- [ ] Create daily/weekly reports for HRM input
- Combine by project
- Combine by task names
- Combine by tags
- [ ] Add tag manager
- [ ] Edit default tags
- [ ] Update to bubbletea 2.0
# Done
- [x] Use JJ

View File

@ -6,6 +6,7 @@ import (
"os"
"tasksquire/taskwarrior"
"tasksquire/timewarrior"
"golang.org/x/term"
)
@ -13,20 +14,24 @@ import (
type Common struct {
Ctx context.Context
TW taskwarrior.TaskWarrior
TimeW timewarrior.TimeWarrior
Keymap *Keymap
Styles *Styles
Udas []taskwarrior.Uda
pageStack *Stack[Component]
width int
height int
}
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common {
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior, timeW timewarrior.TimeWarrior) *Common {
return &Common{
Ctx: ctx,
TW: tw,
TimeW: timeW,
Keymap: NewKeymap(),
Styles: NewStyles(tw.GetConfig()),
Udas: tw.GetUdas(),
pageStack: NewStack[Component](),
}
@ -52,5 +57,15 @@ func (c *Common) PushPage(page Component) {
}
func (c *Common) PopPage() (Component, error) {
return c.pageStack.Pop()
component, err := c.pageStack.Pop()
if err != nil {
return nil, err
}
component.SetSize(c.width, c.height)
return component, nil
}
func (c *Common) HasSubpages() bool {
return !c.pageStack.IsEmpty()
}

View File

@ -17,14 +17,22 @@ type Keymap struct {
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
}
// TODO: use config values for key bindings
@ -86,6 +94,26 @@ func NewKeymap() *Keymap {
key.WithHelp("→/l", "Right"),
),
Next: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "Next"),
),
Prev: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "Previous"),
),
NextPage: key.NewBinding(
key.WithKeys("]"),
key.WithHelp("[", "Next page"),
),
PrevPage: key.NewBinding(
key.WithKeys("["),
key.WithHelp("]", "Previous page"),
),
SetReport: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "Set report"),
@ -101,9 +129,14 @@ 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("enter"),
key.WithHelp("enter", "Select"),
key.WithKeys(" "),
key.WithHelp("space", "Select"),
),
Insert: key.NewBinding(
@ -121,9 +154,24 @@ 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"),
),
}
}

View File

@ -38,3 +38,10 @@ 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
}

View File

@ -20,67 +20,36 @@ type TableStyle struct {
}
type Styles struct {
Colors map[string]*lipgloss.Style
Base lipgloss.Style
Form *huh.Theme
TableStyle TableStyle
Tab lipgloss.Style
ActiveTab lipgloss.Style
TabBar lipgloss.Style
ColumnFocused lipgloss.Style
ColumnBlurred lipgloss.Style
ColumnInsert lipgloss.Style
// TODO: make color config completely dynamic to account for keyword., project., tag. and uda. colors
Active lipgloss.Style
Alternate lipgloss.Style
Blocked lipgloss.Style
Blocking lipgloss.Style
BurndownDone lipgloss.Style
BurndownPending lipgloss.Style
BurndownStarted lipgloss.Style
CalendarDue lipgloss.Style
CalendarDueToday lipgloss.Style
CalendarHoliday lipgloss.Style
CalendarOverdue lipgloss.Style
CalendarScheduled lipgloss.Style
CalendarToday lipgloss.Style
CalendarWeekend lipgloss.Style
CalendarWeeknumber lipgloss.Style
Completed lipgloss.Style
Debug lipgloss.Style
Deleted lipgloss.Style
Due lipgloss.Style
DueToday lipgloss.Style
Error lipgloss.Style
Footnote lipgloss.Style
Header lipgloss.Style
HistoryAdd lipgloss.Style
HistoryDelete lipgloss.Style
HistoryDone lipgloss.Style
Label lipgloss.Style
LabelSort lipgloss.Style
Overdue lipgloss.Style
ProjectNone lipgloss.Style
Recurring lipgloss.Style
Scheduled lipgloss.Style
SummaryBackground lipgloss.Style
SummaryBar lipgloss.Style
SyncAdded lipgloss.Style
SyncChanged lipgloss.Style
SyncRejected lipgloss.Style
TagNext lipgloss.Style
TagNone lipgloss.Style
Tagged lipgloss.Style
UdaPriorityH lipgloss.Style
UdaPriorityL lipgloss.Style
UdaPriorityM lipgloss.Style
UndoAfter lipgloss.Style
UndoBefore lipgloss.Style
Until lipgloss.Style
Warning lipgloss.Style
}
func NewStyles(config *taskwarrior.TWConfig) *Styles {
styles := parseColors(config.GetConfig())
styles := Styles{}
colors := make(map[string]*lipgloss.Style)
for key, value := range config.GetConfig() {
if strings.HasPrefix(key, "color.") {
_, color, _ := strings.Cut(key, ".")
colors[color] = parseColorString(value)
}
}
styles.Colors = colors
styles.Base = lipgloss.NewStyle()
styles.TableStyle = TableStyle{
@ -92,11 +61,12 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
formTheme := huh.ThemeBase()
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("> ")
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString(" ")
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("> ")
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString(" ")
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ")
formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true)
formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
@ -105,127 +75,36 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
styles.Form = formTheme
styles.ColumnFocused = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true)
styles.ColumnBlurred = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.HiddenBorder(), true)
styles.ColumnInsert = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true).BorderForeground(styles.Active.GetForeground())
styles.Tab = lipgloss.NewStyle().
Padding(0, 1).
Foreground(lipgloss.Color("240"))
return styles
}
styles.ActiveTab = styles.Tab.
Foreground(lipgloss.Color("252")).
Bold(true)
func parseColors(config map[string]string) *Styles {
styles := Styles{}
styles.TabBar = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(lipgloss.Color("240")).
MarginBottom(1)
for key, value := range config {
if strings.HasPrefix(key, "color.") {
_, colorValue, _ := strings.Cut(key, ".")
switch colorValue {
case "active":
styles.Active = parseColorString(value)
case "alternate":
styles.Alternate = parseColorString(value)
case "blocked":
styles.Blocked = parseColorString(value)
case "blocking":
styles.Blocking = parseColorString(value)
case "burndown.done":
styles.BurndownDone = parseColorString(value)
case "burndown.pending":
styles.BurndownPending = parseColorString(value)
case "burndown.started":
styles.BurndownStarted = parseColorString(value)
case "calendar.due":
styles.CalendarDue = parseColorString(value)
case "calendar.due.today":
styles.CalendarDueToday = parseColorString(value)
case "calendar.holiday":
styles.CalendarHoliday = parseColorString(value)
case "calendar.overdue":
styles.CalendarOverdue = parseColorString(value)
case "calendar.scheduled":
styles.CalendarScheduled = parseColorString(value)
case "calendar.today":
styles.CalendarToday = parseColorString(value)
case "calendar.weekend":
styles.CalendarWeekend = parseColorString(value)
case "calendar.weeknumber":
styles.CalendarWeeknumber = parseColorString(value)
case "completed":
styles.Completed = parseColorString(value)
case "debug":
styles.Debug = parseColorString(value)
case "deleted":
styles.Deleted = parseColorString(value)
case "due":
styles.Due = parseColorString(value)
case "due.today":
styles.DueToday = parseColorString(value)
case "error":
styles.Error = parseColorString(value)
case "footnote":
styles.Footnote = parseColorString(value)
case "header":
styles.Header = parseColorString(value)
case "history.add":
styles.HistoryAdd = parseColorString(value)
case "history.delete":
styles.HistoryDelete = parseColorString(value)
case "history.done":
styles.HistoryDone = parseColorString(value)
case "label":
styles.Label = parseColorString(value)
case "label.sort":
styles.LabelSort = parseColorString(value)
case "overdue":
styles.Overdue = parseColorString(value)
case "project.none":
styles.ProjectNone = parseColorString(value)
case "recurring":
styles.Recurring = parseColorString(value)
case "scheduled":
styles.Scheduled = parseColorString(value)
case "summary.background":
styles.SummaryBackground = parseColorString(value)
case "summary.bar":
styles.SummaryBar = parseColorString(value)
case "sync.added":
styles.SyncAdded = parseColorString(value)
case "sync.changed":
styles.SyncChanged = parseColorString(value)
case "sync.rejected":
styles.SyncRejected = parseColorString(value)
case "tag.next":
styles.TagNext = parseColorString(value)
case "tag.none":
styles.TagNone = parseColorString(value)
case "tagged":
styles.Tagged = parseColorString(value)
case "uda.priority.H":
styles.UdaPriorityH = parseColorString(value)
case "uda.priority.L":
styles.UdaPriorityL = parseColorString(value)
case "uda.priority.M":
styles.UdaPriorityM = parseColorString(value)
case "undo.after":
styles.UndoAfter = parseColorString(value)
case "undo.before":
styles.UndoBefore = parseColorString(value)
case "until":
styles.Until = parseColorString(value)
case "warning":
styles.Warning = parseColorString(value)
}
}
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)
if styles.Colors["active"] != nil {
styles.ColumnInsert = styles.ColumnInsert.BorderForeground(styles.Colors["active"].GetForeground())
}
return &styles
}
func parseColorString(color string) lipgloss.Style {
style := lipgloss.NewStyle()
func parseColorString(color string) *lipgloss.Style {
if color == "" {
return style
return nil
}
style := lipgloss.NewStyle()
if strings.Contains(color, "on") {
fgbg := strings.Split(color, "on")
fg := strings.TrimSpace(fgbg[0])
@ -240,7 +119,7 @@ func parseColorString(color string) lipgloss.Style {
style = style.Foreground(parseColor(strings.TrimSpace(color)))
}
return style
return &style
}
func parseColor(color string) lipgloss.Color {

85
common/sync.go Normal file
View File

@ -0,0 +1,85 @@
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)
}
}

View File

@ -0,0 +1,293 @@
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
}

View File

@ -0,0 +1,174 @@
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
}

View File

@ -0,0 +1,672 @@
package input
import (
"fmt"
"strings"
"tasksquire/common"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/huh/accessibility"
"github.com/charmbracelet/lipgloss"
)
// MultiSelect is a form multi-select field.
type MultiSelect struct {
common *common.Common
value *[]string
key string
// customization
title string
description string
options []Option[string]
filterable bool
filteredOptions []Option[string]
limit int
height int
// error handling
validate func([]string) error
err error
// state
cursor int
focused bool
filtering bool
filter textinput.Model
viewport viewport.Model
// options
width int
accessible bool
theme *huh.Theme
keymap huh.MultiSelectKeyMap
// new
hasNewOption bool
newInput textinput.Model
newInputActive bool
}
// NewMultiSelect returns a new multi-select field.
func NewMultiSelect(common *common.Common) *MultiSelect {
filter := textinput.New()
filter.Prompt = "/"
newInput := textinput.New()
newInput.Prompt = "New: "
return &MultiSelect{
common: common,
options: []Option[string]{},
value: new([]string),
validate: func([]string) error { return nil },
filtering: false,
filter: filter,
newInput: newInput,
newInputActive: false,
}
}
// Value sets the value of the multi-select field.
func (m *MultiSelect) Value(value *[]string) *MultiSelect {
m.value = value
for i, o := range m.options {
for _, v := range *value {
if o.Value == v {
m.options[i].selected = true
break
}
}
}
return m
}
// Key sets the key of the select field which can be used to retrieve the value
// after submission.
func (m *MultiSelect) Key(key string) *MultiSelect {
m.key = key
return m
}
// Title sets the title of the multi-select field.
func (m *MultiSelect) Title(title string) *MultiSelect {
m.title = title
return m
}
// Description sets the description of the multi-select field.
func (m *MultiSelect) Description(description string) *MultiSelect {
m.description = description
return m
}
// Options sets the options of the multi-select field.
func (m *MultiSelect) Options(hasNewOption bool, options ...Option[string]) *MultiSelect {
m.hasNewOption = hasNewOption
if m.hasNewOption {
newOption := []Option[string]{
{Key: "(new)", Value: ""},
}
options = append(newOption, options...)
}
if len(options) <= 0 {
return m
}
for i, o := range options {
for _, v := range *m.value {
if o.Value == v {
options[i].selected = true
break
}
}
}
m.options = options
m.filteredOptions = options
m.updateViewportHeight()
return m
}
// Filterable sets the multi-select field as filterable.
func (m *MultiSelect) Filterable(filterable bool) *MultiSelect {
m.filterable = filterable
return m
}
// Limit sets the limit of the multi-select field.
func (m *MultiSelect) Limit(limit int) *MultiSelect {
m.limit = limit
return m
}
// Height sets the height of the multi-select field.
func (m *MultiSelect) Height(height int) *MultiSelect {
// What we really want to do is set the height of the viewport, but we
// need a theme applied before we can calcualate its height.
m.height = height
m.updateViewportHeight()
return m
}
// Validate sets the validation function of the multi-select field.
func (m *MultiSelect) Validate(validate func([]string) error) *MultiSelect {
m.validate = validate
return m
}
// Error returns the error of the multi-select field.
func (m *MultiSelect) Error() error {
return m.err
}
// Skip returns whether the multiselect should be skipped or should be blocking.
func (*MultiSelect) Skip() bool {
return false
}
// Zoom returns whether the multiselect should be zoomed.
func (*MultiSelect) Zoom() bool {
return false
}
// Focus focuses the multi-select field.
func (m *MultiSelect) Focus() tea.Cmd {
m.focused = true
return nil
}
// Blur blurs the multi-select field.
func (m *MultiSelect) Blur() tea.Cmd {
m.focused = false
return nil
}
// KeyBinds returns the help message for the multi-select field.
func (m *MultiSelect) KeyBinds() []key.Binding {
return []key.Binding{
m.keymap.Toggle,
m.keymap.Up,
m.keymap.Down,
m.keymap.Filter,
m.keymap.SetFilter,
m.keymap.ClearFilter,
m.keymap.Prev,
m.keymap.Submit,
m.keymap.Next,
}
}
// Init initializes the multi-select field.
func (m *MultiSelect) Init() tea.Cmd {
return nil
}
// Update updates the multi-select field.
func (m *MultiSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Enforce height on the viewport during update as we need themes to
// be applied before we can calculate the height.
m.updateViewportHeight()
var cmd tea.Cmd
if m.filtering {
m.filter, cmd = m.filter.Update(msg)
}
if m.newInputActive {
m.newInput, cmd = m.newInput.Update(msg)
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.common.Keymap.Ok):
newOptions := []Option[string]{}
for _, item := range strings.Split(m.newInput.Value(), " ") {
newOptions = append(newOptions, Option[string]{
Key: item,
Value: item,
selected: true,
})
}
m.options = append(m.options, newOptions...)
filteredNewOptions := []Option[string]{}
for _, item := range newOptions {
if m.filterFunc(item.Key) {
filteredNewOptions = append(filteredNewOptions, item)
}
}
m.filteredOptions = append(m.filteredOptions, filteredNewOptions...)
m.newInputActive = false
m.newInput.SetValue("")
m.newInput.Blur()
case key.Matches(msg, m.common.Keymap.Back):
m.newInputActive = false
m.newInput.Blur()
return m, SuppressBack()
}
}
}
switch msg := msg.(type) {
case tea.KeyMsg:
m.err = nil
switch {
case key.Matches(msg, m.keymap.Filter):
m.setFilter(true)
return m, m.filter.Focus()
case key.Matches(msg, m.keymap.SetFilter) && m.filtering:
if len(m.filteredOptions) <= 0 {
m.filter.SetValue("")
m.filteredOptions = m.options
}
m.setFilter(false)
case key.Matches(msg, m.common.Keymap.Back) && m.filtering:
m.filter.SetValue("")
m.filteredOptions = m.options
m.setFilter(false)
case key.Matches(msg, m.keymap.ClearFilter):
m.filter.SetValue("")
m.filteredOptions = m.options
m.setFilter(false)
case key.Matches(msg, m.keymap.Up):
if m.filtering && msg.String() == "k" {
break
}
m.cursor = max(m.cursor-1, 0)
if m.cursor < m.viewport.YOffset {
m.viewport.SetYOffset(m.cursor)
}
case key.Matches(msg, m.keymap.Down):
if m.filtering && msg.String() == "j" {
break
}
m.cursor = min(m.cursor+1, len(m.filteredOptions)-1)
if m.cursor >= m.viewport.YOffset+m.viewport.Height {
m.viewport.LineDown(1)
}
case key.Matches(msg, m.keymap.GotoTop):
if m.filtering {
break
}
m.cursor = 0
m.viewport.GotoTop()
case key.Matches(msg, m.keymap.GotoBottom):
if m.filtering {
break
}
m.cursor = len(m.filteredOptions) - 1
m.viewport.GotoBottom()
case key.Matches(msg, m.keymap.HalfPageUp):
m.cursor = max(m.cursor-m.viewport.Height/2, 0)
m.viewport.HalfViewUp()
case key.Matches(msg, m.keymap.HalfPageDown):
m.cursor = min(m.cursor+m.viewport.Height/2, len(m.filteredOptions)-1)
m.viewport.HalfViewDown()
case key.Matches(msg, m.keymap.Toggle) && !m.filtering:
if m.hasNewOption && m.cursor == 0 {
m.newInputActive = true
m.newInput.Focus()
} else {
for i, option := range m.options {
if option.Key == m.filteredOptions[m.cursor].Key {
if !m.options[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit {
break
}
selected := m.options[i].selected
m.options[i].selected = !selected
m.filteredOptions[m.cursor].selected = !selected
m.finalize()
}
}
}
case key.Matches(msg, m.keymap.Prev):
m.finalize()
if m.err != nil {
return m, nil
}
return m, huh.PrevField
case key.Matches(msg, m.keymap.Next, m.keymap.Submit):
m.finalize()
if m.err != nil {
return m, nil
}
return m, huh.NextField
}
if m.filtering {
m.filteredOptions = m.options
if m.filter.Value() != "" {
m.filteredOptions = nil
for _, option := range m.options {
if m.filterFunc(option.Key) {
m.filteredOptions = append(m.filteredOptions, option)
}
}
}
if len(m.filteredOptions) > 0 {
m.cursor = min(m.cursor, len(m.filteredOptions)-1)
m.viewport.SetYOffset(clamp(m.cursor, 0, len(m.filteredOptions)-m.viewport.Height))
}
}
}
return m, cmd
}
// updateViewportHeight updates the viewport size according to the Height setting
// on this multi-select field.
func (m *MultiSelect) updateViewportHeight() {
// If no height is set size the viewport to the number of options.
if m.height <= 0 {
m.viewport.Height = len(m.options)
return
}
const minHeight = 1
m.viewport.Height = max(minHeight, m.height-
lipgloss.Height(m.titleView())-
lipgloss.Height(m.descriptionView()))
}
func (m *MultiSelect) numSelected() int {
var count int
for _, o := range m.options {
if o.selected {
count++
}
}
return count
}
func (m *MultiSelect) finalize() {
*m.value = make([]string, 0)
for _, option := range m.options {
if option.selected {
*m.value = append(*m.value, option.Value)
}
}
m.err = m.validate(*m.value)
}
func (m *MultiSelect) activeStyles() *huh.FieldStyles {
theme := m.theme
if theme == nil {
theme = huh.ThemeCharm()
}
if m.focused {
return &theme.Focused
}
return &theme.Blurred
}
func (m *MultiSelect) titleView() string {
if m.title == "" {
return ""
}
var (
styles = m.activeStyles()
sb = strings.Builder{}
)
if m.filtering {
sb.WriteString(m.filter.View())
} else if m.filter.Value() != "" {
sb.WriteString(styles.Title.Render(m.title) + styles.Description.Render("/"+m.filter.Value()))
} else {
sb.WriteString(styles.Title.Render(m.title))
}
if m.err != nil {
sb.WriteString(styles.ErrorIndicator.String())
}
return sb.String()
}
func (m *MultiSelect) descriptionView() string {
return m.activeStyles().Description.Render(m.description)
}
func (m *MultiSelect) choicesView() string {
var (
styles = m.activeStyles()
c = styles.MultiSelectSelector.String()
sb strings.Builder
)
for i, option := range m.filteredOptions {
if m.newInputActive && i == 0 {
sb.WriteString(c)
sb.WriteString(m.newInput.View())
sb.WriteString("\n")
continue
} else if m.cursor == i {
sb.WriteString(c)
} else {
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)))
}
if m.filteredOptions[i].selected {
sb.WriteString(styles.SelectedPrefix.String())
sb.WriteString(styles.SelectedOption.Render(option.Key))
} else {
sb.WriteString(styles.UnselectedPrefix.String())
sb.WriteString(styles.UnselectedOption.Render(option.Key))
}
if i < len(m.options)-1 {
sb.WriteString("\n")
}
}
for i := len(m.filteredOptions); i < len(m.options)-1; i++ {
sb.WriteString("\n")
}
return sb.String()
}
// View renders the multi-select field.
func (m *MultiSelect) View() string {
styles := m.activeStyles()
m.viewport.SetContent(m.choicesView())
var sb strings.Builder
if m.title != "" {
sb.WriteString(m.titleView())
sb.WriteString("\n")
}
if m.description != "" {
sb.WriteString(m.descriptionView() + "\n")
}
sb.WriteString(m.viewport.View())
return styles.Base.Render(sb.String())
}
func (m *MultiSelect) printOptions() {
styles := m.activeStyles()
var sb strings.Builder
sb.WriteString(styles.Title.Render(m.title))
sb.WriteString("\n")
for i, option := range m.options {
if option.selected {
sb.WriteString(styles.SelectedOption.Render(fmt.Sprintf("%d. %s %s", i+1, "✓", option.Key)))
} else {
sb.WriteString(fmt.Sprintf("%d. %s %s", i+1, " ", option.Key))
}
sb.WriteString("\n")
}
fmt.Println(sb.String())
}
// setFilter sets the filter of the select field.
func (m *MultiSelect) setFilter(filter bool) {
m.filtering = filter
m.keymap.SetFilter.SetEnabled(filter)
m.keymap.Filter.SetEnabled(!filter)
m.keymap.Next.SetEnabled(!filter)
m.keymap.Submit.SetEnabled(!filter)
m.keymap.Prev.SetEnabled(!filter)
m.keymap.ClearFilter.SetEnabled(!filter && m.filter.Value() != "")
}
// filterFunc returns true if the option matches the filter.
func (m *MultiSelect) filterFunc(option string) bool {
// XXX: remove diacritics or allow customization of filter function.
return strings.Contains(strings.ToLower(option), strings.ToLower(m.filter.Value()))
}
// Run runs the multi-select field.
func (m *MultiSelect) Run() error {
if m.accessible {
return m.runAccessible()
}
return huh.Run(m)
}
// runAccessible() runs the multi-select field in accessible mode.
func (m *MultiSelect) runAccessible() error {
m.printOptions()
styles := m.activeStyles()
var choice int
for {
fmt.Printf("Select up to %d options. 0 to continue.\n", m.limit)
choice = accessibility.PromptInt("Select: ", 0, len(m.options))
if choice == 0 {
m.finalize()
err := m.validate(*m.value)
if err != nil {
fmt.Println(err)
continue
}
break
}
if !m.options[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit {
fmt.Printf("You can't select more than %d options.\n", m.limit)
continue
}
m.options[choice-1].selected = !m.options[choice-1].selected
if m.options[choice-1].selected {
fmt.Printf("Selected: %s\n\n", m.options[choice-1].Key)
} else {
fmt.Printf("Deselected: %s\n\n", m.options[choice-1].Key)
}
m.printOptions()
}
var values []string
for _, option := range m.options {
if option.selected {
*m.value = append(*m.value, option.Value)
values = append(values, option.Key)
}
}
fmt.Println(styles.SelectedOption.Render("Selected:", strings.Join(values, ", ")+"\n"))
return nil
}
// WithTheme sets the theme of the multi-select field.
func (m *MultiSelect) WithTheme(theme *huh.Theme) huh.Field {
if m.theme != nil {
return m
}
m.theme = theme
m.filter.Cursor.Style = m.theme.Focused.TextInput.Cursor
m.filter.PromptStyle = m.theme.Focused.TextInput.Prompt
m.updateViewportHeight()
return m
}
// WithKeyMap sets the keymap of the multi-select field.
func (m *MultiSelect) WithKeyMap(k *huh.KeyMap) huh.Field {
m.keymap = k.MultiSelect
return m
}
// WithAccessible sets the accessible mode of the multi-select field.
func (m *MultiSelect) WithAccessible(accessible bool) huh.Field {
m.accessible = accessible
return m
}
// WithWidth sets the width of the multi-select field.
func (m *MultiSelect) WithWidth(width int) huh.Field {
m.width = width
return m
}
// WithHeight sets the height of the multi-select field.
func (m *MultiSelect) WithHeight(height int) huh.Field {
m.height = height
return m
}
// WithPosition sets the position of the multi-select field.
func (m *MultiSelect) WithPosition(p huh.FieldPosition) huh.Field {
if m.filtering {
return m
}
m.keymap.Prev.SetEnabled(!p.IsFirst())
m.keymap.Next.SetEnabled(!p.IsLast())
m.keymap.Submit.SetEnabled(p.IsLast())
return m
}
// GetKey returns the multi-select's key.
func (m *MultiSelect) GetKey() string {
return m.key
}
// GetValue returns the multi-select's value.
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
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func clamp(n, low, high int) int {
if low > high {
low, high = high, low
}
return min(high, max(low, n))
}
type SuppressBackMsg struct{}
func SuppressBack() tea.Cmd {
return func() tea.Msg {
return SuppressBackMsg{}
}
}

View File

@ -0,0 +1,38 @@
package input
import "fmt"
// Option is an option for select fields.
type Option[T comparable] struct {
Key string
Value T
selected bool
}
// NewOptions returns new options from a list of values.
func NewOptions[T comparable](values ...T) []Option[T] {
options := make([]Option[T], len(values))
for i, o := range values {
options[i] = Option[T]{
Key: fmt.Sprint(o),
Value: o,
}
}
return options
}
// NewOption returns a new select option.
func NewOption[T comparable](key string, value T) Option[T] {
return Option[T]{Key: key, Value: value}
}
// Selected sets whether the option is currently selected.
func (o Option[T]) Selected(selected bool) Option[T] {
o.selected = selected
return o
}
// String returns the key of the option.
func (o Option[T]) String() string {
return o.Key
}

618
components/input/select.go Normal file
View File

@ -0,0 +1,618 @@
package input
import (
"fmt"
"strings"
"tasksquire/common"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/huh/accessibility"
"github.com/charmbracelet/lipgloss"
)
// Select is a form select field.
type Select struct {
common *common.Common
value *string
key string
viewport viewport.Model
// customization
title string
description string
options []Option[string]
filteredOptions []Option[string]
height int
// error handling
validate func(string) error
err error
// state
selected int
focused bool
filtering bool
filter textinput.Model
// options
inline bool
width int
accessible bool
theme *huh.Theme
keymap huh.SelectKeyMap
// new
hasNewOption bool
newInput textinput.Model
newInputActive bool
}
// NewSelect returns a new select field.
func NewSelect(com *common.Common) *Select {
filter := textinput.New()
filter.Prompt = "/"
newInput := textinput.New()
newInput.Prompt = "New: "
return &Select{
common: com,
options: []Option[string]{},
value: new(string),
validate: func(string) error { return nil },
filtering: false,
filter: filter,
newInput: newInput,
newInputActive: false,
}
}
// Value sets the value of the select field.
func (s *Select) Value(value *string) *Select {
s.value = value
s.selectValue(*value)
return s
}
func (s *Select) selectValue(value string) {
for i, o := range s.options {
if o.Value == value {
s.selected = i
break
}
}
}
// Key sets the key of the select field which can be used to retrieve the value
// after submission.
func (s *Select) Key(key string) *Select {
s.key = key
return s
}
// Title sets the title of the select field.
func (s *Select) Title(title string) *Select {
s.title = title
return s
}
// Description sets the description of the select field.
func (s *Select) Description(description string) *Select {
s.description = description
return s
}
// Options sets the options of the select field.
func (s *Select) Options(hasNewOption bool, options ...Option[string]) *Select {
s.hasNewOption = hasNewOption
if s.hasNewOption {
newOption := []Option[string]{
{Key: "(new)", Value: ""},
}
options = append(newOption, options...)
}
if len(options) <= 0 {
return s
}
s.options = options
s.filteredOptions = options
// Set the cursor to the existing value or the last selected option.
for i, option := range options {
if option.Value == *s.value {
s.selected = i
break
} else if option.selected {
s.selected = i
}
}
s.updateViewportHeight()
return s
}
// Inline sets whether the select input should be inline.
func (s *Select) Inline(v bool) *Select {
s.inline = v
if v {
s.Height(1)
}
s.keymap.Left.SetEnabled(v)
s.keymap.Right.SetEnabled(v)
s.keymap.Up.SetEnabled(!v)
s.keymap.Down.SetEnabled(!v)
return s
}
// Height sets the height of the select field. If the number of options
// exceeds the height, the select field will become scrollable.
func (s *Select) Height(height int) *Select {
s.height = height
s.updateViewportHeight()
return s
}
// Validate sets the validation function of the select field.
func (s *Select) Validate(validate func(string) error) *Select {
s.validate = validate
return s
}
// Error returns the error of the select field.
func (s *Select) Error() error {
return s.err
}
// Skip returns whether the select should be skipped or should be blocking.
func (*Select) Skip() bool {
return false
}
// Zoom returns whether the input should be zoomed.
func (*Select) Zoom() bool {
return false
}
// Focus focuses the select field.
func (s *Select) Focus() tea.Cmd {
s.focused = true
return nil
}
// Blur blurs the select field.
func (s *Select) Blur() tea.Cmd {
value := *s.value
if s.inline {
s.clearFilter()
s.selectValue(value)
}
s.focused = false
s.err = s.validate(value)
return nil
}
// KeyBinds returns the help keybindings for the select field.
func (s *Select) KeyBinds() []key.Binding {
return []key.Binding{
s.keymap.Up,
s.keymap.Down,
s.keymap.Left,
s.keymap.Right,
s.keymap.Filter,
s.keymap.SetFilter,
s.keymap.ClearFilter,
s.keymap.Prev,
s.keymap.Next,
s.keymap.Submit,
}
}
// Init initializes the select field.
func (s *Select) Init() tea.Cmd {
return nil
}
// Update updates the select field.
func (s *Select) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.updateViewportHeight()
var cmd tea.Cmd
if s.filtering {
s.filter, cmd = s.filter.Update(msg)
// Keep the selected item in view.
if s.selected < s.viewport.YOffset || s.selected >= s.viewport.YOffset+s.viewport.Height {
s.viewport.SetYOffset(s.selected)
}
}
if s.newInputActive {
s.newInput, cmd = s.newInput.Update(msg)
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, s.common.Keymap.Ok):
if s.newInput.Value() != "" {
newOption := Option[string]{
Key: s.newInput.Value(),
Value: s.newInput.Value(),
selected: true,
}
s.options = append(s.options, newOption)
if s.filterFunc(newOption.Key) {
s.filteredOptions = append(s.filteredOptions, newOption)
}
s.selected = len(s.options) - 1
value := newOption.Value
s.setFiltering(false)
s.err = s.validate(value)
if s.err != nil {
return s, nil
}
*s.value = value
}
s.newInputActive = false
s.newInput.SetValue("")
s.newInput.Blur()
return s, nil
case key.Matches(msg, s.common.Keymap.Back):
s.newInputActive = false
s.newInput.Blur()
return s, SuppressBack()
}
}
}
switch msg := msg.(type) {
case tea.KeyMsg:
s.err = nil
switch {
case key.Matches(msg, s.keymap.Filter):
s.setFiltering(true)
return s, s.filter.Focus()
case key.Matches(msg, s.keymap.SetFilter):
if len(s.filteredOptions) <= 0 {
s.filter.SetValue("")
s.filteredOptions = s.options
}
s.setFiltering(false)
case key.Matches(msg, s.keymap.ClearFilter):
s.clearFilter()
case key.Matches(msg, s.keymap.Up, s.keymap.Left):
// When filtering we should ignore j/k keybindings
//
// XXX: Currently, the below check doesn't account for keymap
// changes. When making this fix it's worth considering ignoring
// whether to ignore all up/down keybindings as ignoring a-zA-Z0-9
// may not be enough when international keyboards are considered.
if s.filtering && (msg.String() == "k" || msg.String() == "h") {
break
}
s.selected = max(s.selected-1, 0)
if s.selected < s.viewport.YOffset {
s.viewport.SetYOffset(s.selected)
}
case key.Matches(msg, s.keymap.GotoTop):
if s.filtering {
break
}
s.selected = 0
s.viewport.GotoTop()
case key.Matches(msg, s.keymap.GotoBottom):
if s.filtering {
break
}
s.selected = len(s.filteredOptions) - 1
s.viewport.GotoBottom()
case key.Matches(msg, s.keymap.HalfPageUp):
s.selected = max(s.selected-s.viewport.Height/2, 0)
s.viewport.HalfViewUp()
case key.Matches(msg, s.keymap.HalfPageDown):
s.selected = min(s.selected+s.viewport.Height/2, len(s.filteredOptions)-1)
s.viewport.HalfViewDown()
case key.Matches(msg, s.keymap.Down, s.keymap.Right):
// When filtering we should ignore j/k keybindings
//
// XXX: See note in the previous case match.
if s.filtering && (msg.String() == "j" || msg.String() == "l") {
break
}
s.selected = min(s.selected+1, len(s.filteredOptions)-1)
if s.selected >= s.viewport.YOffset+s.viewport.Height {
s.viewport.LineDown(1)
}
case key.Matches(msg, s.keymap.Prev):
if s.selected >= len(s.filteredOptions) {
break
}
value := s.filteredOptions[s.selected].Value
s.err = s.validate(value)
if s.err != nil {
return s, nil
}
*s.value = value
return s, huh.PrevField
case key.Matches(msg, s.common.Keymap.Select):
if s.hasNewOption && s.selected == 0 {
s.newInputActive = true
s.newInput.Focus()
return s, nil
}
case key.Matches(msg, s.keymap.Next, s.keymap.Submit):
if s.selected >= len(s.filteredOptions) {
break
}
value := s.filteredOptions[s.selected].Value
s.setFiltering(false)
s.err = s.validate(value)
if s.err != nil {
return s, nil
}
*s.value = value
return s, huh.NextField
}
if s.filtering {
s.filteredOptions = s.options
if s.filter.Value() != "" {
s.filteredOptions = nil
for _, option := range s.options {
if s.filterFunc(option.Key) {
s.filteredOptions = append(s.filteredOptions, option)
}
}
}
if len(s.filteredOptions) > 0 {
s.selected = min(s.selected, len(s.filteredOptions)-1)
s.viewport.SetYOffset(clamp(s.selected, 0, len(s.filteredOptions)-s.viewport.Height))
}
}
}
return s, cmd
}
// updateViewportHeight updates the viewport size according to the Height setting
// on this select field.
func (s *Select) updateViewportHeight() {
// If no height is set size the viewport to the number of options.
if s.height <= 0 {
s.viewport.Height = len(s.options)
return
}
const minHeight = 1
s.viewport.Height = max(minHeight, s.height-
lipgloss.Height(s.titleView())-
lipgloss.Height(s.descriptionView()))
}
func (s *Select) activeStyles() *huh.FieldStyles {
theme := s.theme
if theme == nil {
theme = huh.ThemeCharm()
}
if s.focused {
return &theme.Focused
}
return &theme.Blurred
}
func (s *Select) titleView() string {
if s.title == "" {
return ""
}
var (
styles = s.activeStyles()
sb = strings.Builder{}
)
if s.filtering {
sb.WriteString(styles.Title.Render(s.filter.View()))
} else if s.filter.Value() != "" && !s.inline {
sb.WriteString(styles.Title.Render(s.title) + styles.Description.Render("/"+s.filter.Value()))
} else {
sb.WriteString(styles.Title.Render(s.title))
}
if s.err != nil {
sb.WriteString(styles.ErrorIndicator.String())
}
return sb.String()
}
func (s *Select) descriptionView() string {
return s.activeStyles().Description.Render(s.description)
}
func (s *Select) choicesView() string {
var (
styles = s.activeStyles()
c = styles.SelectSelector.String()
sb strings.Builder
)
if s.inline {
sb.WriteString(styles.PrevIndicator.Faint(s.selected <= 0).String())
if len(s.filteredOptions) > 0 {
sb.WriteString(styles.SelectedOption.Render(s.filteredOptions[s.selected].Key))
} else {
sb.WriteString(styles.TextInput.Placeholder.Render("No matches"))
}
sb.WriteString(styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String())
return sb.String()
}
for i, option := range s.filteredOptions {
if s.newInputActive && i == 0 {
sb.WriteString(c)
sb.WriteString(s.newInput.View())
sb.WriteString("\n")
continue
} else if s.selected == i {
sb.WriteString(c + styles.SelectedOption.Render(option.Key))
} else {
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.Option.Render(option.Key))
}
if i < len(s.options)-1 {
sb.WriteString("\n")
}
}
for i := len(s.filteredOptions); i < len(s.options)-1; i++ {
sb.WriteString("\n")
}
return sb.String()
}
// View renders the select field.
func (s *Select) View() string {
styles := s.activeStyles()
s.viewport.SetContent(s.choicesView())
var sb strings.Builder
if s.title != "" {
sb.WriteString(s.titleView())
if !s.inline {
sb.WriteString("\n")
}
}
if s.description != "" {
sb.WriteString(s.descriptionView())
if !s.inline {
sb.WriteString("\n")
}
}
sb.WriteString(s.viewport.View())
return styles.Base.Render(sb.String())
}
// clearFilter clears the value of the filter.
func (s *Select) clearFilter() {
s.filter.SetValue("")
s.filteredOptions = s.options
s.setFiltering(false)
}
// setFiltering sets the filter of the select field.
func (s *Select) setFiltering(filtering bool) {
if s.inline && filtering {
s.filter.Width = lipgloss.Width(s.titleView()) - 1 - 1
}
s.filtering = filtering
s.keymap.SetFilter.SetEnabled(filtering)
s.keymap.Filter.SetEnabled(!filtering)
s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "")
}
// filterFunc returns true if the option matches the filter.
func (s *Select) filterFunc(option string) bool {
// XXX: remove diacritics or allow customization of filter function.
return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value()))
}
// Run runs the select field.
func (s *Select) Run() error {
if s.accessible {
return s.runAccessible()
}
return huh.Run(s)
}
// runAccessible runs an accessible select field.
func (s *Select) runAccessible() error {
var sb strings.Builder
styles := s.activeStyles()
sb.WriteString(styles.Title.Render(s.title) + "\n")
for i, option := range s.options {
sb.WriteString(fmt.Sprintf("%d. %s", i+1, option.Key))
sb.WriteString("\n")
}
fmt.Println(sb.String())
for {
choice := accessibility.PromptInt("Choose: ", 1, len(s.options))
option := s.options[choice-1]
if err := s.validate(option.Value); err != nil {
fmt.Println(err.Error())
continue
}
fmt.Println(styles.SelectedOption.Render("Chose: " + option.Key + "\n"))
*s.value = option.Value
break
}
return nil
}
// WithTheme sets the theme of the select field.
func (s *Select) WithTheme(theme *huh.Theme) huh.Field {
if s.theme != nil {
return s
}
s.theme = theme
s.filter.Cursor.Style = s.theme.Focused.TextInput.Cursor
s.filter.PromptStyle = s.theme.Focused.TextInput.Prompt
s.updateViewportHeight()
return s
}
// WithKeyMap sets the keymap on a select field.
func (s *Select) WithKeyMap(k *huh.KeyMap) huh.Field {
s.keymap = k.Select
s.keymap.Left.SetEnabled(s.inline)
s.keymap.Right.SetEnabled(s.inline)
s.keymap.Up.SetEnabled(!s.inline)
s.keymap.Down.SetEnabled(!s.inline)
return s
}
// WithAccessible sets the accessible mode of the select field.
func (s *Select) WithAccessible(accessible bool) huh.Field {
s.accessible = accessible
return s
}
// WithWidth sets the width of the select field.
func (s *Select) WithWidth(width int) huh.Field {
s.width = width
return s
}
// WithHeight sets the height of the select field.
func (s *Select) WithHeight(height int) huh.Field {
return s.Height(height)
}
// WithPosition sets the position of the select field.
func (s *Select) WithPosition(p huh.FieldPosition) huh.Field {
if s.filtering {
return s
}
s.keymap.Prev.SetEnabled(!p.IsFirst())
s.keymap.Next.SetEnabled(!p.IsLast())
s.keymap.Submit.SetEnabled(p.IsLast())
return s
}
// GetKey returns the key of the field.
func (s *Select) GetKey() string {
return s.key
}
// GetValue returns the value of the field.
func (s *Select) GetValue() any {
return *s.value
}

285
components/picker/picker.go Normal file
View File

@ -0,0 +1,285 @@
package picker
import (
"tasksquire/common"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Item struct {
text string
}
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 }
// creationItem is a special item for creating new entries
type creationItem struct {
text string
filter string
}
func (i creationItem) Title() string { return i.text }
func (i creationItem) Description() string { return "" }
func (i creationItem) FilterValue() string { return i.filter }
type Picker struct {
common *common.Common
list list.Model
itemProvider func() []list.Item
onSelect func(list.Item) tea.Cmd
onCreate func(string) tea.Cmd
title string
filterByDefault bool
defaultValue string
baseItems []list.Item
focused bool
}
type PickerOption func(*Picker)
func WithFilterByDefault(enabled bool) PickerOption {
return func(p *Picker) {
p.filterByDefault = enabled
}
}
func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption {
return func(p *Picker) {
p.onCreate = onCreate
}
}
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,
itemProvider func() []list.Item,
onSelect func(list.Item) tea.Cmd,
opts ...PickerOption,
) *Picker {
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = false
delegate.SetSpacing(0)
l := list.New([]list.Item{}, delegate, 0, 0)
l.SetShowTitle(false)
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(
key.WithKeys("i"),
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" {
p.filterByDefault = true
}
for _, opt := range opts {
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
}
func (p *Picker) Refresh() tea.Cmd {
p.baseItems = p.itemProvider()
return p.updateListItems()
}
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...)
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)
}
return p.list.SetItems(items)
}
func (p *Picker) SetSize(width, height int) {
// We do NOT set common.SetSize here, as we are a sub-component.
// Set list size. The parent is responsible for providing a reasonable size.
// If this component is intended to fill a page, width/height will be large.
// If it's a small embedded box, they will be small.
// We apply a small margin for the title if needed, but for now we just pass through
// minus a header gap if we render a title.
headerHeight := 2 // Title + gap
p.list.SetSize(width, height-headerHeight)
}
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()
}
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 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]
}
}
// 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
}
switch {
case key.Matches(msg, p.common.Keymap.Ok):
selectedItem := p.list.SelectedItem()
if selectedItem == nil {
return p, nil
}
return p, p.handleSelect(selectedItem)
}
}
p.list, cmd = p.list.Update(msg)
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
}
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
if cItem, ok := item.(creationItem); ok {
if p.onCreate != nil {
return p.onCreate(cItem.filter)
}
}
return p.onSelect(item)
}
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)
}
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
}
func (p *Picker) IsFiltering() bool {
return p.list.FilterState() == list.Filtering
}
// SelectItemByFilterValue selects the item with the given filter value
func (p *Picker) SelectItemByFilterValue(filterValue string) {
items := p.list.Items()
for i, item := range items {
if item.FilterValue() == filterValue {
p.list.Select(i)
break
}
}
}

View File

@ -1,6 +1,7 @@
package table
import (
"fmt"
"strings"
"time"
@ -148,59 +149,89 @@ func (m *Model) parseRowStyles(rows taskwarrior.Tasks) []lipgloss.Style {
if len(rows) == 0 {
return styles
}
taskstyle:
for i, task := range rows {
if task.Status == "deleted" {
styles[i] = m.common.Styles.Deleted.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
if c, ok := m.common.Styles.Colors["deleted"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
if task.Status == "completed" {
styles[i] = m.common.Styles.Completed.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
if c, ok := m.common.Styles.Colors["completed"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
if task.Status == "pending" && task.Start != "" {
styles[i] = m.common.Styles.Active.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
// TODO: implement keyword
// TODO: implement tag
if task.HasTag("next") {
if c, ok := m.common.Styles.Colors["tag.next"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
// TODO: implement project
if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) {
styles[i] = m.common.Styles.Overdue.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
if c, ok := m.common.Styles.Colors["overdue"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
if task.Scheduled != "" {
styles[i] = m.common.Styles.Scheduled.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
if c, ok := m.common.Styles.Colors["scheduled"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) {
styles[i] = m.common.Styles.DueToday.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
if c, ok := m.common.Styles.Colors["due.today"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
if task.Due != "" {
styles[i] = m.common.Styles.Due.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
if c, ok := m.common.Styles.Colors["due"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
if len(task.Depends) > 0 {
styles[i] = m.common.Styles.Blocked.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
if c, ok := m.common.Styles.Colors["blocked"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
// TODO implement blocking
if task.Recur != "" {
styles[i] = m.common.Styles.Recurring.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
if c, ok := m.common.Styles.Colors["recurring"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
}
// TODO: make styles optional and discard if empty
if len(task.Tags) > 0 {
styles[i] = m.common.Styles.Tagged.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
taskIteration:
for _, tag := range task.Tags {
if tag == "next" {
styles[i] = m.common.Styles.TagNext.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
break taskIteration
}
}
if c, ok := m.common.Styles.Colors["tagged"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
// TODO implement uda
}
if len(m.common.Udas) > 0 {
for _, uda := range m.common.Udas {
if u, ok := task.Udas[uda.Name]; ok {
if c, ok := m.common.Styles.Colors[fmt.Sprintf("uda.%s.%s", uda.Name, u)]; ok {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue taskstyle
}
}
}
}
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
}
return styles

View File

@ -0,0 +1,409 @@
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,
}
}

View File

@ -0,0 +1,582 @@
package timetable
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"tasksquire/common"
"tasksquire/timewarrior"
)
// Model defines a state for the table widget.
type Model struct {
common *common.Common
KeyMap KeyMap
cols []Column
rows timewarrior.Intervals
rowStyles []lipgloss.Style
cursor int
focus bool
styles common.TableStyle
styleFunc StyleFunc
viewport viewport.Model
start int
end int
}
// Row represents one line in the table.
type Row *timewarrior.Interval
// Column defines the table structure.
type Column struct {
Title string
Name string
Width int
MaxWidth int
ContentWidth int
}
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu.
type KeyMap struct {
LineUp key.Binding
LineDown key.Binding
PageUp key.Binding
PageDown key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
GotoTop key.Binding
GotoBottom key.Binding
}
// ShortHelp implements the KeyMap interface.
func (km KeyMap) ShortHelp() []key.Binding {
return []key.Binding{km.LineUp, km.LineDown}
}
// FullHelp implements the KeyMap interface.
func (km KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
{km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
}
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
const spacebar = " "
return KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
LineDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PageUp: key.NewBinding(
key.WithKeys("b", "pgup"),
key.WithHelp("b/pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("f", "pgdown", spacebar),
key.WithHelp("f/pgdn", "page down"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
GotoTop: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
}
}
// SetStyles sets the table styles.
func (m *Model) SetStyles(s common.TableStyle) {
m.styles = s
m.UpdateViewport()
}
// Option is used to set options in New. For example:
//
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
type Option func(*Model)
// New creates a new model for the table widget.
func New(com *common.Common, opts ...Option) Model {
m := Model{
common: com,
cursor: 0,
viewport: viewport.New(0, 20),
KeyMap: DefaultKeyMap(),
}
for _, opt := range opts {
opt(&m)
}
m.cols = m.parseColumns(m.cols)
m.rowStyles = m.parseRowStyles(m.rows)
m.UpdateViewport()
return m
}
func (m *Model) parseRowStyles(rows timewarrior.Intervals) []lipgloss.Style {
styles := make([]lipgloss.Style, len(rows))
if len(rows) == 0 {
return styles
}
for i := range rows {
// Default style
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
// If active, maybe highlight?
if rows[i].IsActive() {
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
}
}
}
return styles
}
func (m *Model) parseColumns(cols []Column) []Column {
if len(cols) == 0 {
return cols
}
for i, col := range cols {
for _, interval := range m.rows {
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(interval.GetString(col.Name)))
}
cols[i] = col
}
combinedSize := 0
nonZeroWidths := 0
tagIndex := -1
for i, col := range cols {
if col.ContentWidth > 0 {
col.Width = max(col.ContentWidth, lipgloss.Width(col.Title))
nonZeroWidths++
}
if !strings.Contains(col.Name, "tags") {
combinedSize += col.Width
} else {
tagIndex = i
}
cols[i] = col
}
if tagIndex >= 0 {
cols[tagIndex].Width = m.Width() - combinedSize - nonZeroWidths
}
return cols
}
// WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option {
return func(m *Model) {
m.cols = cols
}
}
// WithRows sets the table rows (data).
func WithIntervals(rows timewarrior.Intervals) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithHeight sets the height of the table.
func WithHeight(h int) Option {
return func(m *Model) {
m.viewport.Height = h - lipgloss.Height(m.headersView())
}
}
// WithWidth sets the width of the table.
func WithWidth(w int) Option {
return func(m *Model) {
m.viewport.Width = w
}
}
// WithFocused sets the focus state of the table.
func WithFocused(f bool) Option {
return func(m *Model) {
m.focus = f
}
}
// WithStyles sets the table styles.
func WithStyles(s common.TableStyle) Option {
return func(m *Model) {
m.styles = s
}
}
// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state.
func WithStyleFunc(f StyleFunc) Option {
return func(m *Model) {
m.styleFunc = f
}
}
// WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) Option {
return func(m *Model) {
m.KeyMap = km
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.LineUp):
m.MoveUp(1)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.PageUp):
m.MoveUp(m.viewport.Height)
case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height)
case key.Matches(msg, m.KeyMap.HalfPageUp):
m.MoveUp(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.HalfPageDown):
m.MoveDown(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom):
m.GotoBottom()
}
}
return m, nil
}
// Focused returns the focus state of the table.
func (m Model) Focused() bool {
return m.focus
}
// Focus focuses the table, allowing the user to move around the rows and
// interact.
func (m *Model) Focus() {
m.focus = true
m.UpdateViewport()
}
// Blur blurs the table, preventing selection or movement.
func (m *Model) Blur() {
m.focus = false
m.UpdateViewport()
}
// View renders the component.
func (m Model) View() string {
return m.headersView() + "\n" + m.viewport.View()
}
// UpdateViewport updates the list content based on the previously defined
// columns and rows.
func (m *Model) UpdateViewport() {
renderedRows := make([]string, 0, len(m.rows))
if m.cursor >= 0 {
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)
} else {
m.start = 0
}
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))
for i := m.start; i < m.end; i++ {
renderedRows = append(renderedRows, m.renderRow(i))
}
m.viewport.SetContent(
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
)
}
// 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]
}
// Rows returns the current rows.
func (m Model) Rows() timewarrior.Intervals {
return m.rows
}
// Columns returns the current columns.
func (m Model) Columns() []Column {
return m.cols
}
// SetRows sets a new rows state.
func (m *Model) SetRows(r timewarrior.Intervals) {
m.rows = r
m.UpdateViewport()
}
// SetColumns sets a new columns state.
func (m *Model) SetColumns(c []Column) {
m.cols = c
m.UpdateViewport()
}
// SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) {
m.viewport.Width = w
m.UpdateViewport()
}
// SetHeight sets the height of the viewport of the table.
func (m *Model) SetHeight(h int) {
m.viewport.Height = h - lipgloss.Height(m.headersView())
m.UpdateViewport()
}
// Height returns the viewport height of the table.
func (m Model) Height() int {
return m.viewport.Height
}
// Width returns the viewport width of the table.
func (m Model) Width() int {
return m.viewport.Width
}
// Cursor returns the index of the selected row.
func (m Model) Cursor() int {
return m.cursor
}
// 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.
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))
case m.start < m.viewport.Height:
m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height))
case m.viewport.YOffset >= 1:
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)
}
m.UpdateViewport()
}
// MoveDown moves the selection down by any number of rows.
// It can not go below the last row. Skips gap rows.
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 {
case m.end == len(m.rows) && m.viewport.YOffset > 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))
case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))
case m.viewport.YOffset > 1:
case m.cursor > m.viewport.YOffset+m.viewport.Height-1:
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))
}
}
// GotoTop moves the selection to the first row.
func (m *Model) GotoTop() {
m.MoveUp(m.cursor)
}
// GotoBottom moves the selection to the last row.
func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows))
}
// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index.
type StyleFunc func(row, col int, value string) lipgloss.Style
func (m Model) headersView() string {
var s = make([]string, 0, len(m.cols))
for _, col := range m.cols {
if col.Width <= 0 {
continue
}
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
s = append(s, m.styles.Header.Render(renderedCell))
}
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
}
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 {
continue
}
var cellStyle lipgloss.Style
cellStyle = m.rowStyles[r]
if r == m.cursor {
cellStyle = cellStyle.Inherit(m.styles.Selected)
}
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…")))
s = append(s, renderedCell)
}
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
if r == m.cursor {
return m.styles.Selected.Render(row)
}
return row
}
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
}
func clamp(v, low, high int) int {
return min(max(v, low), high)
}

43
go.mod
View File

@ -1,32 +1,51 @@
module tasksquire
go 1.22.2
go 1.23.0
toolchain go1.24.12
require (
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.1
github.com/charmbracelet/huh v0.3.0
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb
github.com/mattn/go-runewidth v0.0.15
golang.org/x/term v0.20.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
)
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/x/exp/term v0.0.0-20240506152644-8135bef4e495 // 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/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/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.15.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // 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
)

91
go.sum
View File

@ -1,21 +1,55 @@
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.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0=
github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo=
github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE=
github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA=
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb h1:Hs3xzxHuruNT2Iuo87iS40c0PhLqpnUKBI6Xw6Ad3wQ=
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs=
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 h1:+0U9qX8Pv8KiYgRxfBvORRjgBzLgHMjtElP4O0PyKYA=
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c=
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/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/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=
@ -25,29 +59,42 @@ 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.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
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/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=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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/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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=

30
main.go
View File

@ -10,14 +10,40 @@ import (
"tasksquire/common"
"tasksquire/pages"
"tasksquire/taskwarrior"
"tasksquire/timewarrior"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
ts := taskwarrior.NewTaskSquire("./test/taskrc")
var taskrcPath string
if taskrcEnv := os.Getenv("TASKRC"); taskrcEnv != "" {
taskrcPath = taskrcEnv
} else if _, err := os.Stat(os.Getenv("HOME") + "/.taskrc"); err == nil {
taskrcPath = os.Getenv("HOME") + "/.taskrc"
} else if _, err := os.Stat(os.Getenv("HOME") + "/.config/task/taskrc"); err == nil {
taskrcPath = os.Getenv("HOME") + "/.config/task/taskrc"
} else {
log.Fatal("Unable to find taskrc file")
}
var timewConfigPath string
if timewConfigEnv := os.Getenv("TIMEWARRIORDB"); timewConfigEnv != "" {
timewConfigPath = timewConfigEnv
} else if _, err := os.Stat(os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg"); err == nil {
timewConfigPath = os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg"
} else {
// Default to empty string if not found, let TimeSquire handle defaults or errors if necessary
// But TimeSquire seems to only take config location.
// Let's assume standard location if not found or pass empty if it auto-detects.
// Checking TimeSquire implementation: it calls `timew show` which usually works if timew is in path.
timewConfigPath = ""
}
ts := taskwarrior.NewTaskSquire(taskrcPath)
tws := timewarrior.NewTimeSquire(timewConfigPath)
ctx := context.Background()
common := common.NewCommon(ctx, ts)
common := common.NewCommon(ctx, ts, tws)
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {

118
on-modify.timewarrior Normal file
View File

@ -0,0 +1,118 @@
#!/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)

View File

@ -4,17 +4,19 @@ import (
"log/slog"
"slices"
"tasksquire/common"
"tasksquire/components/picker"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type ContextPickerPage struct {
common *common.Common
contexts taskwarrior.Contexts
form *huh.Form
picker *picker.Picker
}
func NewContextPickerPage(common *common.Common) *ContextPickerPage {
@ -24,8 +26,11 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
}
selected := common.TW.GetActiveContext().Name
itemProvider := func() []list.Item {
contexts := common.TW.GetContexts()
options := make([]string, 0)
for _, c := range p.contexts {
for _, c := range contexts {
if c.Name != "none" {
options = append(options, c.Name)
}
@ -33,38 +38,75 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
slices.Sort(options)
options = append([]string{"(none)"}, options...)
p.form = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Key("context").
Options(huh.NewOptions(options...)...).
Title("Contexts").
Description("Choose a context").
Value(&selected),
),
).
WithShowHelp(false).
WithShowErrors(true).
WithTheme(p.common.Styles.Form)
items := []list.Item{}
for _, opt := range options {
items = append(items, picker.NewItem(opt))
}
return items
}
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return contextSelectedMsg{item: item} }
}
p.picker = picker.New(common, "Contexts", itemProvider, onSelect)
// Set active context
if selected == "" {
selected = "(none)"
}
p.picker.SelectItemByFilterValue(selected)
p.SetSize(common.Width(), common.Height())
return p
}
func (p *ContextPickerPage) 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 *ContextPickerPage) Init() tea.Cmd {
return p.form.Init()
return p.picker.Init()
}
type contextSelectedMsg struct {
item list.Item
}
func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case contextSelectedMsg:
name := msg.item.FilterValue() // Use FilterValue (which is the name/text)
if name == "(none)" {
name = ""
}
ctx := p.common.TW.GetContext(name)
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, func() tea.Msg { return UpdateContextMsg(ctx) }
case tea.KeyMsg:
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
@ -75,36 +117,28 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return model, BackCmd
}
}
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.updateContextCmd)
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(cmds...)
}
return p, tea.Batch(cmds...)
_, cmd = p.picker.Update(msg)
return p, cmd
}
func (p *ContextPickerPage) View() string {
return p.common.Styles.Base.Render(p.form.View())
width := p.common.Width() - 4
if width > 40 {
width = 40
}
func (p *ContextPickerPage) updateContextCmd() tea.Msg {
context := p.form.GetString("context")
if context == "(none)" {
context = ""
}
return UpdateContextMsg(p.common.TW.GetContext(context))
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 UpdateContextMsg *taskwarrior.Context

View File

@ -1,14 +1,22 @@
package pages
import (
tea "github.com/charmbracelet/bubbletea"
"tasksquire/common"
"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
}
func NewMainPage(common *common.Common) *MainPage {
@ -16,15 +24,17 @@ func NewMainPage(common *common.Common) *MainPage {
common: common,
}
m.activePage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
// m.activePage = NewTaskEditorPage(common, taskwarrior.Task{})
m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
m.timePage = NewTimePage(common)
m.activePage = m.taskPage
m.currentTab = 0
return m
}
func (m *MainPage) Init() tea.Cmd {
return m.activePage.Init()
return tea.Batch(m.taskPage.Init(), m.timePage.Init())
}
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -32,7 +42,43 @@ 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 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)
// 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()
}
}
activePage, cmd := m.activePage.Update(msg)
@ -41,6 +87,22 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
func (m *MainPage) View() string {
return m.activePage.View()
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())
}

View File

@ -1,6 +1,11 @@
package pages
import tea "github.com/charmbracelet/bubbletea"
import (
"tasksquire/taskwarrior"
"time"
tea "github.com/charmbracelet/bubbletea"
)
type UpdatedTasksMsg struct{}
@ -52,9 +57,9 @@ func prevArea() tea.Cmd {
}
}
type changeAreaMsg area
type changeAreaMsg int
func changeArea(a area) tea.Cmd {
func changeArea(a int) tea.Cmd {
return func() tea.Msg {
return changeAreaMsg(a)
}
@ -67,3 +72,17 @@ func changeMode(mode mode) tea.Cmd {
}
type changeModeMsg mode
type taskMsg taskwarrior.Tasks
type tickMsg time.Time
func doTick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
type TaskPickedMsg struct {
Task *taskwarrior.Task
}

View File

@ -3,15 +3,17 @@ 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/huh"
"github.com/charmbracelet/lipgloss"
)
type ProjectPickerPage struct {
common *common.Common
form *huh.Form
picker *picker.Picker
}
func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage {
@ -19,48 +21,80 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
common: common,
}
var selected string
if activeProject == "" {
selected = "(none)"
} else {
selected = activeProject
itemProvider := func() []list.Item {
projects := common.TW.GetProjects()
items := []list.Item{picker.NewItem("(none)")}
for _, proj := range projects {
items = append(items, picker.NewItem(proj))
}
return items
}
projects := common.TW.GetProjects()
options := []string{"(none)"}
options = append(options, projects...)
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return projectSelectedMsg{item: item} }
}
p.form = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Key("project").
Options(huh.NewOptions(options...)...).
Title("Projects").
Description("Choose a project").
Value(&selected),
),
).
WithShowHelp(false).
WithShowErrors(false)
// onCreate := func(name string) tea.Cmd {
// return func() tea.Msg { return projectSelectedMsg{item: picker.NewItem(name)} }
// }
// p.picker = picker.New(common, "Projects", itemProvider, onSelect, picker.WithOnCreate(onCreate))
p.picker = picker.New(common, "Projects", itemProvider, onSelect)
// Set active project
if activeProject == "" {
activeProject = "(none)"
}
p.picker.SelectItemByFilterValue(activeProject)
p.SetSize(common.Width(), common.Height())
return p
}
func (p *ProjectPickerPage) 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 *ProjectPickerPage) Init() tea.Cmd {
return p.form.Init()
return p.picker.Init()
}
type projectSelectedMsg struct {
item list.Item
}
func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case projectSelectedMsg:
proj := msg.item.FilterValue() // Use FilterValue (text)
if proj == "(none)" {
proj = ""
}
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, func() tea.Msg { return UpdateProjectMsg(proj) }
case tea.KeyMsg:
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
@ -71,36 +105,32 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return model, BackCmd
}
}
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.updateProjectCmd)
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(cmds...)
}
return p, tea.Batch(cmds...)
_, cmd = p.picker.Update(msg)
return p, cmd
}
func (p *ProjectPickerPage) View() string {
return p.common.Styles.Base.Render(p.form.View())
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),
)
}
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
project := p.form.GetString("project")
if project == "(none)" {
project = ""
}
return UpdateProjectMsg(project)
return nil
}
type UpdateProjectMsg string

465
pages/projectTaskPicker.go Normal file
View File

@ -0,0 +1,465 @@
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
}

View File

@ -3,12 +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 {
@ -24,7 +25,11 @@ type ReportPage struct {
taskTable table.Model
subpage tea.Model
// Details panel state
detailsPanelActive bool
detailsViewer *detailsviewer.DetailsViewer
subpage common.Component
}
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
@ -42,24 +47,53 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
activeContext: com.TW.GetActiveContext(),
activeProject: "",
taskTable: table.New(com),
detailsPanelActive: false,
detailsViewer: detailsviewer.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)
p.taskTable.SetWidth(width - p.common.Styles.Base.GetVerticalFrameSize())
p.taskTable.SetHeight(height - p.common.Styles.Base.GetHorizontalFrameSize())
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
}
func (p *ReportPage) Init() tea.Cmd {
return p.getTasks()
return tea.Batch(p.getTasks(), doTick())
}
func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -68,7 +102,11 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
// case BackMsg:
case TaskMsg:
case tickMsg:
cmds = append(cmds, p.getTasks())
cmds = append(cmds, doTick())
return p, tea.Batch(cmds...)
case taskMsg:
p.tasks = taskwarrior.Tasks(msg)
p.populateTaskTable(p.tasks)
case UpdateReportMsg:
@ -81,32 +119,46 @@ 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
case key.Matches(msg, p.common.Keymap.SetReport):
p.subpage = NewReportPickerPage(p.common, p.activeReport)
p.subpage.Init()
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, nil
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.SetContext):
p.subpage = NewContextPickerPage(p.common)
p.subpage.Init()
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, nil
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Add):
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
p.subpage.Init()
p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, nil
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Edit):
p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
p.subpage.Init()
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, nil
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Ok):
p.common.TW.SetTaskDone(p.selectedTask)
return p, p.getTasks()
@ -115,9 +167,14 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, p.getTasks()
case key.Matches(msg, p.common.Keymap.SetProject):
p.subpage = NewProjectPickerPage(p.common, p.activeProject)
p.subpage.Init()
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, nil
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")
@ -136,16 +193,40 @@ 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
// 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)
} else {
// Route to table when details panel not active
p.taskTable, cmd = p.taskTable.Update(msg)
cmds = append(cmds, cmd)
@ -154,16 +235,28 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
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")
}
return p.taskTable.View()
tableView := p.taskTable.View()
if !p.detailsPanelActive {
return tableView
}
// Combine table and details panel vertically
return lipgloss.JoinVertical(
lipgloss.Left,
tableView,
p.detailsViewer.View(),
)
}
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
@ -184,13 +277,27 @@ 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(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
table.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()-1),
table.WithWidth(baseWidth),
table.WithHeight(tableHeight),
table.WithStyles(p.common.Styles.TableStyle),
)
@ -202,6 +309,11 @@ 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 {
@ -211,8 +323,6 @@ func (p *ReportPage) getTasks() tea.Cmd {
filters = append(filters, "project:"+p.activeProject)
}
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
return TaskMsg(tasks)
return taskMsg(tasks)
}
}
type TaskMsg taskwarrior.Tasks

View File

@ -4,17 +4,19 @@ import (
"log/slog"
"slices"
"tasksquire/common"
"tasksquire/components/picker"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type ReportPickerPage struct {
common *common.Common
reports taskwarrior.Reports
form *huh.Form
picker *picker.Picker
}
func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage {
@ -23,45 +25,76 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
reports: common.TW.GetReports(),
}
selected := activeReport.Name
itemProvider := func() []list.Item {
options := make([]string, 0)
for _, r := range p.reports {
options = append(options, r.Name)
}
slices.Sort(options)
p.form = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Key("report").
Options(huh.NewOptions(options...)...).
Title("Reports").
Description("Choose a report").
Value(&selected),
),
).
WithShowHelp(false).
WithShowErrors(false)
items := []list.Item{}
for _, opt := range options {
items = append(items, picker.NewItem(opt))
}
return items
}
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return reportSelectedMsg{item: item} }
}
p.picker = picker.New(common, "Reports", itemProvider, onSelect)
if activeReport != nil {
p.picker.SelectItemByFilterValue(activeReport.Name)
}
p.SetSize(common.Width(), common.Height())
return p
}
func (p *ReportPickerPage) 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 *ReportPickerPage) Init() tea.Cmd {
return p.form.Init()
return p.picker.Init()
}
type reportSelectedMsg struct {
item list.Item
}
func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case reportSelectedMsg:
reportName := msg.item.FilterValue()
report := p.common.TW.GetReport(reportName)
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, func() tea.Msg { return UpdateReportMsg(report) }
case tea.KeyMsg:
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
@ -72,32 +105,28 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return model, BackCmd
}
}
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, []tea.Cmd{BackCmd, p.updateReportCmd}...)
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(cmds...)
}
return p, tea.Batch(cmds...)
_, cmd = p.picker.Update(msg)
return p, cmd
}
func (p *ReportPickerPage) View() string {
return p.common.Styles.Base.Render(p.form.View())
width := p.common.Width() - 4
if width > 40 {
width = 40
}
func (p *ReportPickerPage) updateReportCmd() tea.Msg {
return UpdateReportMsg(p.common.TW.GetReport(p.form.GetString("report")))
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 UpdateReportMsg *taskwarrior.Report

File diff suppressed because it is too large Load Diff

537
pages/timeEditor.go Normal file
View File

@ -0,0 +1,537 @@
package pages
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"
)
type TimeEditorPage struct {
common *common.Common
interval *timewarrior.Interval
// 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
}
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,
}
return p
}
func (p *TimeEditorPage) Init() tea.Cmd {
// Focus the first field (project picker)
p.currentField = 0
return p.projectPicker.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):
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
}
}
}
cmds = append(cmds, cmd)
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...)
}
func (p *TimeEditorPage) SetSize(width, height int) {
p.common.SetSize(width, height)
}
func (p *TimeEditorPage) saveInterval() {
// If it's an existing interval (has ID), delete it first so we can replace it with the modified version
if p.interval.ID != 0 {
err := p.common.TimeW.DeleteInterval(p.interval.ID)
if err != nil {
slog.Error("Failed to delete old interval during edit", "err", err)
// Proceeding to import anyway, attempting to save user data
}
}
p.interval.Start = p.startEditor.GetValueString()
p.interval.End = p.endEditor.GetValueString()
// Parse display tags from input
displayTags := parseTags(p.tagsInput.GetValue())
// 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)
if err != nil {
slog.Error("Failed to modify interval", "err", err)
}
}
func parseTags(tagsStr string) []string {
var tags []string
var current strings.Builder
inQuotes := false
for _, r := range tagsStr {
switch {
case r == '"':
inQuotes = !inQuotes
case r == ' ' && !inQuotes:
if current.Len() > 0 {
tags = append(tags, current.String())
current.Reset()
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
tags = append(tags, current.String())
}
return tags
}
func formatTags(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, " ")
}
// 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
}

498
pages/timePage.go Normal file
View File

@ -0,0 +1,498 @@
package pages
import (
"fmt"
"log/slog"
"time"
"tasksquire/common"
"tasksquire/components/timetable"
"tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type TimePage struct {
common *common.Common
intervals timetable.Model
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",
}
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())
case tea.KeyMsg:
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...)
}
case key.Matches(msg, p.common.Keymap.Delete):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
p.common.TimeW.DeleteInterval(interval.ID)
return p, tea.Batch(p.getIntervals(), doTick())
}
case key.Matches(msg, p.common.Keymap.Edit):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
editor := NewTimeEditorPage(p.common, interval)
p.common.PushPage(p)
return editor, editor.Init()
}
case key.Matches(msg, p.common.Keymap.Add):
interval := timewarrior.NewInterval()
interval.Start = time.Now().UTC().Format("20060102T150405Z")
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())
}
}
}
}
var cmd tea.Cmd
p.intervals, cmd = p.intervals.Update(msg)
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
}
type RefreshIntervalsMsg struct{}
func refreshIntervals() tea.Msg {
return RefreshIntervalsMsg{}
}
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,
)
}
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
}
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
}
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
var selectedStart string
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: "Duration", Name: "duration", Width: 10},
{Title: "Project", Name: "project", 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.WithFocused(true),
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()),
timetable.WithHeight(tableHeight),
timetable.WithStyles(p.common.Styles.TableStyle),
)
if len(intervalsWithGaps) > 0 {
newIdx := -1
if p.shouldSelectActive {
for i, interval := range intervalsWithGaps {
if !interval.IsGap && interval.IsActive() {
newIdx = i
break
}
}
p.shouldSelectActive = false
}
if newIdx == -1 && selectedStart != "" {
for i, interval := range intervalsWithGaps {
if !interval.IsGap && interval.Start == selectedStart {
newIdx = i
break
}
}
}
if newIdx == -1 {
// Default to first non-gap interval
for i, interval := range intervalsWithGaps {
if !interval.IsGap {
newIdx = i
break
}
}
}
if newIdx >= len(intervalsWithGaps) {
newIdx = len(intervalsWithGaps) - 1
}
if newIdx < 0 {
newIdx = 0
}
p.intervals.SetCursor(newIdx)
}
}
type intervalsMsg timewarrior.Intervals
func (p *TimePage) getIntervals() tea.Cmd {
return func() tea.Msg {
intervals := p.common.TimeW.GetIntervals(p.selectedTimespan)
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
}
}

146
pages/timePage_test.go Normal file
View File

@ -0,0 +1,146 @@
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")
}
}
})
}
}

128
pages/timespanPicker.go Normal file
View File

@ -0,0 +1,128 @@
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

View File

@ -14,7 +14,8 @@ var (
defaultConfig = map[string]string{
"uda.tasksquire.report.default": "next",
"uda.tasksquire.tag.default": "next",
"uda.tasksquire.tags.default": "low_energy,customer,delegate,code,communication,research",
"uda.tasksquire.tags.default": "mngmnt,ops,low_energy,cust,delegate,code,comm,research",
"uda.tasksquire.picker.filter_by_default": "yes",
}
)

View File

@ -1,6 +1,7 @@
package taskwarrior
import (
"encoding/json"
"fmt"
"log/slog"
"math"
@ -13,6 +14,23 @@ const (
dtformat = "20060102T150405Z"
)
type UdaType string
const (
UdaTypeString UdaType = "string"
UdaTypeDate UdaType = "date"
UdaTypeNumeric UdaType = "numeric"
UdaTypeDuration UdaType = "duration"
)
type Uda struct {
Name string
Type UdaType
Label string
Values []string
Default string
}
type Annotation struct {
Entry string `json:"entry,omitempty"`
Description string `json:"description,omitempty"`
@ -22,12 +40,14 @@ func (a Annotation) String() string {
return fmt.Sprintf("%s %s", a.Entry, a.Description)
}
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"`
Priority string `json:"priority,omitempty"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags"`
VirtualTags []string `json:"-"`
@ -45,6 +65,16 @@ type Task struct {
Modified string `json:"modified,omitempty"`
Recur string `json:"recur,omitempty"`
Annotations []Annotation `json:"annotations,omitempty"`
Udas map[string]any `json:"-"`
}
// TODO: fix pointer receiver
func NewTask() Task {
return Task{
Tags: make([]string, 0),
Depends: make([]string, 0),
Udas: make(map[string]any),
}
}
func (t *Task) GetString(fieldWFormat string) string {
@ -90,19 +120,25 @@ func (t *Task) GetString(fieldWFormat string) string {
}
}
if len(t.Annotations) == 0 {
return t.Description
if t.Udas["details"] != nil && t.Udas["details"] != "" {
return fmt.Sprintf("%s [D]", 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))
return t.Description
}
// 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":
@ -185,12 +221,45 @@ func (t *Task) GetString(fieldWFormat string) string {
return t.Recur
default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
return ""
// TODO: format according to UDA type
if val, ok := t.Udas[field]; ok {
if strVal, ok := val.(string); ok {
return strVal
}
}
}
func (t *Task) GetDate(dateString string) time.Time {
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
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{}
}
dt, err := time.Parse(dtformat, dateString)
if err != nil {
slog.Error("Failed to parse time:", err)
@ -223,7 +292,69 @@ func (t *Task) RemoveTag(tag string) {
}
}
type Tasks []*Task
func (t *Task) UnmarshalJSON(data []byte) error {
type Alias Task
task := Alias{}
if err := json.Unmarshal(data, &task); err != nil {
return err
}
*t = Task(task)
m := make(map[string]any)
if err := json.Unmarshal(data, &m); err != nil {
return err
}
delete(m, "id")
delete(m, "uuid")
delete(m, "description")
delete(m, "project")
// delete(m, "priority")
delete(m, "status")
delete(m, "tags")
delete(m, "depends")
delete(m, "urgency")
delete(m, "parent")
delete(m, "due")
delete(m, "wait")
delete(m, "scheduled")
delete(m, "until")
delete(m, "start")
delete(m, "end")
delete(m, "entry")
delete(m, "modified")
delete(m, "recur")
delete(m, "annotations")
t.Udas = m
return nil
}
func (t *Task) MarshalJSON() ([]byte, error) {
type Alias Task
task := Alias(*t)
knownFields, err := json.Marshal(task)
if err != nil {
return nil, err
}
var knownMap map[string]any
if err := json.Unmarshal(knownFields, &knownMap); err != nil {
return nil, err
}
for key, value := range t.Udas {
if value != nil && value != "" {
knownMap[key] = value
}
}
return json.Marshal(knownMap)
}
type Context struct {
Name string
@ -411,3 +542,15 @@ func ValidateDate(s string) error {
return fmt.Errorf("invalid date")
}
func ValidateNumeric(s string) error {
if _, err := strconv.ParseFloat(s, 64); err != nil {
return fmt.Errorf("invalid number")
}
return nil
}
func ValidateDuration(s string) error {
// TODO: implement duration validation
return nil
}

View File

@ -0,0 +1,81 @@
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)
}
})
}
}

View File

@ -89,13 +89,18 @@ type TaskWarrior interface {
GetReport(report string) *Report
GetReports() Reports
GetUdas() []Uda
GetTasks(report *Report, filter ...string) Tasks
AddTask(task *Task) error
// AddTask(task *Task) error
ImportTask(task *Task)
SetTaskDone(task *Task)
DeleteTask(task *Task)
StartTask(task *Task)
StopTask(task *Task)
StopActiveTasks()
GetInformation(task *Task) string
AddTaskAnnotation(uuid string, annotation string)
Undo()
}
@ -142,7 +147,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args := ts.defaultArgs
if report.Context {
if report != nil && report.Context {
for _, context := range ts.contexts {
if context.Active && context.Name != "none" {
args = append(args, context.ReadFilter)
@ -155,7 +160,12 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args = append(args, filter...)
}
cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...)
exportArgs := []string{"export"}
if report != nil && report.Name != "" {
exportArgs = append(exportArgs, report.Name)
}
cmd := exec.Command(twBinary, append(args, exportArgs...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting report:", err)
@ -281,10 +291,18 @@ func (ts *TaskSquire) GetTags() []string {
}
tags := make([]string, 0)
tagSet := make(map[string]struct{})
for _, tag := range strings.Split(string(output), "\n") {
if _, ok := virtualTags[tag]; !ok && tag != "" {
tags = append(tags, tag)
tagSet[tag] = struct{}{}
}
}
for _, tag := range strings.Split(ts.config.Get("uda.tasksquire.tags.default"), ",") {
if _, ok := tagSet[tag]; !ok {
tags = append(tags, tag)
}
}
@ -307,6 +325,45 @@ func (ts *TaskSquire) GetReports() Reports {
return ts.reports
}
func (ts *TaskSquire) GetUdas() []Uda {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "_udas")...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting config:", err)
return nil
}
udas := make([]Uda, 0)
for _, uda := range strings.Split(string(output), "\n") {
if uda != "" {
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
if udatype == "" {
slog.Error(fmt.Sprintf("UDA type not found: %s", uda))
continue
}
label := ts.config.Get(fmt.Sprintf("uda.%s.label", uda))
values := strings.Split(ts.config.Get(fmt.Sprintf("uda.%s.values", uda)), ",")
def := ts.config.Get(fmt.Sprintf("uda.%s.default", uda))
uda := Uda{
Name: uda,
Label: label,
Type: udatype,
Values: values,
Default: def,
}
udas = append(udas, uda)
}
}
return udas
}
func (ts *TaskSquire) SetContext(context *Context) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
@ -328,42 +385,42 @@ func (ts *TaskSquire) SetContext(context *Context) error {
return nil
}
func (ts *TaskSquire) AddTask(task *Task) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
// func (ts *TaskSquire) AddTask(task *Task) error {
// ts.mutex.Lock()
// defer ts.mutex.Unlock()
addArgs := []string{"add"}
// addArgs := []string{"add"}
if task.Description == "" {
slog.Error("Task description is required")
return nil
} else {
addArgs = append(addArgs, task.Description)
}
if task.Priority != "" && task.Priority != "(none)" {
addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority))
}
if task.Project != "" && task.Project != "(none)" {
addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project))
}
if task.Tags != nil {
for _, tag := range task.Tags {
addArgs = append(addArgs, fmt.Sprintf("+%s", tag))
}
}
if task.Due != "" {
addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due))
}
// if task.Description == "" {
// slog.Error("Task description is required")
// return nil
// } else {
// addArgs = append(addArgs, task.Description)
// }
// if task.Priority != "" && task.Priority != "(none)" {
// addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority))
// }
// if task.Project != "" && task.Project != "(none)" {
// addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project))
// }
// if task.Tags != nil {
// for _, tag := range task.Tags {
// addArgs = append(addArgs, fmt.Sprintf("+%s", tag))
// }
// }
// if task.Due != "" {
// addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due))
// }
cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed adding task:", err)
}
// cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...)
// err := cmd.Run()
// if err != nil {
// slog.Error("Failed adding task:", err)
// }
// TODO remove error?
return nil
}
// // TODO remove error?
// return nil
// }
// TODO error handling
func (ts *TaskSquire) ImportTask(task *Task) {
@ -371,7 +428,6 @@ func (ts *TaskSquire) ImportTask(task *Task) {
defer ts.mutex.Unlock()
tasks, err := json.Marshal(Tasks{task})
if err != nil {
slog.Error("Failed marshalling task:", err)
}
@ -440,6 +496,58 @@ 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()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting task information:", err)
return ""
}
return string(output)
}
func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed adding annotation:", err)
}
}
func (ts *TaskSquire) extractConfig() *TWConfig {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
output, err := cmd.CombinedOutput()

Binary file not shown.

View File

@ -1,6 +1,30 @@
include light-256.theme
uda.priority.values=H,M,,L
context.test.read=+test
context.test.write=+test
context.home.read=+home
context.home.write=+home
uda.testuda.type=string
uda.testuda.label=Testuda
uda.testuda.values=eins,zwei,drei
uda.testuda.default=eins
uda.testuda2.type=numeric
uda.testuda2.label=TESTUDA2
uda.testuda3.type=date
uda.testuda3.label=Ttttuda
uda.testuda4.type=duration
uda.testuda4.label=TtttudaDURUD
report.next.columns=id,testuda,start.age,entry.age,depends,priority,project,tags,recur,scheduled.countdown,due.relative,until.remaining,description,urgency
report.next.context=1
report.next.description=Most urgent tasks
report.next.filter=status:pending -WAITING
report.next.labels=ID,UDA,Active,Age,Deps,P,Project,Tag,Recur,S,Due,Until,Description,Urg
report.next.sort=urgency-
uda.tasksquire.use_details=true

78
timewarrior/config.go Normal file
View File

@ -0,0 +1,78 @@
package timewarrior
import (
"fmt"
"log/slog"
"strings"
)
type TWConfig struct {
config map[string]string
}
var (
defaultConfig = map[string]string{
"uda.timesquire.default.tag": "",
}
)
func NewConfig(config []string) *TWConfig {
cfg := parseConfig(config)
for key, value := range defaultConfig {
if _, ok := cfg[key]; !ok {
cfg[key] = value
}
}
return &TWConfig{
config: cfg,
}
}
func (tc *TWConfig) GetConfig() map[string]string {
return tc.config
}
func (tc *TWConfig) Get(key string) string {
if _, ok := tc.config[key]; !ok {
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
return ""
}
return tc.config[key]
}
func parseConfig(config []string) map[string]string {
configMap := make(map[string]string)
for _, line := range config {
// Skip empty lines and comments
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Timewarrior config format: key = value or key: value
var key, value string
if strings.Contains(line, "=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key = strings.TrimSpace(parts[0])
value = strings.TrimSpace(parts[1])
}
} else if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key = strings.TrimSpace(parts[0])
value = strings.TrimSpace(parts[1])
}
}
if key != "" {
configMap[key] = value
}
}
return configMap
}

323
timewarrior/models.go Normal file
View File

@ -0,0 +1,323 @@
package timewarrior
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"time"
)
const (
dtformat = "20060102T150405Z"
)
type Intervals []*Interval
type Interval struct {
ID int `json:"-"`
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 {
return &Interval{
Tags: make([]string, 0),
}
}
// 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)
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
case "duration":
return i.GetDuration()
case "active":
if i.End == "" {
return "●"
}
return ""
default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
return ""
}
}
func (i *Interval) GetDuration() string {
start, err := time.Parse(dtformat, i.Start)
if err != nil {
slog.Error("Failed to parse start time:", err)
return ""
}
var end time.Time
if i.End == "" {
end = time.Now()
} else {
end, err = time.Parse(dtformat, i.End)
if err != nil {
slog.Error("Failed to parse end time:", err)
return ""
}
}
duration := end.Sub(start)
return formatDuration(duration)
}
func (i *Interval) GetStartTime() time.Time {
dt, err := time.Parse(dtformat, i.Start)
if err != nil {
slog.Error("Failed to parse time:", err)
return time.Time{}
}
return dt
}
func (i *Interval) GetEndTime() time.Time {
if i.End == "" {
return time.Now()
}
dt, err := time.Parse(dtformat, i.End)
if err != nil {
slog.Error("Failed to parse time:", err)
return time.Time{}
}
return dt
}
func (i *Interval) HasTag(tag string) bool {
for _, t := range i.Tags {
if t == tag {
return true
}
}
return false
}
func (i *Interval) AddTag(tag string) {
if !i.HasTag(tag) {
i.Tags = append(i.Tags, tag)
}
}
func (i *Interval) RemoveTag(tag string) {
for idx, t := range i.Tags {
if t == tag {
i.Tags = append(i.Tags[:idx], i.Tags[idx+1:]...)
return
}
}
}
func (i *Interval) IsActive() bool {
return i.End == ""
}
func formatDate(date string, format string) string {
if date == "" {
return ""
}
dt, err := time.Parse(dtformat, date)
if err != nil {
slog.Error("Failed to parse time:", err)
return ""
}
dt = dt.Local()
switch format {
case "formatted", "":
return dt.Format("2006-01-02 15:04")
case "time":
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":
return strconv.FormatInt(dt.Unix(), 10)
case "age":
return parseDurationVague(time.Since(dt))
case "relative":
return parseDurationVague(time.Until(dt))
default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
return ""
}
}
func formatDuration(d time.Duration) string {
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
func parseDurationVague(d time.Duration) string {
dur := d.Round(time.Second).Abs()
days := dur.Hours() / 24
var formatted string
if dur >= time.Hour*24*365 {
formatted = fmt.Sprintf("%.1fy", days/365)
} else if dur >= time.Hour*24*90 {
formatted = strconv.Itoa(int(math.Round(days/30))) + "mo"
} else if dur >= time.Hour*24*7 {
formatted = strconv.Itoa(int(math.Round(days/7))) + "w"
} else if dur >= time.Hour*24 {
formatted = strconv.Itoa(int(days)) + "d"
} else if dur >= time.Hour {
formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h"
} else if dur >= time.Minute {
formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min"
} else if dur >= time.Second {
formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s"
}
if d < 0 {
formatted = "-" + formatted
}
return formatted
}
var (
dateFormats = []string{
"2006-01-02",
"2006-01-02T15:04",
"20060102T150405Z",
}
specialDateFormats = []string{
"",
"now",
"today",
"yesterday",
"tomorrow",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
}
)
func ValidateDate(s string) error {
for _, f := range dateFormats {
if _, err := time.Parse(f, s); err == nil {
return nil
}
}
for _, f := range specialDateFormats {
if s == f {
return nil
}
}
return fmt.Errorf("invalid date")
}
func ValidateDuration(s string) error {
// TODO: implement proper duration validation
// Should accept formats like: 1h, 30m, 1h30m, etc.
return nil
}
// Summary represents time tracking summary data
type Summary struct {
Range string
TotalTime time.Duration
ByTag map[string]time.Duration
}
func (s *Summary) GetTotalString() string {
return formatDuration(s.TotalTime)
}
func (s *Summary) GetTagTime(tag string) string {
if duration, ok := s.ByTag[tag]; ok {
return formatDuration(duration)
}
return "0:00"
}

54
timewarrior/tags.go Normal file
View File

@ -0,0 +1,54 @@
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 ""
}

405
timewarrior/timewarrior.go Normal file
View File

@ -0,0 +1,405 @@
// TODO: error handling
// TODO: split combinedOutput and handle stderr differently
package timewarrior
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"regexp"
"slices"
"strings"
"sync"
)
const (
twBinary = "timew"
)
type TimeWarrior interface {
GetConfig() *TWConfig
GetTags() []string
GetTagCombinations() []string
GetIntervals(filter ...string) Intervals
StartTracking(tags []string) error
StopTracking() error
ContinueTracking() error
ContinueInterval(id int) error
CancelTracking() error
DeleteInterval(id int) error
FillInterval(id int) error
JoinInterval(id int) error
ModifyInterval(interval *Interval, adjust bool) error
GetSummary(filter ...string) string
GetActive() *Interval
Undo()
}
type TimeSquire struct {
configLocation string
defaultArgs []string
config *TWConfig
mutex sync.Mutex
}
func NewTimeSquire(configLocation string) *TimeSquire {
if _, err := exec.LookPath(twBinary); err != nil {
slog.Error("Timewarrior not found")
return nil
}
ts := &TimeSquire{
configLocation: configLocation,
defaultArgs: []string{},
mutex: sync.Mutex{},
}
ts.config = ts.extractConfig()
return ts
}
func (ts *TimeSquire) GetConfig() *TWConfig {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.config
}
func (ts *TimeSquire) GetTags() []string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting tags:", err)
return nil
}
tags := make([]string, 0)
lines := strings.Split(string(output), "\n")
// Skip header lines and parse tag names
for i, line := range lines {
if i < 3 || line == "" {
continue
}
// Tags are space-separated, first column is the tag name
fields := strings.Fields(line)
if len(fields) > 0 {
tags = append(tags, fields[0])
}
}
slices.Sort(tags)
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
// 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...)
}
cmd := exec.Command(twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting intervals:", err)
return nil
}
intervals := make(Intervals, 0)
err = json.Unmarshal(output, &intervals)
if err != nil {
slog.Error("Failed unmarshalling intervals:", err)
return nil
}
// Reverse the intervals to show newest first
slices.Reverse(intervals)
// Assign IDs based on new order (newest is @1)
for i := range intervals {
intervals[i].ID = i + 1
}
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()
if len(tags) == 0 {
return fmt.Errorf("at least one tag is required")
}
args := append(ts.defaultArgs, "start")
args = append(args, tags...)
cmd := exec.Command(twBinary, args...)
if err := cmd.Run(); err != nil {
slog.Error("Failed starting tracking:", err)
return err
}
return nil
}
func (ts *TimeSquire) StopTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed stopping tracking:", err)
return err
}
return nil
}
func (ts *TimeSquire) ContinueTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed continuing tracking:", err)
return err
}
return nil
}
func (ts *TimeSquire) ContinueInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed continuing interval:", err)
return err
}
return nil
}
func (ts *TimeSquire) CancelTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed canceling tracking:", err)
return err
}
return nil
}
func (ts *TimeSquire) DeleteInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed deleting interval:", err)
return err
}
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 {
ts.mutex.Lock()
defer ts.mutex.Unlock()
// Export the modified interval
intervals, err := json.Marshal(Intervals{interval})
if err != nil {
slog.Error("Failed marshalling interval:", err)
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.Stdin = bytes.NewBuffer(intervals)
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed modifying interval:", err, string(out))
return err
}
return nil
}
func (ts *TimeSquire) GetSummary(filter ...string) string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
args := append(ts.defaultArgs, "summary")
if filter != nil {
args = append(args, filter...)
}
cmd := exec.Command(twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting summary:", err)
return ""
}
return string(output)
}
func (ts *TimeSquire) GetActive() *Interval {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
output, err := cmd.CombinedOutput()
if err != nil || string(output) == "0\n" {
return nil
}
// Get the active interval using unlocked version (we already hold the mutex)
intervals := ts.getIntervalsUnlocked()
for _, interval := range intervals {
if interval.End == "" {
return interval
}
}
return nil
}
func (ts *TimeSquire) Undo() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed undoing:", err)
}
}
func (ts *TimeSquire) extractConfig() *TWConfig {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting config:", err)
return nil
}
return NewConfig(strings.Split(string(output), "\n"))
}
func extractTags(config string) []string {
re := regexp.MustCompile(`tag\.([^.]+)\.[^.]+`)
matches := re.FindAllStringSubmatch(config, -1)
uniques := make(map[string]struct{})
for _, match := range matches {
uniques[match[1]] = struct{}{}
}
var tags []string
for tag := range uniques {
tags = append(tags, tag)
}
slices.Sort(tags)
return tags
}