33 Commits

Author SHA1 Message Date
Martin Pander
f0a3e0a568 Fix config 2026-02-26 22:49:00 +01:00
Martin Pander
9eda92503e Use native bubble table 2026-02-26 22:47:31 +01:00
Martin Pander
418bcd96a8 Tear down everything
Fix config
2026-02-26 22:47:12 +01:00
Martin Pander
6b1418fc71 Update flake to flake-parts 2026-02-23 21:30:53 +01:00
Martin Pander
b46aced2c7 Align subpages 2026-02-17 21:31:24 +01:00
Martin Pander
3ab26f658d Unify styles 2026-02-17 21:25:14 +01:00
Martin Pander
1a9fd9b4b0 Clean up task editor and time editor 2026-02-17 20:57:21 +01:00
Martin Pander
6e60698526 Merge branch 'feat/time' into dev 2026-02-10 15:54:31 +01:00
Martin Pander
703ed981ac Add things 2026-02-10 15:54:08 +01:00
Martin Pander
e3effe8b25 Minor fixes 2026-02-07 20:48:26 +01:00
Martin Pander
980c8eb309 Move log 2026-02-07 20:44:58 +01:00
Martin Pander
e35f480248 Update flake 2026-02-07 20:32:52 +01:00
Martin Pander
02fa2e503a Add things 2026-02-04 13:13:04 +01:00
Martin
474bb3dc07 Add project tracking picker 2026-02-03 20:59:47 +01:00
Martin
1ffcf42773 Fix bugs 2026-02-03 20:13:09 +01:00
Martin Pander
44ddbc0f47 Add syncing 2026-02-03 16:04:47 +01:00
Martin Pander
2e33893e29 Merge branch 'feat/taskedit' into feat/time 2026-02-03 07:40:11 +01:00
Martin Pander
46ce91196a Merge branch 'feat/task' into feat/time 2026-02-03 07:39:59 +01:00
Martin Pander
2b31d9bc2b Add autocompletion 2026-02-03 07:39:26 +01:00
Martin
70b6ee9bc7 Add picker to task edit 2026-02-02 20:43:08 +01:00
Martin
2baf3859fd Add tab bar 2026-02-02 19:47:18 +01:00
Martin
2940711b26 Make task details scrollable 2026-02-02 19:39:02 +01:00
Martin Pander
f5d297e6ab Add proper fuzzy matching for time tags 2026-02-02 15:54:39 +01:00
Martin Pander
938ed177f1 Add fuzzy matching for time tags 2026-02-02 15:41:53 +01:00
Martin Pander
81b9d87935 Add niceties to time page 2026-02-02 12:44:12 +01:00
Martin Pander
9940316ace Add time undo and fill 2026-02-02 11:12:09 +01:00
Martin Pander
fc8e9481c3 Add timestamp editor 2026-02-02 10:55:47 +01:00
Martin
7032d0fa54 Add time editing 2026-02-02 10:04:54 +01:00
Martin
681ed7e635 Add time page 2026-02-02 10:04:54 +01:00
Martin
effd95f6c1 Refactor picker 2026-02-02 10:04:54 +01:00
Martin
4767a6cd91 Integrate timewarrior 2026-02-02 10:04:54 +01:00
Martin
ce193c336c Add README 2026-02-02 10:04:54 +01:00
Martin Pander
f19767fb10 Minor fixes 2026-02-02 10:04:31 +01:00
53 changed files with 2889 additions and 5612 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:*)"
]
}
}

4
.gitignore vendored
View File

@@ -1,4 +1,6 @@
.DS_Store .DS_Store
app.log
test/taskchampion.sqlite3 test/taskchampion.sqlite3
tasksquire tasksquire
test/*.sqlite3*
result
main

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 `/tmp/tasksquire.log`
- **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 `/tmp/tasksquire.log`
- 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`)

View File

@@ -61,7 +61,7 @@ Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It look
2. `$HOME/.taskrc` 2. `$HOME/.taskrc`
3. `$HOME/.config/task/taskrc` 3. `$HOME/.config/task/taskrc`
Logging is written to `app.log` in the current working directory. Logging is written to `/tmp/tasksquire.log`.
## Development Conventions ## Development Conventions

View File

@@ -1,177 +0,0 @@
package common
import (
"log/slog"
"strconv"
"strings"
// "github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"tasksquire/taskwarrior"
)
type TableStyle struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
type Styles struct {
Colors map[string]*lipgloss.Style
Base lipgloss.Style
Form *huh.Theme
TableStyle TableStyle
ColumnFocused lipgloss.Style
ColumnBlurred lipgloss.Style
ColumnInsert lipgloss.Style
}
func NewStyles(config *taskwarrior.TWConfig) *Styles {
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{
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true),
Selected: lipgloss.NewStyle().Bold(true).Reverse(true),
}
formTheme := huh.ThemeBase()
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ")
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
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(" ")
formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ")
formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ")
styles.Form = formTheme
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 {
if color == "" {
return nil
}
style := lipgloss.NewStyle()
if strings.Contains(color, "on") {
fgbg := strings.Split(color, "on")
fg := strings.TrimSpace(fgbg[0])
bg := strings.TrimSpace(fgbg[1])
if fg != "" {
style = style.Foreground(parseColor(fg))
}
if bg != "" {
style = style.Background(parseColor(bg))
}
} else {
style = style.Foreground(parseColor(strings.TrimSpace(color)))
}
return &style
}
func parseColor(color string) lipgloss.Color {
if strings.HasPrefix(color, "rgb") {
return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb")))
}
if strings.HasPrefix(color, "color") {
return lipgloss.Color(strings.TrimPrefix(color, "color"))
}
if strings.HasPrefix(color, "gray") {
gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray"))
if err != nil {
slog.Error("Invalid gray color format")
return lipgloss.Color("0")
}
return lipgloss.Color(strconv.Itoa(gray + 232))
}
if ansi, okcolor := colorStrings[color]; okcolor {
return lipgloss.Color(strconv.Itoa(ansi))
}
slog.Error("Invalid color format")
return lipgloss.Color("0")
}
func convertRgbToAnsi(rgbString string) string {
var err error
if len(rgbString) != 3 {
slog.Error("Invalid RGB color format")
return ""
}
r, err := strconv.Atoi(string(rgbString[0]))
if err != nil {
slog.Error("Invalid value for R")
return ""
}
g, err := strconv.Atoi(string(rgbString[1]))
if err != nil {
slog.Error("Invalid value for G")
return ""
}
b, err := strconv.Atoi(string(rgbString[2]))
if err != nil {
slog.Error("Invalid value for B")
return ""
}
return strconv.Itoa(16 + (36 * r) + (6 * g) + b)
}
var colorStrings = map[string]int{
"black": 0,
"red": 1,
"green": 2,
"yellow": 3,
"blue": 4,
"magenta": 5,
"cyan": 6,
"white": 7,
"bright black": 8,
"bright red": 9,
"bright green": 10,
"bright yellow": 11,
"bright blue": 12,
"bright magenta": 13,
"bright cyan": 14,
"bright white": 15,
}

View File

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

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

View File

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

View File

@@ -1,209 +0,0 @@
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
baseItems []list.Item
}
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 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)
// Custom key for filtering (insert mode)
l.KeyMap.Filter = key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "filter"),
)
p := &Picker{
common: c,
list: l,
itemProvider: itemProvider,
onSelect: onSelect,
title: title,
}
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
p.filterByDefault = true
}
for _, opt := range opts {
opt(p)
}
p.Refresh()
return p
}
func (p *Picker) Refresh() tea.Cmd {
p.baseItems = p.itemProvider()
return p.updateListItems()
}
func (p *Picker) updateListItems() tea.Cmd {
items := p.baseItems
filterVal := p.list.FilterValue()
if p.onCreate != nil && filterVal != "" {
newItem := creationItem{
text: "(new) " + filterVal,
filter: filterVal,
}
newItems := make([]list.Item, len(items)+1)
copy(newItems, items)
newItems[len(items)] = newItem
items = newItems
}
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 {
if p.filterByDefault {
return func() tea.Msg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}
}
}
return nil
}
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
// If filtering, let the list handle keys (including Enter to stop filtering)
if p.list.FilterState() == list.Filtering {
if key.Matches(msg, p.common.Keymap.Ok) {
items := p.list.VisibleItems()
if len(items) == 1 {
return p, p.handleSelect(items[0])
}
}
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)
}
}
prevFilter := p.list.FilterValue()
p.list, cmd = p.list.Update(msg)
if p.list.FilterValue() != prevFilter {
updateCmd := p.updateListItems()
return p, tea.Batch(cmd, updateCmd)
}
return p, cmd
}
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
if cItem, ok := item.(creationItem); ok {
if p.onCreate != nil {
return p.onCreate(cItem.filter)
}
}
return p.onSelect(item)
}
func (p *Picker) View() string {
title := p.common.Styles.Form.Focused.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,624 +0,0 @@
package table
import (
"fmt"
"strings"
"time"
"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/taskwarrior"
)
// Model defines a state for the table widget.
type Model struct {
common *common.Common
KeyMap KeyMap
cols []Column
rows taskwarrior.Tasks
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 *taskwarrior.Task
// 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
}
// TODO: dynamically read rule.precedence.color
func (m *Model) parseRowStyles(rows taskwarrior.Tasks) []lipgloss.Style {
styles := make([]lipgloss.Style, len(rows))
if len(rows) == 0 {
return styles
}
taskstyle:
for i, task := range rows {
if task.Status == "deleted" {
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" {
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 != "" {
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()) {
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 != "" {
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)) {
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 != "" {
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 {
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 != "" {
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 {
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
}
}
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
}
func (m *Model) parseColumns(cols []Column) []Column {
if len(cols) == 0 {
return cols
}
for i, col := range cols {
for _, task := range m.rows {
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(task.GetString(col.Name)))
}
cols[i] = col
}
combinedSize := 0
nonZeroWidths := 0
descIndex := -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, "description") {
combinedSize += col.Width
} else {
descIndex = i
}
cols[i] = col
}
if descIndex >= 0 {
cols[descIndex].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
}
}
func WithReport(report *taskwarrior.Report) Option {
return func(m *Model) {
columns := make([]Column, len(report.Columns))
for i, col := range report.Columns {
columns[i] = Column{
Title: report.Labels[i],
Name: col,
Width: 0,
}
}
m.cols = columns
}
}
// WithRows sets the table rows (data).
func WithRows(rows taskwarrior.Tasks) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithRows sets the table rows (data).
func WithTasks(rows taskwarrior.Tasks) 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))
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
// Constant runtime, independent of number of rows in a table.
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
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.
// You can cast it to your own implementation.
func (m Model) SelectedRow() Row {
if m.cursor < 0 || m.cursor >= len(m.rows) {
return nil
}
return m.rows[m.cursor]
}
// Rows returns the current rows.
func (m Model) Rows() taskwarrior.Tasks {
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 taskwarrior.Tasks) {
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.
func (m *Model) SetCursor(n int) {
m.cursor = clamp(n, 0, len(m.rows)-1)
m.UpdateViewport()
}
// MoveUp moves the selection up by any number of rows.
// It can not go above the first row.
func (m *Model) MoveUp(n int) {
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
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.
func (m *Model) MoveDown(n int) {
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
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))
}
// FromValues create the table rows from a simple string. It uses `\n` by
// default for getting all the rows and the given separator for the fields on
// each row.
// func (m *Model) FromValues(value, separator string) {
// rows := []Row{}
// for _, line := range strings.Split(value, "\n") {
// r := Row{}
// for _, field := range strings.Split(line, separator) {
// r = append(r, field)
// }
// rows = append(rows, r)
// }
// m.SetRows(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 {
var s = make([]string, 0, len(m.cols))
for i, col := range m.cols {
// for i, task := range m.rows[r] {
if m.cols[i].Width <= 0 {
continue
}
var cellStyle lipgloss.Style
// if m.styleFunc != nil {
// cellStyle = m.styleFunc(r, i, m.rows[r].GetString(col.Name))
// if r == m.cursor {
// cellStyle.Inherit(m.styles.Selected)
// }
// } else {
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)
}

63
flake.lock generated
View File

@@ -1,58 +1,59 @@
{ {
"nodes": { "nodes": {
"flake-utils": { "flake-parts": {
"inputs": { "inputs": {
"systems": "systems" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1710146030, "lastModified": 1769996383,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "numtide", "owner": "hercules-ci",
"repo": "flake-utils", "repo": "flake-parts",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "numtide", "owner": "hercules-ci",
"repo": "flake-utils", "repo": "flake-parts",
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1715787315, "lastModified": 1771848320,
"narHash": "sha256-cYApT0NXJfqBkKcci7D9Kr4CBYZKOQKDYA23q8XNuWg=", "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
"owner": "NixOS", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "33d1e753c82ffc557b4a585c77de43d4c922ebb5", "rev": "2fc6539b481e1d2569f25f8799236694180c0993",
"type": "github" "type": "github"
}, },
"original": { "original": {
"id": "nixpkgs", "owner": "nixos",
"ref": "nixos-unstable", "ref": "nixos-unstable",
"type": "indirect" "repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1769909678,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "72716169fe93074c333e8d0173151350670b824c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View File

@@ -2,38 +2,55 @@
description = "Tasksquire"; description = "Tasksquire";
inputs = { inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-parts.url = "github:hercules-ci/flake-parts";
}; };
outputs = { self, nixpkgs, flake-utils }: outputs = inputs@{ self, nixpkgs, flake-parts, ... }:
flake-utils.lib.eachDefaultSystem (system: flake-parts.lib.mkFlake { inherit inputs; } {
let systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
pkgs = import nixpkgs {
inherit system; perSystem = { config, self', inputs', pkgs, system, ... }: {
packages.tasksquire = pkgs.buildGoModule {
pname = "tasksquire";
version = "0.1.0";
src = ./.;
vendorHash = "sha256-fDzQuKBZPkOATMMnYcFv/aJP62XDhL9LjM/UYre9JQ4=";
ldflags = [ "-s" "-w" ];
nativeBuildInputs = with pkgs; [
taskwarrior3
timewarrior
];
meta = with pkgs.lib; {
description = "A Terminal User Interface (TUI) for Taskwarrior";
mainProgram = "tasksquire";
};
}; };
buildDeps = with pkgs; [ # Set the default package
go_1_22 packages.default = self'.packages.tasksquire;
gcc
];
devDeps = with pkgs; buildDeps ++ [ # Development shell
gotools devShells.default = pkgs.mkShell {
golangci-lint inputsFrom = [ self'.packages.tasksquire ];
gopls buildInputs = with pkgs; [
go-outline go
gopkgs gcc
go-tools gotools
gotests golangci-lint
delve gopls
]; go-outline
in gopkgs
{ go-tools
devShell = pkgs.mkShell { gotests
buildInputs = devDeps; delve
CGO_CFLAGS="-O"; ];
CGO_CFLAGS = "-O";
}; };
}); };
};
} }

51
go.mod
View File

@@ -1,39 +1,28 @@
module tasksquire module tasksquire
go 1.22.2 go 1.25
toolchain go1.25.7
require ( require (
github.com/charmbracelet/bubbles v0.18.0 charm.land/bubbles/v2 v2.0.0 // indirect
github.com/charmbracelet/bubbletea v0.26.4 charm.land/bubbletea/v2 v2.0.0 // indirect
github.com/charmbracelet/huh v0.4.2 charm.land/lipgloss/v2 v2.0.0 // indirect
github.com/charmbracelet/lipgloss v0.11.0 github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/mattn/go-runewidth v0.0.15 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
golang.org/x/term v0.21.0 github.com/charmbracelet/x/ansi v0.11.6 // indirect
) github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
require ( github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/catppuccin/go v0.2.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/charmbracelet/x/ansi v0.1.2 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a // indirect github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 // indirect
github.com/charmbracelet/x/input v0.1.1 // indirect
github.com/charmbracelet/x/term v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // 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/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.7.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.21.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/term v0.40.0 // indirect
) )

107
go.sum
View File

@@ -1,69 +1,50 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 h1:6Rw/MHf+Rm7wlUr+euFyaGw1fNKeZPKVh4gkFHUjFsY= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7/go.mod h1:UZyEz89i6+jVx2oW3lgmIsVDNr7qzaKuvPVoSdwqDR0= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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/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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=

View File

@@ -5,8 +5,8 @@ import (
"log/slog" "log/slog"
"os" "os"
"tasksquire/taskwarrior" "tasksquire/internal/taskwarrior"
"tasksquire/timewarrior" "tasksquire/internal/timewarrior"
"golang.org/x/term" "golang.org/x/term"
) )
@@ -65,3 +65,7 @@ func (c *Common) PopPage() (Component, error) {
component.SetSize(c.width, c.height) component.SetSize(c.width, c.height)
return component, nil return component, nil
} }
func (c *Common) HasSubpages() bool {
return !c.pageStack.IsEmpty()
}

View File

@@ -1,6 +1,6 @@
package common package common
import tea "github.com/charmbracelet/bubbletea" import tea "charm.land/bubbletea/v2"
type Component interface { type Component interface {
tea.Model tea.Model

View File

@@ -1,34 +1,39 @@
package common package common
import ( import (
"github.com/charmbracelet/bubbles/key" "charm.land/bubbles/v2/key"
) )
// Keymap is a collection of key bindings. // Keymap is a collection of key bindings.
type Keymap struct { type Keymap struct {
Quit key.Binding Quit key.Binding
Back key.Binding Back key.Binding
Ok key.Binding Ok key.Binding
Delete key.Binding Delete key.Binding
Input key.Binding Input key.Binding
Add key.Binding Add key.Binding
Edit key.Binding Edit key.Binding
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
Left key.Binding Left key.Binding
Right key.Binding Right key.Binding
Next key.Binding Next key.Binding
Prev key.Binding Prev key.Binding
NextPage key.Binding NextPage key.Binding
PrevPage key.Binding PrevPage key.Binding
SetReport key.Binding SetReport key.Binding
SetContext key.Binding SetContext key.Binding
SetProject key.Binding SetProject key.Binding
Select key.Binding PickProjectTask key.Binding
Insert key.Binding Select key.Binding
Tag key.Binding Insert key.Binding
Undo key.Binding Tag key.Binding
StartStop key.Binding Undo key.Binding
Fill key.Binding
StartStop key.Binding
Join key.Binding
ViewDetails key.Binding
Subtask key.Binding
} }
// TODO: use config values for key bindings // TODO: use config values for key bindings
@@ -101,13 +106,13 @@ func NewKeymap() *Keymap {
), ),
NextPage: key.NewBinding( NextPage: key.NewBinding(
key.WithKeys("]"), key.WithKeys("]", "L"),
key.WithHelp("[", "Next page"), key.WithHelp("]/L", "Next page"),
), ),
PrevPage: key.NewBinding( PrevPage: key.NewBinding(
key.WithKeys("["), key.WithKeys("[", "H"),
key.WithHelp("]", "Previous page"), key.WithHelp("[/H", "Previous page"),
), ),
SetReport: key.NewBinding( SetReport: key.NewBinding(
@@ -125,6 +130,11 @@ func NewKeymap() *Keymap {
key.WithHelp("p", "Set project"), key.WithHelp("p", "Set project"),
), ),
PickProjectTask: key.NewBinding(
key.WithKeys("P"),
key.WithHelp("P", "Pick project task"),
),
Select: key.NewBinding( Select: key.NewBinding(
key.WithKeys(" "), key.WithKeys(" "),
key.WithHelp("space", "Select"), key.WithHelp("space", "Select"),
@@ -145,9 +155,29 @@ func NewKeymap() *Keymap {
key.WithHelp("undo", "Undo"), key.WithHelp("undo", "Undo"),
), ),
Fill: key.NewBinding(
key.WithKeys("f"),
key.WithHelp("fill", "Fill gaps"),
),
StartStop: key.NewBinding( StartStop: key.NewBinding(
key.WithKeys("s"), key.WithKeys("s"),
key.WithHelp("start/stop", "Start/Stop"), 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"),
),
Subtask: key.NewBinding(
key.WithKeys("S"),
key.WithHelp("S", "Create subtask"),
),
} }
} }

View File

@@ -0,0 +1,18 @@
package common
import (
"tasksquire/internal/taskwarrior"
"time"
tea "charm.land/bubbletea/v2"
)
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)
})
}

View File

@@ -38,3 +38,10 @@ func (s *Stack[T]) Pop() (T, error) {
return item, nil return item, nil
} }
func (s *Stack[T]) IsEmpty() bool {
s.mutex.Lock()
defer s.mutex.Unlock()
return len(s.items) == 0
}

234
internal/common/styles.go Normal file
View File

@@ -0,0 +1,234 @@
package common
import (
"log/slog"
"strconv"
"strings"
"image/color"
"tasksquire/internal/taskwarrior"
"charm.land/lipgloss/v2"
)
type TableStyle struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
type Palette struct {
Primary lipgloss.Style
Secondary lipgloss.Style
Accent lipgloss.Style
Muted lipgloss.Style
Border lipgloss.Style
Background lipgloss.Style
Text lipgloss.Style
}
type Styles struct {
Colors map[string]*lipgloss.Style
Palette Palette
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
}
func NewStyles(config *taskwarrior.TWConfig) *Styles {
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
// Initialize Palette (Iceberg Light)
styles.Palette.Primary = lipgloss.NewStyle().Foreground(lipgloss.Color("#2d539e")) // Blue
styles.Palette.Secondary = lipgloss.NewStyle().Foreground(lipgloss.Color("#7759b4")) // Purple
styles.Palette.Accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c57339")) // Orange
styles.Palette.Muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#8389a3")) // Grey
styles.Palette.Border = lipgloss.NewStyle().Foreground(lipgloss.Color("#cad0de")) // Light Grey Border
styles.Palette.Background = lipgloss.NewStyle().Background(lipgloss.Color("#e8e9ec")) // Light Background
styles.Palette.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#33374c")) // Dark Text
// Override from config if available (example mapping)
if s, ok := styles.Colors["primary"]; ok {
styles.Palette.Primary = *s
}
if s, ok := styles.Colors["secondary"]; ok {
styles.Palette.Secondary = *s
}
if s, ok := styles.Colors["active"]; ok {
styles.Palette.Accent = *s
}
styles.Base = lipgloss.NewStyle().Foreground(styles.Palette.Text.GetForeground())
styles.TableStyle = TableStyle{
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true).Foreground(styles.Palette.Primary.GetForeground()),
Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()),
}
// formTheme := huh.ThemeBase()
// formTheme.Focused.Title = formTheme.Focused.Title.Bold(true).Foreground(styles.Palette.Primary.GetForeground())
// formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
// formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true).Foreground(styles.Palette.Accent.GetForeground())
// formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
// formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ").Foreground(styles.Palette.Accent.GetForeground())
// formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
// formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true).Foreground(styles.Palette.Muted.GetForeground())
// formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
// formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
// formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
// formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ")
// formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ")
// styles.Form = formTheme
styles.Tab = lipgloss.NewStyle().
Padding(0, 1).
Foreground(styles.Palette.Muted.GetForeground())
styles.ActiveTab = styles.Tab.
Foreground(styles.Palette.Primary.GetForeground()).
Bold(true)
styles.TabBar = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(styles.Palette.Border.GetForeground()).
MarginBottom(1)
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Primary.GetForeground())
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1).BorderForeground(styles.Palette.Border.GetForeground())
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Accent.GetForeground())
return &styles
}
func (s *Styles) GetModalSize(width, height int) (int, int) {
modalWidth := 60
if width < 64 {
modalWidth = width - 4
}
modalHeight := 20
if height < 24 {
modalHeight = height - 4
}
return modalWidth, modalHeight
}
func parseColorString(color string) *lipgloss.Style {
if color == "" {
return nil
}
style := lipgloss.NewStyle()
if strings.Contains(color, "on") {
fgbg := strings.Split(color, "on")
fg := strings.TrimSpace(fgbg[0])
bg := strings.TrimSpace(fgbg[1])
if fg != "" {
style = style.Foreground(parseColor(fg))
}
if bg != "" {
style = style.Background(parseColor(bg))
}
} else {
style = style.Foreground(parseColor(strings.TrimSpace(color)))
}
return &style
}
func parseColor(color string) color.Color {
if strings.HasPrefix(color, "rgb") {
return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb")))
}
if strings.HasPrefix(color, "color") {
return lipgloss.Color(strings.TrimPrefix(color, "color"))
}
if strings.HasPrefix(color, "gray") {
gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray"))
if err != nil {
slog.Error("Invalid gray color format")
return lipgloss.Color("0")
}
return lipgloss.Color(strconv.Itoa(gray + 232))
}
if ansi, okcolor := colorStrings[color]; okcolor {
return lipgloss.Color(strconv.Itoa(ansi))
}
slog.Error("Invalid color format")
return lipgloss.Color("0")
}
func convertRgbToAnsi(rgbString string) string {
var err error
if len(rgbString) != 3 {
slog.Error("Invalid RGB color format")
return ""
}
r, err := strconv.Atoi(string(rgbString[0]))
if err != nil {
slog.Error("Invalid value for R")
return ""
}
g, err := strconv.Atoi(string(rgbString[1]))
if err != nil {
slog.Error("Invalid value for G")
return ""
}
b, err := strconv.Atoi(string(rgbString[2]))
if err != nil {
slog.Error("Invalid value for B")
return ""
}
return strconv.Itoa(16 + (36 * r) + (6 * g) + b)
}
var colorStrings = map[string]int{
"black": 0,
"red": 1,
"green": 2,
"yellow": 3,
"blue": 4,
"magenta": 5,
"cyan": 6,
"white": 7,
"bright black": 8,
"bright red": 9,
"bright green": 10,
"bright yellow": 11,
"bright blue": 12,
"bright magenta": 13,
"bright cyan": 14,
"bright white": 15,
}

85
internal/common/sync.go Normal file
View File

@@ -0,0 +1,85 @@
package common
import (
"log/slog"
"tasksquire/internal/taskwarrior"
"tasksquire/internal/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

@@ -29,7 +29,7 @@ type Task struct {
Depends []string `json:"depends,omitempty"` Depends []string `json:"depends,omitempty"`
DependsIds string `json:"-"` DependsIds string `json:"-"`
Urgency float32 `json:"urgency,omitempty"` Urgency float32 `json:"urgency,omitempty"`
Parent string `json:"parent,omitempty"` Parent string `json:"parenttask,omitempty"`
Due string `json:"due,omitempty"` Due string `json:"due,omitempty"`
Wait string `json:"wait,omitempty"` Wait string `json:"wait,omitempty"`
Scheduled string `json:"scheduled,omitempty"` Scheduled string `json:"scheduled,omitempty"`
@@ -125,7 +125,7 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
return strings.Join(t.Tags, " ") return strings.Join(t.Tags, " ")
case "parent": case "parenttask":
if format == "short" { if format == "short" {
return t.Parent[:8] return t.Parent[:8]
} }
@@ -178,7 +178,7 @@ func (t *Task) GetString(fieldWFormat string) string {
return t.Recur return t.Recur
default: default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field)) slog.Error("Field not implemented", "field", field)
return "" return ""
} }
} }
@@ -214,7 +214,7 @@ func formatDate(date string, format string) string {
dtformat := "20060102T150405Z" dtformat := "20060102T150405Z"
dt, err := time.Parse(dtformat, date) dt, err := time.Parse(dtformat, date)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return "" return ""
} }
@@ -238,7 +238,7 @@ func formatDate(date string, format string) string {
case "countdown": case "countdown":
return parseCountdown(time.Since(dt)) return parseCountdown(time.Since(dt))
default: default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format)) slog.Error("Date format not implemented", "format", format)
return "" return ""
} }
} }

View File

@@ -1,30 +1,26 @@
package timetable package tasktable
import ( import (
"strings" taskw "tasksquire/internal/taskwarrior"
"github.com/charmbracelet/bubbles/key" "charm.land/bubbles/v2/help"
"github.com/charmbracelet/bubbles/viewport" "charm.land/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea" "charm.land/bubbles/v2/viewport"
"github.com/charmbracelet/lipgloss" tea "charm.land/bubbletea/v2"
"github.com/mattn/go-runewidth" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"tasksquire/common"
"tasksquire/timewarrior"
) )
// Model defines a state for the table widget. // Model defines a state for the table widget.
type Model struct { type Model struct {
common *common.Common
KeyMap KeyMap KeyMap KeyMap
Help help.Model
cols []Column cols []Column
rows timewarrior.Intervals rows []Row
rowStyles []lipgloss.Style cursor int
cursor int focus bool
focus bool styles Styles
styles common.TableStyle
styleFunc StyleFunc
viewport viewport.Model viewport viewport.Model
start int start int
@@ -32,19 +28,20 @@ type Model struct {
} }
// Row represents one line in the table. // Row represents one line in the table.
type Row *timewarrior.Interval type Row struct {
task taskw.Task
style lipgloss.Style
}
// Column defines the table structure. // Column defines the table structure.
type Column struct { type Column struct {
Title string Title string
Name string Name string
Width int Width int
MaxWidth int
ContentWidth int
} }
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which // KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu. // is used to render the help menu.
type KeyMap struct { type KeyMap struct {
LineUp key.Binding LineUp key.Binding
LineDown key.Binding LineDown key.Binding
@@ -71,7 +68,6 @@ func (km KeyMap) FullHelp() [][]key.Binding {
// DefaultKeyMap returns a default set of keybindings. // DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap { func DefaultKeyMap() KeyMap {
const spacebar = " "
return KeyMap{ return KeyMap{
LineUp: key.NewBinding( LineUp: key.NewBinding(
key.WithKeys("up", "k"), key.WithKeys("up", "k"),
@@ -86,7 +82,7 @@ func DefaultKeyMap() KeyMap {
key.WithHelp("b/pgup", "page up"), key.WithHelp("b/pgup", "page up"),
), ),
PageDown: key.NewBinding( PageDown: key.NewBinding(
key.WithKeys("f", "pgdown", spacebar), key.WithKeys("f", "pgdown", "space"),
key.WithHelp("f/pgdn", "page down"), key.WithHelp("f/pgdn", "page down"),
), ),
HalfPageUp: key.NewBinding( HalfPageUp: key.NewBinding(
@@ -108,8 +104,25 @@ func DefaultKeyMap() KeyMap {
} }
} }
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this table.
func DefaultStyles() Styles {
return Styles{
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1),
Cell: lipgloss.NewStyle().Padding(0, 1),
}
}
// SetStyles sets the table styles. // SetStyles sets the table styles.
func (m *Model) SetStyles(s common.TableStyle) { func (m *Model) SetStyles(s Styles) {
m.styles = s m.styles = s
m.UpdateViewport() m.UpdateViewport()
} }
@@ -120,83 +133,25 @@ func (m *Model) SetStyles(s common.TableStyle) {
type Option func(*Model) type Option func(*Model)
// New creates a new model for the table widget. // New creates a new model for the table widget.
func New(com *common.Common, opts ...Option) Model { func New(opts ...Option) Model {
m := Model{ m := Model{
common: com,
cursor: 0, cursor: 0,
viewport: viewport.New(0, 20), viewport: viewport.New(viewport.WithHeight(20)), //nolint:mnd
KeyMap: DefaultKeyMap(), KeyMap: DefaultKeyMap(),
Help: help.New(),
styles: DefaultStyles(),
} }
for _, opt := range opts { for _, opt := range opts {
opt(&m) opt(&m)
} }
m.cols = m.parseColumns(m.cols)
m.rowStyles = m.parseRowStyles(m.rows)
m.UpdateViewport() m.UpdateViewport()
return m 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). // WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option { func WithColumns(cols []Column) Option {
return func(m *Model) { return func(m *Model) {
@@ -205,7 +160,7 @@ func WithColumns(cols []Column) Option {
} }
// WithRows sets the table rows (data). // WithRows sets the table rows (data).
func WithIntervals(rows timewarrior.Intervals) Option { func WithRows(rows []Row) Option {
return func(m *Model) { return func(m *Model) {
m.rows = rows m.rows = rows
} }
@@ -214,14 +169,14 @@ func WithIntervals(rows timewarrior.Intervals) Option {
// WithHeight sets the height of the table. // WithHeight sets the height of the table.
func WithHeight(h int) Option { func WithHeight(h int) Option {
return func(m *Model) { return func(m *Model) {
m.viewport.Height = h - lipgloss.Height(m.headersView()) m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
} }
} }
// WithWidth sets the width of the table. // WithWidth sets the width of the table.
func WithWidth(w int) Option { func WithWidth(w int) Option {
return func(m *Model) { return func(m *Model) {
m.viewport.Width = w m.viewport.SetWidth(w)
} }
} }
@@ -233,19 +188,12 @@ func WithFocused(f bool) Option {
} }
// WithStyles sets the table styles. // WithStyles sets the table styles.
func WithStyles(s common.TableStyle) Option { func WithStyles(s Styles) Option {
return func(m *Model) { return func(m *Model) {
m.styles = s 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. // WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) Option { func WithKeyMap(km KeyMap) Option {
return func(m *Model) { return func(m *Model) {
@@ -260,22 +208,20 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyPressMsg:
switch { switch {
case key.Matches(msg, m.KeyMap.LineUp): case key.Matches(msg, m.KeyMap.LineUp):
m.MoveUp(1) m.MoveUp(1)
case key.Matches(msg, m.KeyMap.LineDown): case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1) m.MoveDown(1)
case key.Matches(msg, m.KeyMap.PageUp): case key.Matches(msg, m.KeyMap.PageUp):
m.MoveUp(m.viewport.Height) m.MoveUp(m.viewport.Height())
case key.Matches(msg, m.KeyMap.PageDown): case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height) m.MoveDown(m.viewport.Height())
case key.Matches(msg, m.KeyMap.HalfPageUp): case key.Matches(msg, m.KeyMap.HalfPageUp):
m.MoveUp(m.viewport.Height / 2) m.MoveUp(m.viewport.Height() / 2) //nolint:mnd
case key.Matches(msg, m.KeyMap.HalfPageDown): case key.Matches(msg, m.KeyMap.HalfPageDown):
m.MoveDown(m.viewport.Height / 2) m.MoveDown(m.viewport.Height() / 2) //nolint:mnd
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.GotoTop): case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop() m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom): case key.Matches(msg, m.KeyMap.GotoBottom):
@@ -309,17 +255,27 @@ func (m Model) View() string {
return m.headersView() + "\n" + m.viewport.View() return m.headersView() + "\n" + m.viewport.View()
} }
// HelpView is a helper method for rendering the help menu from the keymap.
// Note that this view is not rendered by default and you must call it
// manually in your application, where applicable.
func (m Model) HelpView() string {
return m.Help.View(m.KeyMap)
}
// UpdateViewport updates the list content based on the previously defined // UpdateViewport updates the list content based on the previously defined
// columns and rows. // columns and rows.
func (m *Model) UpdateViewport() { func (m *Model) UpdateViewport() {
renderedRows := make([]string, 0, len(m.rows)) renderedRows := make([]string, 0, len(m.rows))
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
// Constant runtime, independent of number of rows in a table.
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
if m.cursor >= 0 { if m.cursor >= 0 {
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor) m.start = clamp(m.cursor-m.viewport.Height(), 0, m.cursor)
} else { } else {
m.start = 0 m.start = 0
} }
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows)) m.end = clamp(m.cursor+m.viewport.Height(), m.cursor, len(m.rows))
for i := m.start; i < m.end; i++ { for i := m.start; i < m.end; i++ {
renderedRows = append(renderedRows, m.renderRow(i)) renderedRows = append(renderedRows, m.renderRow(i))
} }
@@ -330,16 +286,17 @@ func (m *Model) UpdateViewport() {
} }
// SelectedRow returns the selected row. // SelectedRow returns the selected row.
// You can cast it to your own implementation.
func (m Model) SelectedRow() Row { func (m Model) SelectedRow() Row {
if m.cursor < 0 || m.cursor >= len(m.rows) { if m.cursor < 0 || m.cursor >= len(m.rows) {
return nil return Row{}
} }
return m.rows[m.cursor] return m.rows[m.cursor]
} }
// Rows returns the current rows. // Rows returns the current rows.
func (m Model) Rows() timewarrior.Intervals { func (m Model) Rows() []Row {
return m.rows return m.rows
} }
@@ -349,8 +306,13 @@ func (m Model) Columns() []Column {
} }
// SetRows sets a new rows state. // SetRows sets a new rows state.
func (m *Model) SetRows(r timewarrior.Intervals) { func (m *Model) SetRows(r []Row) {
m.rows = r m.rows = r
if m.cursor > len(m.rows)-1 {
m.cursor = len(m.rows) - 1
}
m.UpdateViewport() m.UpdateViewport()
} }
@@ -362,24 +324,24 @@ func (m *Model) SetColumns(c []Column) {
// SetWidth sets the width of the viewport of the table. // SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) { func (m *Model) SetWidth(w int) {
m.viewport.Width = w m.viewport.SetWidth(w)
m.UpdateViewport() m.UpdateViewport()
} }
// SetHeight sets the height of the viewport of the table. // SetHeight sets the height of the viewport of the table.
func (m *Model) SetHeight(h int) { func (m *Model) SetHeight(h int) {
m.viewport.Height = h - lipgloss.Height(m.headersView()) m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
m.UpdateViewport() m.UpdateViewport()
} }
// Height returns the viewport height of the table. // Height returns the viewport height of the table.
func (m Model) Height() int { func (m Model) Height() int {
return m.viewport.Height return m.viewport.Height()
} }
// Width returns the viewport width of the table. // Width returns the viewport width of the table.
func (m Model) Width() int { func (m Model) Width() int {
return m.viewport.Width return m.viewport.Width()
} }
// Cursor returns the index of the selected row. // Cursor returns the index of the selected row.
@@ -397,14 +359,17 @@ func (m *Model) SetCursor(n int) {
// It can not go above the first row. // It can not go above the first row.
func (m *Model) MoveUp(n int) { func (m *Model) MoveUp(n int) {
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1) m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
offset := m.viewport.YOffset()
switch { switch {
case m.start == 0: case m.start == 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor)) offset = clamp(offset, 0, m.cursor)
case m.start < m.viewport.Height: case m.start < m.viewport.Height():
m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height)) offset = clamp(clamp(offset+n, 0, m.cursor), 0, m.viewport.Height())
case m.viewport.YOffset >= 1: case offset >= 1:
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height) offset = clamp(offset+n, 1, m.viewport.Height())
} }
m.viewport.SetYOffset(offset)
m.UpdateViewport() m.UpdateViewport()
} }
@@ -414,15 +379,17 @@ func (m *Model) MoveDown(n int) {
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1) m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
m.UpdateViewport() m.UpdateViewport()
offset := m.viewport.YOffset()
switch { switch {
case m.end == len(m.rows) && m.viewport.YOffset > 0: case m.end == len(m.rows) && offset > 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height)) offset = clamp(offset-n, 1, m.viewport.Height())
case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0: case m.cursor > (m.end-m.start)/2 && offset > 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor)) offset = clamp(offset-n, 1, m.cursor)
case m.viewport.YOffset > 1: case offset > 1:
case m.cursor > m.viewport.YOffset+m.viewport.Height-1: case m.cursor > offset+m.viewport.Height()-1:
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1)) offset = clamp(offset+1, 0, 1)
} }
m.viewport.SetYOffset(offset)
} }
// GotoTop moves the selection to the first row. // GotoTop moves the selection to the first row.
@@ -435,40 +402,36 @@ func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows)) 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 { func (m Model) headersView() string {
var s = make([]string, 0, len(m.cols)) s := make([]string, 0, len(m.cols))
for _, col := range m.cols { for _, col := range m.cols {
if col.Width <= 0 { if col.Width <= 0 {
continue continue
} }
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) renderedCell := style.Render(ansi.Truncate(col.Title, col.Width, "…"))
s = append(s, m.styles.Header.Render(renderedCell)) s = append(s, m.styles.Header.Render(renderedCell))
} }
return lipgloss.JoinHorizontal(lipgloss.Left, s...) return lipgloss.JoinHorizontal(lipgloss.Top, s...)
} }
func (m *Model) renderRow(r int) string { func (m *Model) renderRow(r int) string {
var s = make([]string, 0, len(m.cols)) s := make([]string, 0, len(m.cols))
for i, col := range m.cols { for i, col := range m.cols {
if m.cols[i].Width <= 0 { if m.cols[i].Width <= 0 {
continue continue
} }
var cellStyle lipgloss.Style cellStyle := m.rows[r].style
cellStyle = m.rowStyles[r]
if r == m.cursor { if r == m.cursor {
cellStyle = cellStyle.Inherit(m.styles.Selected) cellStyle = cellStyle.Inherit(m.styles.Selected)
} }
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) 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, "…"))) renderedCell := cellStyle.Render(style.Render(ansi.Truncate(m.rows[r].task.GetString(col.Name), m.cols[i].Width, "…")))
s = append(s, renderedCell) s = append(s, renderedCell)
} }
row := lipgloss.JoinHorizontal(lipgloss.Left, s...) row := lipgloss.JoinHorizontal(lipgloss.Top, s...)
if r == m.cursor { if r == m.cursor {
return m.styles.Selected.Render(row) return m.styles.Selected.Render(row)
@@ -477,22 +440,6 @@ func (m *Model) renderRow(r int) string {
return 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 { func clamp(v, low, high int) int {
return min(max(v, low), high) return min(max(v, low), high)
} }

112
internal/pages/main.go Normal file
View File

@@ -0,0 +1,112 @@
package pages
import (
"tasksquire/internal/common"
tea "charm.land/bubbletea/v2"
// "charm.land/bubbles/v2/key"
"charm.land/lipgloss/v2"
)
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 {
m := &MainPage{
common: common,
}
m.taskPage = NewTaskPage(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 tea.Batch(m.taskPage.Init(), m.timePage.Init())
return tea.Batch()
}
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// switch msg := msg.(type) {
// case tea.WindowSizeMsg:
// m.width = msg.Width
// m.height = msg.Height
// m.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)
m.activePage = activePage.(common.Component)
return m, cmd
}
func (m *MainPage) renderTabBar() string {
var tabs []string
headers := []string{"Tasks", "Time"}
for i, header := range headers {
style := m.common.Styles.Tab
if m.currentTab == i {
style = m.common.Styles.ActiveTab
}
tabs = append(tabs, style.Render(header))
}
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
}
func (m *MainPage) View() tea.View {
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View().Content))
v.AltScreen = true
return v
// return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
}

355
internal/pages/tasks.go Normal file
View File

@@ -0,0 +1,355 @@
// TODO: update table every second (to show correct relative time)
package pages
import (
"tasksquire/internal/common"
"tasksquire/internal/components/tasktable"
"tasksquire/internal/taskwarrior"
tea "charm.land/bubbletea/v2"
// "charm.land/lipgloss/v2"
"charm.land/bubbles/v2/key"
)
type TaskPage struct {
common *common.Common
activeReport *taskwarrior.Report
activeContext *taskwarrior.Context
activeProject string
selectedTask *taskwarrior.Task
taskCursor int
tasks taskwarrior.Tasks
taskTable tasktable.Model
// Details panel state
// detailsPanelActive bool
// detailsViewer *detailsviewer.DetailsViewer
subpage common.Component
}
func NewTaskPage(com *common.Common, report *taskwarrior.Report) *TaskPage {
p := &TaskPage{
common: com,
activeReport: report,
activeContext: com.TW.GetActiveContext(),
activeProject: "",
taskTable: tasktable.New(),
// detailsPanelActive: false,
// detailsViewer: detailsviewer.New(com),
}
return p
}
func (p *TaskPage) SetSize(width int, height int) {
p.common.SetSize(width, height)
baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize()
baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize()
var tableHeight int
// if p.detailsPanelActive {
// // Allocate 60% for table, 40% for details panel
// // Minimum 5 lines for details, minimum 10 lines for table
// detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
// tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
//
// // Set component size (component handles its own border/padding)
// // p.detailsViewer.SetSize(baseWidth, detailsHeight)
// } else {
tableHeight = baseHeight
// }
p.taskTable.SetWidth(baseWidth)
p.taskTable.SetHeight(tableHeight)
}
func (p *TaskPage) Init() tea.Cmd {
return tea.Batch(p.getTasks(), common.DoTick())
}
func (p *TaskPage) 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 BackMsg:
case common.TickMsg:
cmds = append(cmds, p.getTasks())
cmds = append(cmds, common.DoTick())
return p, tea.Batch(cmds...)
case common.TaskMsg:
p.tasks = taskwarrior.Tasks(msg)
// case UpdateReportMsg:
// p.activeReport = msg
// cmds = append(cmds, p.getTasks())
// case UpdateContextMsg:
// p.activeContext = msg
// p.common.TW.SetContext(msg)
// p.populateTaskTable(p.tasks)
// cmds = append(cmds, p.getTasks())
// 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.KeyPressMsg:
// 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)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.SetContext):
// p.subpage = NewContextPickerPage(p.common)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.Add):
// p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.Edit):
// p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.Subtask):
// if p.selectedTask != nil {
// // Create new task inheriting parent's attributes
// newTask := taskwarrior.NewTask()
//
// // Set parent relationship
// newTask.Parent = p.selectedTask.Uuid
//
// // Copy parent's attributes
// newTask.Project = p.selectedTask.Project
// newTask.Priority = p.selectedTask.Priority
// newTask.Tags = make([]string, len(p.selectedTask.Tags))
// copy(newTask.Tags, p.selectedTask.Tags)
//
// // Copy UDAs (except "details" which is task-specific)
// if p.selectedTask.Udas != nil {
// newTask.Udas = make(map[string]any)
// for k, v := range p.selectedTask.Udas {
// // Skip "details" UDA - it's specific to parent task
// if k == "details" {
// continue
// }
// // Deep copy other UDA values
// newTask.Udas[k] = v
// }
// }
//
// // Open task editor with pre-populated task
// p.subpage = NewTaskEditorPage(p.common, newTask)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// }
// return p, nil
// case key.Matches(msg, p.common.Keymap.Ok):
// p.common.TW.SetTaskDone(p.selectedTask)
// return p, p.getTasks()
// case key.Matches(msg, p.common.Keymap.Delete):
// p.common.TW.DeleteTask(p.selectedTask)
// return p, p.getTasks()
// case key.Matches(msg, p.common.Keymap.SetProject):
// p.subpage = NewProjectPickerPage(p.common, p.activeProject)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.PickProjectTask):
// p.subpage = NewProjectTaskPickerPage(p.common)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.Tag):
// if p.selectedTask != nil {
// tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
// if p.selectedTask.HasTag(tag) {
// p.selectedTask.RemoveTag(tag)
// } else {
// p.selectedTask.AddTag(tag)
// }
// p.common.TW.ImportTask(p.selectedTask)
// return p, p.getTasks()
// }
// return p, nil
// case key.Matches(msg, p.common.Keymap.Undo):
// p.common.TW.Undo()
// return p, p.getTasks()
// 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)
//
// if p.tasks != nil && len(p.tasks) > 0 {
// p.selectedTask = p.tasks[p.taskTable.Cursor()]
// } else {
// p.selectedTask = nil
// }
// }
}
return p, tea.Batch(cmds...)
}
func (p *TaskPage) View() tea.View {
if len(p.tasks) == 0 {
return tea.NewView(p.common.Styles.Base.Render("No tasks found"))
}
tableView := p.taskTable.View()
return tea.NewView(tableView)
}
//
// if !p.detailsPanelActive {
// return tableView
// }
//
// // Combine table and details panel vertically
// return lipgloss.JoinVertical(
// lipgloss.Left,
// tableView,
// p.detailsViewer.View(),
// )
// }
//
// func (p *TaskPage) populateTaskTable(tasks taskwarrior.Tasks) {
// if len(tasks) == 0 {
// return
// }
//
// // Build task tree for hierarchical display
// taskTree := taskwarrior.BuildTaskTree(tasks)
//
// // Use flattened tree list for display order
// orderedTasks := make(taskwarrior.Tasks, len(taskTree.FlatList))
// for i, node := range taskTree.FlatList {
// orderedTasks[i] = node.Task
// }
//
// selected := p.taskTable.Cursor()
//
// // Adjust cursor for tree ordering
// if p.selectedTask != nil {
// for i, task := range orderedTasks {
// if task.Uuid == p.selectedTask.Uuid {
// selected = i
// break
// }
// }
// }
// if selected > len(orderedTasks)-1 {
// selected = len(orderedTasks) - 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,
// able.WithReport(p.activeReport),
// table.WithTasks(orderedTasks),
// table.WithTaskTree(taskTree),
// table.WithFocused(true),
// table.WithWidth(baseWidth),
// table.WithHeight(tableHeight),
// table.WithStyles(p.common.Styles.TableStyle),
// )
//
// if selected == 0 {
// selected = p.taskTable.Cursor()
// }
// if selected < len(orderedTasks) {
// p.taskTable.SetCursor(selected)
// } 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 *TaskPage) getTasks() tea.Cmd {
return func() tea.Msg {
filters := []string{}
if p.activeProject != "" {
filters = append(filters, "project:"+p.activeProject)
}
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
return common.TaskMsg(tasks)
}
}

View File

@@ -39,7 +39,7 @@ func (tc *TWConfig) GetConfig() map[string]string {
func (tc *TWConfig) Get(key string) string { func (tc *TWConfig) Get(key string) string {
if _, ok := tc.config[key]; !ok { if _, ok := tc.config[key]; !ok {
slog.Debug(fmt.Sprintf("Key not found in config: %s", key)) slog.Debug(fmt.Sprintf("Key not found in Taskwarrior config: %s", key))
return "" return ""
} }

View File

@@ -43,18 +43,18 @@ func (a Annotation) String() string {
type Tasks []*Task type Tasks []*Task
type Task struct { type Task struct {
Id int64 `json:"id,omitempty"` Id int64 `json:"id,omitempty"`
Uuid string `json:"uuid,omitempty"` Uuid string `json:"uuid,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Project string `json:"project"` Project string `json:"project"`
// Priority string `json:"priority"` Priority string `json:"priority,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
VirtualTags []string `json:"-"` VirtualTags []string `json:"-"`
Depends []string `json:"depends,omitempty"` Depends []string `json:"depends,omitempty"`
DependsIds string `json:"-"` DependsIds string `json:"-"`
Urgency float32 `json:"urgency,omitempty"` Urgency float32 `json:"urgency,omitempty"`
Parent string `json:"parent,omitempty"` Parent string `json:"parenttask,omitempty"`
Due string `json:"due,omitempty"` Due string `json:"due,omitempty"`
Wait string `json:"wait,omitempty"` Wait string `json:"wait,omitempty"`
Scheduled string `json:"scheduled,omitempty"` Scheduled string `json:"scheduled,omitempty"`
@@ -120,19 +120,25 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
} }
if len(t.Annotations) == 0 { if t.Udas["details"] != nil && t.Udas["details"] != "" {
return t.Description return fmt.Sprintf("%s [D]", t.Description)
} else { } else {
// var annotations []string return t.Description
// for _, a := range t.Annotations {
// annotations = append(annotations, a.String())
// }
// return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
// TODO enable support for multiline in table
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
} }
// if len(t.Annotations) == 0 {
// return t.Description
// } else {
// // var annotations []string
// // for _, a := range t.Annotations {
// // annotations = append(annotations, a.String())
// // }
// // return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
// // TODO enable support for multiline in table
// return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
// }
case "project": case "project":
switch format { switch format {
case "parent": case "parent":
@@ -143,8 +149,8 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
return t.Project return t.Project
// case "priority": case "priority":
// return t.Priority return t.Priority
case "status": case "status":
return t.Status return t.Status
@@ -162,7 +168,7 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
return strings.Join(t.Tags, " ") return strings.Join(t.Tags, " ")
case "parent": case "parenttask":
if format == "short" { if format == "short" {
return t.Parent[:8] return t.Parent[:8]
} }
@@ -223,14 +229,40 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
} }
slog.Error(fmt.Sprintf("Field not implemented: %s", field)) slog.Error("Field not implemented", "field", field)
return "" return ""
} }
func (t *Task) GetDate(dateString string) time.Time { 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) dt, err := time.Parse(dtformat, dateString)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return time.Time{} return time.Time{}
} }
return dt return dt
@@ -285,7 +317,7 @@ func (t *Task) UnmarshalJSON(data []byte) error {
delete(m, "tags") delete(m, "tags")
delete(m, "depends") delete(m, "depends")
delete(m, "urgency") delete(m, "urgency")
delete(m, "parent") delete(m, "parenttask")
delete(m, "due") delete(m, "due")
delete(m, "wait") delete(m, "wait")
delete(m, "scheduled") delete(m, "scheduled")
@@ -352,7 +384,7 @@ func formatDate(date string, format string) string {
dt, err := time.Parse(dtformat, date) dt, err := time.Parse(dtformat, date)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return "" return ""
} }
@@ -376,7 +408,7 @@ func formatDate(date string, format string) string {
case "countdown": case "countdown":
return parseCountdown(time.Since(dt)) return parseCountdown(time.Since(dt))
default: default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format)) slog.Error("Date format not implemented", "format", format)
return "" return ""
} }
} }

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

@@ -5,6 +5,7 @@ package taskwarrior
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -98,55 +99,62 @@ type TaskWarrior interface {
DeleteTask(task *Task) DeleteTask(task *Task)
StartTask(task *Task) StartTask(task *Task)
StopTask(task *Task) StopTask(task *Task)
StopActiveTasks()
GetInformation(task *Task) string GetInformation(task *Task) string
AddTaskAnnotation(uuid string, annotation string) AddTaskAnnotation(uuid string, annotation string)
Undo() Undo()
} }
type TaskSquire struct { type TaskwarriorInterop struct {
configLocation string configLocation string
defaultArgs []string defaultArgs []string
config *TWConfig config *TWConfig
reports Reports reports Reports
contexts Contexts contexts Contexts
ctx context.Context
mutex sync.Mutex mutex sync.Mutex
} }
func NewTaskSquire(configLocation string) *TaskSquire { func NewTaskwarriorInterop(ctx context.Context, configLocation string) *TaskwarriorInterop {
if _, err := exec.LookPath(twBinary); err != nil { if _, err := exec.LookPath(twBinary); err != nil {
slog.Error("Taskwarrior not found") slog.Error("Taskwarrior not found")
return nil return nil
} }
defaultArgs := []string{fmt.Sprintf("rc=%s", configLocation), "rc.verbose=nothing", "rc.dependency.confirmation=no", "rc.recurrence.confirmation=no", "rc.confirmation=no", "rc.json.array=1"} defaultArgs := []string{fmt.Sprintf("rc=%s", configLocation), "rc.verbose=nothing", "rc.dependency.confirmation=no", "rc.recurrence.confirmation=no", "rc.confirmation=no", "rc.json.array=1"}
ts := &TaskSquire{ ts := &TaskwarriorInterop{
configLocation: configLocation, configLocation: configLocation,
defaultArgs: defaultArgs, defaultArgs: defaultArgs,
ctx: ctx,
mutex: sync.Mutex{}, mutex: sync.Mutex{},
} }
ts.config = ts.extractConfig() ts.config = ts.extractConfig()
if ts.config == nil {
slog.Error("Failed to extract config - taskwarrior commands are failing. Check your taskrc file for syntax errors.")
return nil
}
ts.reports = ts.extractReports() ts.reports = ts.extractReports()
ts.contexts = ts.extractContexts() ts.contexts = ts.extractContexts()
return ts return ts
} }
func (ts *TaskSquire) GetConfig() *TWConfig { func (ts *TaskwarriorInterop) GetConfig() *TWConfig {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
return ts.config return ts.config
} }
func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks { func (ts *TaskwarriorInterop) GetTasks(report *Report, filter ...string) Tasks {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
args := ts.defaultArgs args := ts.defaultArgs
if report.Context { if report != nil && report.Context {
for _, context := range ts.contexts { for _, context := range ts.contexts {
if context.Active && context.Name != "none" { if context.Active && context.Name != "none" {
args = append(args, context.ReadFilter) args = append(args, context.ReadFilter)
@@ -159,22 +167,30 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args = append(args, filter...) 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.CommandContext(ts.ctx, twBinary, append(args, exportArgs...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting report:", err) if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting report", "error", err)
return nil return nil
} }
tasks := make(Tasks, 0) tasks := make(Tasks, 0)
err = json.Unmarshal(output, &tasks) err = json.Unmarshal(output, &tasks)
if err != nil { if err != nil {
slog.Error("Failed unmarshalling tasks:", err) slog.Error("Failed unmarshalling tasks", "error", err)
return nil return nil
} }
for _, task := range tasks { for _, task := range tasks {
if task.Depends != nil && len(task.Depends) > 0 { if len(task.Depends) > 0 {
ids := make([]string, len(task.Depends)) ids := make([]string, len(task.Depends))
for i, dependUuid := range task.Depends { for i, dependUuid := range task.Depends {
ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)}) ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)})
@@ -187,18 +203,18 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
return tasks return tasks
} }
func (ts *TaskSquire) getIds(filter []string) string { func (ts *TaskwarriorInterop) getIds(filter []string) string {
cmd := exec.Command(twBinary, append(append(ts.defaultArgs, filter...), "_ids")...) cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting field:", err) slog.Error("Failed getting field", "error", err)
return "" return ""
} }
return strings.TrimSpace(string(out)) return strings.TrimSpace(string(out))
} }
func (ts *TaskSquire) GetContext(context string) *Context { func (ts *TaskwarriorInterop) GetContext(context string) *Context {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
@@ -209,12 +225,12 @@ func (ts *TaskSquire) GetContext(context string) *Context {
if context, ok := ts.contexts[context]; ok { if context, ok := ts.contexts[context]; ok {
return context return context
} else { } else {
slog.Error(fmt.Sprintf("Context not found: %s", context.Name)) slog.Error("Context not found", "name", context)
return nil return nil
} }
} }
func (ts *TaskSquire) GetActiveContext() *Context { func (ts *TaskwarriorInterop) GetActiveContext() *Context {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
@@ -227,22 +243,22 @@ func (ts *TaskSquire) GetActiveContext() *Context {
return ts.contexts["none"] return ts.contexts["none"]
} }
func (ts *TaskSquire) GetContexts() Contexts { func (ts *TaskwarriorInterop) GetContexts() Contexts {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
return ts.contexts return ts.contexts
} }
func (ts *TaskSquire) GetProjects() []string { func (ts *TaskwarriorInterop) GetProjects() []string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_projects"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting projects:", err) slog.Error("Failed getting projects", "error", err)
return nil return nil
} }
@@ -258,7 +274,7 @@ func (ts *TaskSquire) GetProjects() []string {
return projects return projects
} }
func (ts *TaskSquire) GetPriorities() []string { func (ts *TaskwarriorInterop) GetPriorities() []string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
@@ -272,15 +288,15 @@ func (ts *TaskSquire) GetPriorities() []string {
return priorities return priorities
} }
func (ts *TaskSquire) GetTags() []string { func (ts *TaskwarriorInterop) GetTags() []string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_tags"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting tags:", err) slog.Error("Failed getting tags", "error", err)
return nil return nil
} }
@@ -294,7 +310,7 @@ func (ts *TaskSquire) GetTags() []string {
} }
} }
for _, tag := range strings.Split(ts.config.Get("uda.tasksquire.tags.default"), ",") { for _, tag := range strings.Split(ts.config.Get("uda.TaskwarriorInterop.tags.default"), ",") {
if _, ok := tagSet[tag]; !ok { if _, ok := tagSet[tag]; !ok {
tags = append(tags, tag) tags = append(tags, tag)
} }
@@ -305,28 +321,31 @@ func (ts *TaskSquire) GetTags() []string {
return tags return tags
} }
func (ts *TaskSquire) GetReport(report string) *Report { func (ts *TaskwarriorInterop) GetReport(report string) *Report {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
return ts.reports[report] return ts.reports[report]
} }
func (ts *TaskSquire) GetReports() Reports { func (ts *TaskwarriorInterop) GetReports() Reports {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
return ts.reports return ts.reports
} }
func (ts *TaskSquire) GetUdas() []Uda { func (ts *TaskwarriorInterop) GetUdas() []Uda {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "_udas")...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "_udas")...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting config:", err) if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting UDAs", "error", err)
return nil return nil
} }
@@ -335,7 +354,7 @@ func (ts *TaskSquire) GetUdas() []Uda {
if uda != "" { if uda != "" {
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda))) udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
if udatype == "" { if udatype == "" {
slog.Error(fmt.Sprintf("UDA type not found: %s", uda)) slog.Error("UDA type not found", "uda", uda)
continue continue
} }
@@ -358,7 +377,7 @@ func (ts *TaskSquire) GetUdas() []Uda {
return udas return udas
} }
func (ts *TaskSquire) SetContext(context *Context) error { func (ts *TaskwarriorInterop) SetContext(context *Context) error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
@@ -366,9 +385,9 @@ func (ts *TaskSquire) SetContext(context *Context) error {
return nil return nil
} }
cmd := exec.Command(twBinary, []string{"context", context.Name}...) cmd := exec.CommandContext(ts.ctx, twBinary, []string{"context", context.Name}...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed setting context:", err) slog.Error("Failed setting context", "error", err)
return err return err
} }
@@ -379,7 +398,7 @@ func (ts *TaskSquire) SetContext(context *Context) error {
return nil return nil
} }
// func (ts *TaskSquire) AddTask(task *Task) error { // func (ts *TaskwarriorInterop) AddTask(task *Task) error {
// ts.mutex.Lock() // ts.mutex.Lock()
// defer ts.mutex.Unlock() // defer ts.mutex.Unlock()
@@ -417,117 +436,156 @@ func (ts *TaskSquire) SetContext(context *Context) error {
// } // }
// TODO error handling // TODO error handling
func (ts *TaskSquire) ImportTask(task *Task) { func (ts *TaskwarriorInterop) ImportTask(task *Task) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
tasks, err := json.Marshal(Tasks{task}) tasks, err := json.Marshal(Tasks{task})
if err != nil { if err != nil {
slog.Error("Failed marshalling task:", err) slog.Error("Failed marshalling task", "error", err)
} }
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
cmd.Stdin = bytes.NewBuffer(tasks) cmd.Stdin = bytes.NewBuffer(tasks)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed modifying task:", err, string(out)) if ts.ctx.Err() == context.Canceled {
return
}
slog.Error("Failed modifying task", "error", err, "output", string(out))
} }
} }
func (ts *TaskSquire) SetTaskDone(task *Task) { func (ts *TaskwarriorInterop) SetTaskDone(task *Task) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed setting task done:", err) slog.Error("Failed setting task done", "error", err)
} }
} }
func (ts *TaskSquire) DeleteTask(task *Task) { func (ts *TaskwarriorInterop) DeleteTask(task *Task) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed deleting task:", err) slog.Error("Failed deleting task", "error", err)
} }
} }
func (ts *TaskSquire) Undo() { func (ts *TaskwarriorInterop) Undo() {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed undoing task:", err) slog.Error("Failed undoing task", "error", err)
} }
} }
func (ts *TaskSquire) StartTask(task *Task) { func (ts *TaskwarriorInterop) StartTask(task *Task) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed starting task:", err) slog.Error("Failed starting task", "error", err)
} }
} }
func (ts *TaskSquire) StopTask(task *Task) { func (ts *TaskwarriorInterop) StopTask(task *Task) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed stopping task:", err) slog.Error("Failed stopping task", "error", err)
} }
} }
func (ts *TaskSquire) GetInformation(task *Task) string { func (ts *TaskwarriorInterop) StopActiveTasks() {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting task information:", err) if ts.ctx.Err() == context.Canceled {
return
}
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", "error", err)
return
}
for _, task := range tasks {
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed stopping task", "error", err)
}
}
}
func (ts *TaskwarriorInterop) GetInformation(task *Task) string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return ""
}
slog.Error("Failed getting task information", "error", err)
return "" return ""
} }
return string(output) return string(output)
} }
func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) { func (ts *TaskwarriorInterop) AddTaskAnnotation(uuid string, annotation string) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed adding annotation:", err) slog.Error("Failed adding annotation", "error", err)
} }
} }
func (ts *TaskSquire) extractConfig() *TWConfig { func (ts *TaskwarriorInterop) extractConfig() *TWConfig {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting config:", err) if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting config", "error", err, "output", string(output))
return nil return nil
} }
return NewConfig(strings.Split(string(output), "\n")) return NewConfig(strings.Split(string(output), "\n"))
} }
func (ts *TaskSquire) extractReports() Reports { func (ts *TaskwarriorInterop) extractReports() Reports {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_config"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return nil return nil
@@ -572,12 +630,15 @@ func extractReports(config string) []string {
return reports return reports
} }
func (ts *TaskSquire) extractContexts() Contexts { func (ts *TaskwarriorInterop) extractContexts() Contexts {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_context"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting contexts:", err) if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting contexts", "error", err, "output", string(output))
return nil return nil
} }

View File

@@ -1,6 +1,7 @@
package taskwarrior package taskwarrior
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"testing" "testing"
@@ -56,7 +57,7 @@ func TestTaskSquire_GetContext(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tt.prep() tt.prep()
ts := NewTaskSquire(tt.fields.configLocation) ts := NewTaskSquire(context.Background(), tt.fields.configLocation)
if got := ts.GetActiveContext(); got.Name != tt.want { if got := ts.GetActiveContext(); got.Name != tt.want {
t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want) t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want)
} }

View File

@@ -0,0 +1,127 @@
package taskwarrior
import (
"log/slog"
)
// TaskNode represents a task in the tree structure
type TaskNode struct {
Task *Task
Children []*TaskNode
Parent *TaskNode
Depth int
}
// TaskTree manages the hierarchical task structure
type TaskTree struct {
Nodes map[string]*TaskNode // UUID -> TaskNode
Roots []*TaskNode // Top-level tasks (no parent)
FlatList []*TaskNode // Flattened tree in display order
}
// BuildTaskTree constructs a tree from a flat list of tasks
// Three-pass algorithm:
// 1. Create all nodes
// 2. Establish parent-child relationships
// 3. Calculate depths and flatten tree
func BuildTaskTree(tasks Tasks) *TaskTree {
tree := &TaskTree{
Nodes: make(map[string]*TaskNode),
Roots: make([]*TaskNode, 0),
FlatList: make([]*TaskNode, 0),
}
// Pass 1: Create all nodes
for _, task := range tasks {
node := &TaskNode{
Task: task,
Children: make([]*TaskNode, 0),
Parent: nil,
Depth: 0,
}
tree.Nodes[task.Uuid] = node
}
// Pass 2: Establish parent-child relationships
// Iterate over original tasks slice to preserve order
for _, task := range tasks {
node := tree.Nodes[task.Uuid]
parentUUID := getParentUUID(node.Task)
if parentUUID == "" {
// No parent, this is a root task
tree.Roots = append(tree.Roots, node)
} else {
// Find parent node
parentNode, exists := tree.Nodes[parentUUID]
if !exists {
// Orphaned task - missing parent
slog.Warn("Task has missing parent",
"task_uuid", node.Task.Uuid,
"parent_uuid", parentUUID,
"task_desc", node.Task.Description)
// Treat as root (graceful degradation)
tree.Roots = append(tree.Roots, node)
} else {
// Establish relationship
node.Parent = parentNode
parentNode.Children = append(parentNode.Children, node)
}
}
}
// Pass 3: Calculate depths and flatten tree
for _, root := range tree.Roots {
flattenNode(root, 0, &tree.FlatList)
}
return tree
}
// getParentUUID extracts the parent UUID from a task's UDAs
func getParentUUID(task *Task) string {
if task.Udas == nil {
return ""
}
parentVal, exists := task.Udas["parenttask"]
if !exists {
return ""
}
// Parent UDA is stored as a string
if parentStr, ok := parentVal.(string); ok {
return parentStr
}
return ""
}
// flattenNode recursively flattens the tree in depth-first order
func flattenNode(node *TaskNode, depth int, flatList *[]*TaskNode) {
node.Depth = depth
*flatList = append(*flatList, node)
// Recursively flatten children
for _, child := range node.Children {
flattenNode(child, depth+1, flatList)
}
}
// GetChildrenStatus returns completed/total counts for a parent task
func (tn *TaskNode) GetChildrenStatus() (completed int, total int) {
total = len(tn.Children)
completed = 0
for _, child := range tn.Children {
if child.Task.Status == "completed" {
completed++
}
}
return completed, total
}
// HasChildren returns true if the node has any children
func (tn *TaskNode) HasChildren() bool {
return len(tn.Children) > 0
}

View File

@@ -0,0 +1,345 @@
package taskwarrior
import (
"testing"
)
func TestBuildTaskTree_EmptyList(t *testing.T) {
tasks := Tasks{}
tree := BuildTaskTree(tasks)
if tree == nil {
t.Fatal("Expected tree to be non-nil")
}
if len(tree.Nodes) != 0 {
t.Errorf("Expected 0 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 0 {
t.Errorf("Expected 0 roots, got %d", len(tree.Roots))
}
if len(tree.FlatList) != 0 {
t.Errorf("Expected 0 items in flat list, got %d", len(tree.FlatList))
}
}
func TestBuildTaskTree_NoParents(t *testing.T) {
tasks := Tasks{
{Uuid: "task1", Description: "Task 1", Status: "pending"},
{Uuid: "task2", Description: "Task 2", Status: "pending"},
{Uuid: "task3", Description: "Task 3", Status: "completed"},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 3 {
t.Errorf("Expected 3 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 3 {
t.Errorf("Expected 3 roots (all tasks have no parent), got %d", len(tree.Roots))
}
if len(tree.FlatList) != 3 {
t.Errorf("Expected 3 items in flat list, got %d", len(tree.FlatList))
}
// All tasks should have depth 0
for i, node := range tree.FlatList {
if node.Depth != 0 {
t.Errorf("Task %d expected depth 0, got %d", i, node.Depth)
}
if node.HasChildren() {
t.Errorf("Task %d should not have children", i)
}
}
}
func TestBuildTaskTree_SimpleParentChild(t *testing.T) {
tasks := Tasks{
{Uuid: "parent1", Description: "Parent Task", Status: "pending"},
{
Uuid: "child1",
Description: "Child Task 1",
Status: "pending",
Udas: map[string]any{"parenttask": "parent1"},
},
{
Uuid: "child2",
Description: "Child Task 2",
Status: "completed",
Udas: map[string]any{"parenttask": "parent1"},
},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 3 {
t.Fatalf("Expected 3 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 1 {
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
}
// Check root is the parent
root := tree.Roots[0]
if root.Task.Uuid != "parent1" {
t.Errorf("Expected root to be parent1, got %s", root.Task.Uuid)
}
// Check parent has 2 children
if len(root.Children) != 2 {
t.Fatalf("Expected parent to have 2 children, got %d", len(root.Children))
}
// Check children status
completed, total := root.GetChildrenStatus()
if total != 2 {
t.Errorf("Expected total children = 2, got %d", total)
}
if completed != 1 {
t.Errorf("Expected completed children = 1, got %d", completed)
}
// Check flat list order (parent first, then children)
if len(tree.FlatList) != 3 {
t.Fatalf("Expected 3 items in flat list, got %d", len(tree.FlatList))
}
if tree.FlatList[0].Task.Uuid != "parent1" {
t.Errorf("Expected first item to be parent, got %s", tree.FlatList[0].Task.Uuid)
}
if tree.FlatList[0].Depth != 0 {
t.Errorf("Expected parent depth = 0, got %d", tree.FlatList[0].Depth)
}
// Children should be at depth 1
for i := 1; i < 3; i++ {
if tree.FlatList[i].Depth != 1 {
t.Errorf("Expected child %d depth = 1, got %d", i, tree.FlatList[i].Depth)
}
if tree.FlatList[i].Parent == nil {
t.Errorf("Child %d should have a parent", i)
} else if tree.FlatList[i].Parent.Task.Uuid != "parent1" {
t.Errorf("Child %d parent should be parent1, got %s", i, tree.FlatList[i].Parent.Task.Uuid)
}
}
}
func TestBuildTaskTree_MultiLevel(t *testing.T) {
tasks := Tasks{
{Uuid: "grandparent", Description: "Grandparent", Status: "pending"},
{
Uuid: "parent1",
Description: "Parent 1",
Status: "pending",
Udas: map[string]any{"parenttask": "grandparent"},
},
{
Uuid: "parent2",
Description: "Parent 2",
Status: "pending",
Udas: map[string]any{"parenttask": "grandparent"},
},
{
Uuid: "child1",
Description: "Child 1",
Status: "pending",
Udas: map[string]any{"parenttask": "parent1"},
},
{
Uuid: "grandchild1",
Description: "Grandchild 1",
Status: "completed",
Udas: map[string]any{"parenttask": "child1"},
},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 5 {
t.Fatalf("Expected 5 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 1 {
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
}
// Find nodes by UUID
grandparentNode := tree.Nodes["grandparent"]
parent1Node := tree.Nodes["parent1"]
child1Node := tree.Nodes["child1"]
grandchildNode := tree.Nodes["grandchild1"]
// Check depths
if grandparentNode.Depth != 0 {
t.Errorf("Expected grandparent depth = 0, got %d", grandparentNode.Depth)
}
if parent1Node.Depth != 1 {
t.Errorf("Expected parent1 depth = 1, got %d", parent1Node.Depth)
}
if child1Node.Depth != 2 {
t.Errorf("Expected child1 depth = 2, got %d", child1Node.Depth)
}
if grandchildNode.Depth != 3 {
t.Errorf("Expected grandchild depth = 3, got %d", grandchildNode.Depth)
}
// Check parent-child relationships
if len(grandparentNode.Children) != 2 {
t.Errorf("Expected grandparent to have 2 children, got %d", len(grandparentNode.Children))
}
if len(parent1Node.Children) != 1 {
t.Errorf("Expected parent1 to have 1 child, got %d", len(parent1Node.Children))
}
if len(child1Node.Children) != 1 {
t.Errorf("Expected child1 to have 1 child, got %d", len(child1Node.Children))
}
if grandchildNode.HasChildren() {
t.Error("Expected grandchild to have no children")
}
// Check flat list maintains tree order
if len(tree.FlatList) != 5 {
t.Fatalf("Expected 5 items in flat list, got %d", len(tree.FlatList))
}
// Grandparent should be first
if tree.FlatList[0].Task.Uuid != "grandparent" {
t.Errorf("Expected first item to be grandparent, got %s", tree.FlatList[0].Task.Uuid)
}
}
func TestBuildTaskTree_OrphanedTask(t *testing.T) {
tasks := Tasks{
{Uuid: "task1", Description: "Normal Task", Status: "pending"},
{
Uuid: "orphan",
Description: "Orphaned Task",
Status: "pending",
Udas: map[string]any{"parenttask": "nonexistent"},
},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 2 {
t.Fatalf("Expected 2 nodes, got %d", len(tree.Nodes))
}
// Orphaned task should be treated as root
if len(tree.Roots) != 2 {
t.Errorf("Expected 2 roots (orphan should be treated as root), got %d", len(tree.Roots))
}
// Both should have depth 0
for _, node := range tree.FlatList {
if node.Depth != 0 {
t.Errorf("Expected all tasks to have depth 0, got %d for %s", node.Depth, node.Task.Description)
}
}
}
func TestTaskNode_GetChildrenStatus(t *testing.T) {
tests := []struct {
name string
children []*TaskNode
wantComp int
wantTotal int
}{
{
name: "no children",
children: []*TaskNode{},
wantComp: 0,
wantTotal: 0,
},
{
name: "all pending",
children: []*TaskNode{
{Task: &Task{Status: "pending"}},
{Task: &Task{Status: "pending"}},
},
wantComp: 0,
wantTotal: 2,
},
{
name: "all completed",
children: []*TaskNode{
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "completed"}},
},
wantComp: 3,
wantTotal: 3,
},
{
name: "mixed status",
children: []*TaskNode{
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "pending"}},
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "pending"}},
{Task: &Task{Status: "completed"}},
},
wantComp: 3,
wantTotal: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := &TaskNode{
Task: &Task{},
Children: tt.children,
}
gotComp, gotTotal := node.GetChildrenStatus()
if gotComp != tt.wantComp {
t.Errorf("GetChildrenStatus() completed = %d, want %d", gotComp, tt.wantComp)
}
if gotTotal != tt.wantTotal {
t.Errorf("GetChildrenStatus() total = %d, want %d", gotTotal, tt.wantTotal)
}
})
}
}
func TestTaskNode_HasChildren(t *testing.T) {
tests := []struct {
name string
children []*TaskNode
want bool
}{
{
name: "no children",
children: []*TaskNode{},
want: false,
},
{
name: "has children",
children: []*TaskNode{{Task: &Task{}}},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := &TaskNode{
Task: &Task{},
Children: tt.children,
}
if got := node.HasChildren(); got != tt.want {
t.Errorf("HasChildren() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -20,6 +20,7 @@ type Interval struct {
Start string `json:"start,omitempty"` Start string `json:"start,omitempty"`
End string `json:"end,omitempty"` End string `json:"end,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
IsGap bool `json:"-"` // True if this represents an untracked time gap
} }
func NewInterval() *Interval { func NewInterval() *Interval {
@@ -28,7 +29,31 @@ func NewInterval() *Interval {
} }
} }
// NewGapInterval creates a new gap interval representing untracked time.
// start and end are the times between which the gap occurred.
func NewGapInterval(start, end time.Time) *Interval {
return &Interval{
ID: -1, // Gap intervals have no real ID
Start: start.UTC().Format(dtformat),
End: end.UTC().Format(dtformat),
Tags: make([]string, 0),
IsGap: true,
}
}
func (i *Interval) GetString(field string) string { 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 { switch field {
case "id": case "id":
return strconv.Itoa(i.ID) return strconv.Itoa(i.ID)
@@ -36,17 +61,38 @@ func (i *Interval) GetString(field string) string {
case "start": case "start":
return formatDate(i.Start, "formatted") return formatDate(i.Start, "formatted")
case "start_time":
return formatDate(i.Start, "time")
case "end": case "end":
if i.End == "" { if i.End == "" {
return "now" return "now"
} }
return formatDate(i.End, "formatted") 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": case "tags":
if len(i.Tags) == 0 { if len(i.Tags) == 0 {
return "" return ""
} }
return strings.Join(i.Tags, " ") // 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": case "duration":
return i.GetDuration() return i.GetDuration()
@@ -58,7 +104,7 @@ func (i *Interval) GetString(field string) string {
return "" return ""
default: default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field)) slog.Error("Field not implemented", "field", field)
return "" return ""
} }
} }
@@ -66,7 +112,7 @@ func (i *Interval) GetString(field string) string {
func (i *Interval) GetDuration() string { func (i *Interval) GetDuration() string {
start, err := time.Parse(dtformat, i.Start) start, err := time.Parse(dtformat, i.Start)
if err != nil { if err != nil {
slog.Error("Failed to parse start time:", err) slog.Error("Failed to parse start time", "error", err)
return "" return ""
} }
@@ -76,7 +122,7 @@ func (i *Interval) GetDuration() string {
} else { } else {
end, err = time.Parse(dtformat, i.End) end, err = time.Parse(dtformat, i.End)
if err != nil { if err != nil {
slog.Error("Failed to parse end time:", err) slog.Error("Failed to parse end time", "error", err)
return "" return ""
} }
} }
@@ -88,7 +134,7 @@ func (i *Interval) GetDuration() string {
func (i *Interval) GetStartTime() time.Time { func (i *Interval) GetStartTime() time.Time {
dt, err := time.Parse(dtformat, i.Start) dt, err := time.Parse(dtformat, i.Start)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return time.Time{} return time.Time{}
} }
return dt return dt
@@ -100,7 +146,7 @@ func (i *Interval) GetEndTime() time.Time {
} }
dt, err := time.Parse(dtformat, i.End) dt, err := time.Parse(dtformat, i.End)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return time.Time{} return time.Time{}
} }
return dt return dt
@@ -141,10 +187,10 @@ func formatDate(date string, format string) string {
dt, err := time.Parse(dtformat, date) dt, err := time.Parse(dtformat, date)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return "" return ""
} }
dt = dt.Local() dt = dt.Local()
switch format { switch format {
@@ -154,6 +200,8 @@ func formatDate(date string, format string) string {
return dt.Format("15:04") return dt.Format("15:04")
case "date": case "date":
return dt.Format("2006-01-02") return dt.Format("2006-01-02")
case "weekday":
return dt.Format("Mon")
case "iso": case "iso":
return dt.Format("2006-01-02T150405Z") return dt.Format("2006-01-02T150405Z")
case "epoch": case "epoch":
@@ -163,7 +211,7 @@ func formatDate(date string, format string) string {
case "relative": case "relative":
return parseDurationVague(time.Until(dt)) return parseDurationVague(time.Until(dt))
default: default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format)) slog.Error("Date format not implemented", "format", format)
return "" return ""
} }
} }
@@ -173,10 +221,7 @@ func formatDuration(d time.Duration) string {
minutes := int(d.Minutes()) % 60 minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60 seconds := int(d.Seconds()) % 60
if hours > 0 { return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
}
return fmt.Sprintf("%d:%02d", minutes, seconds)
} }
func parseDurationVague(d time.Duration) string { func parseDurationVague(d time.Duration) string {

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 ""
}

View File

@@ -0,0 +1,411 @@
// TODO: error handling
// TODO: split combinedOutput and handle stderr differently
package timewarrior
import (
"bytes"
"context"
"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 TimewarriorInterop struct {
configLocation string
defaultArgs []string
config *TWConfig
ctx context.Context
mutex sync.Mutex
}
func NewTimewarriorInterop(ctx context.Context, configLocation string) *TimewarriorInterop {
if _, err := exec.LookPath(twBinary); err != nil {
slog.Error("Timewarrior not found")
return nil
}
ts := &TimewarriorInterop{
configLocation: configLocation,
defaultArgs: []string{},
ctx: ctx,
mutex: sync.Mutex{},
}
ts.config = ts.extractConfig()
return ts
}
func (ts *TimewarriorInterop) GetConfig() *TWConfig {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.config
}
func (ts *TimewarriorInterop) GetTags() []string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting tags", "error", 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 *TimewarriorInterop) 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 *TimewarriorInterop) getIntervalsUnlocked(filter ...string) Intervals {
args := append(ts.defaultArgs, "export")
if filter != nil {
args = append(args, filter...)
}
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting intervals", "error", err)
return nil
}
intervals := make(Intervals, 0)
err = json.Unmarshal(output, &intervals)
if err != nil {
slog.Error("Failed unmarshalling intervals", "error", 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 *TimewarriorInterop) GetIntervals(filter ...string) Intervals {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.getIntervalsUnlocked(filter...)
}
func (ts *TimewarriorInterop) 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.CommandContext(ts.ctx, twBinary, args...)
if err := cmd.Run(); err != nil {
slog.Error("Failed starting tracking", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) StopTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "stop")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed stopping tracking", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) ContinueTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "continue")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed continuing tracking", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) ContinueInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed continuing interval", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) CancelTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "cancel")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed canceling tracking", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) DeleteInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed deleting interval", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) FillInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed filling interval", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) 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.CommandContext(ts.ctx, 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", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) 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", "error", 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.CommandContext(ts.ctx, twBinary, args...)
cmd.Stdin = bytes.NewBuffer(intervals)
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed modifying interval", "error", err, "output", string(out))
return err
}
return nil
}
func (ts *TimewarriorInterop) 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.CommandContext(ts.ctx, twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting summary", "error", err)
return ""
}
return string(output)
}
func (ts *TimewarriorInterop) GetActive() *Interval {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, 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 *TimewarriorInterop) Undo() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed undoing", "error", err)
}
}
func (ts *TimewarriorInterop) extractConfig() *TWConfig {
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"show"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting config", "error", 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
}

82
main.go
View File

@@ -1,82 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"tasksquire/common"
"tasksquire/pages"
"tasksquire/taskwarrior"
"tasksquire/timewarrior"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
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, tws)
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatalf("failed to open log file: %v", err)
}
defer file.Close()
// Create a new slog handler for the file
handler := slog.NewTextHandler(file, &slog.HandlerOptions{})
// Set the default logger to use the file handler
slog.SetDefault(slog.New(handler))
// form := huh.NewForm(
// huh.NewGroup(
// huh.NewSelect[string]().
// Options(huh.NewOptions(config.Reports...)...).
// Title("Report").
// Description("Choose the report to display").
// Value(&report),
// ),
// )
// err = form.Run()
// if err != nil {
// slog.Error("Uh oh:", err)
// os.Exit(1)
// }
m := pages.NewMainPage(common)
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}

40
opencode_sandbox.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# 1. Resolve the absolute path of opencode from your Nix environment
OPENCODE_PATH=$(command -v opencode)
if [ -z "$OPENCODE_PATH" ]; then
echo "❌ Error: 'opencode' not found in your PATH."
exit 1
fi
echo "🛡️ Engaging Bubblewrap Sandbox..."
echo "📍 Using binary: $OPENCODE_PATH"
# 2. Run bwrap using the absolute path
bwrap \
--ro-bind /bin /bin \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /nix /nix \
--ro-bind /home/pan/.nix-profile/bin /home/pan/.nix-profile/bin \
--ro-bind /home/pan/.config/opencode /home/pan/.config/opencode \
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--ro-bind /etc/hosts /etc/hosts \
--ro-bind-try /etc/ssl/certs /etc/ssl/certs \
--ro-bind-try /etc/static/ssl/certs /etc/static/ssl/certs \
--bind /home/pan/.local/share/opencode /home/pan/.local/share/opencode \
--proc /proc \
--dev-bind /dev /dev \
--tmpfs /tmp \
--unshare-all \
--share-net \
--die-with-parent \
--bind "$(pwd)" "$(pwd)" \
--chdir "$(pwd)" \
--setenv PATH "$PATH" \
--setenv HOME "$HOME" \
--setenv TASKRC "$TASKRC" \
--setenv TASKDATA "$TASKDATA" \
"$OPENCODE_PATH" "$@"

View File

@@ -1,144 +0,0 @@
package pages
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/lipgloss"
)
type ContextPickerPage struct {
common *common.Common
contexts taskwarrior.Contexts
picker *picker.Picker
}
func NewContextPickerPage(common *common.Common) *ContextPickerPage {
p := &ContextPickerPage{
common: common,
contexts: common.TW.GetContexts(),
}
selected := common.TW.GetActiveContext().Name
itemProvider := func() []list.Item {
contexts := common.TW.GetContexts()
options := make([]string, 0)
for _, c := range contexts {
if c.Name != "none" {
options = append(options, c.Name)
}
}
slices.Sort(options)
options = append([]string{"(none)"}, options...)
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.picker.Init()
}
type contextSelectedMsg struct {
item list.Item
}
func (p *ContextPickerPage) 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 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()
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 *ContextPickerPage) 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 UpdateContextMsg *taskwarrior.Context

View File

@@ -1,122 +0,0 @@
package pages
// import (
// "tasksquire/common"
// "github.com/charmbracelet/bubbles/textinput"
// tea "github.com/charmbracelet/bubbletea"
// "github.com/charmbracelet/lipgloss"
// datepicker "github.com/ethanefung/bubble-datepicker"
// )
// type Model struct {
// focus focus
// input textinput.Model
// datepicker datepicker.Model
// }
// var inputStyles = lipgloss.NewStyle().Padding(1, 1, 0)
// func initializeModel() tea.Model {
// dp := datepicker.New(time.Now())
// input := textinput.New()
// input.Placeholder = "YYYY-MM-DD (enter date)"
// input.Focus()
// input.Width = 20
// return Model{
// focus: FocusInput,
// input: input,
// datepicker: dp,
// }
// }
// func (m Model) Init() tea.Cmd {
// return textinput.Blink
// }
// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// var cmd tea.Cmd
// switch msg := msg.(type) {
// case tea.WindowSizeMsg:
// // TODO figure out how we want to size things
// // we'll probably want both bubbles to be vertically stacked
// // and to take as much room as the can
// return m, nil
// case tea.KeyMsg:
// switch msg.String() {
// case "ctrl+c", "q":
// return m, tea.Quit
// case "tab":
// if m.focus == FocusInput {
// m.focus = FocusDatePicker
// m.input.Blur()
// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly))
// m.datepicker.SelectDate()
// m.datepicker.SetFocus(datepicker.FocusHeaderMonth)
// m.datepicker = m.datepicker
// return m, nil
// }
// case "shift+tab":
// if m.focus == FocusDatePicker && m.datepicker.Focused == datepicker.FocusHeaderMonth {
// m.focus = FocusInput
// m.datepicker.Blur()
// m.input.Focus()
// return m, nil
// }
// }
// }
// switch m.focus {
// case FocusInput:
// m.input, cmd = m.UpdateInput(msg)
// case FocusDatePicker:
// m.datepicker, cmd = m.UpdateDatepicker(msg)
// case FocusNone:
// // do nothing
// }
// return m, cmd
// }
// func (m Model) View() string {
// return lipgloss.JoinVertical(lipgloss.Left, inputStyles.Render(m.input.View()), m.datepicker.View())
// }
// func (m *Model) UpdateInput(msg tea.Msg) (textinput.Model, tea.Cmd) {
// var cmd tea.Cmd
// m.input, cmd = m.input.Update(msg)
// val := m.input.Value()
// t, err := time.Parse(time.DateOnly, strings.TrimSpace(val))
// if err == nil {
// m.datepicker.SetTime(t)
// m.datepicker.SelectDate()
// m.datepicker.Blur()
// }
// if err != nil && m.datepicker.Selected {
// m.datepicker.UnselectDate()
// }
// return m.input, cmd
// }
// func (m *Model) UpdateDatepicker(msg tea.Msg) (datepicker.Model, tea.Cmd) {
// var cmd tea.Cmd
// prev := m.datepicker.Time
// m.datepicker, cmd = m.datepicker.Update(msg)
// if prev != m.datepicker.Time {
// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly))
// }
// return m.datepicker, cmd
// }

View File

@@ -1,64 +0,0 @@
package pages
import (
"tasksquire/common"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
type MainPage struct {
common *common.Common
activePage common.Component
taskPage common.Component
timePage common.Component
}
func NewMainPage(common *common.Common) *MainPage {
m := &MainPage{
common: common,
}
m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
m.timePage = NewTimePage(common)
m.activePage = m.taskPage
return m
}
func (m *MainPage) Init() tea.Cmd {
return tea.Batch(m.taskPage.Init(), m.timePage.Init())
}
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.common.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
if key.Matches(msg, m.common.Keymap.Next) {
if m.activePage == m.taskPage {
m.activePage = m.timePage
} else {
m.activePage = m.taskPage
}
// Re-size the new active page just in case
m.activePage.SetSize(m.common.Width(), m.common.Height())
// Trigger a refresh/init on switch? Maybe not needed if we keep state.
// But we might want to refresh data.
return m, m.activePage.Init()
}
}
activePage, cmd := m.activePage.Update(msg)
m.activePage = activePage.(common.Component)
return m, cmd
}
func (m *MainPage) View() string {
return m.activePage.View()
}

View File

@@ -1,84 +0,0 @@
package pages
import (
"tasksquire/taskwarrior"
"time"
tea "github.com/charmbracelet/bubbletea"
)
type UpdatedTasksMsg struct{}
type nextColumnMsg struct{}
func nextColumn() tea.Cmd {
return func() tea.Msg {
return nextColumnMsg{}
}
}
type prevColumnMsg struct{}
func prevColumn() tea.Cmd {
return func() tea.Msg {
return prevColumnMsg{}
}
}
type nextFieldMsg struct{}
func nextField() tea.Cmd {
return func() tea.Msg {
return nextFieldMsg{}
}
}
type prevFieldMsg struct{}
func prevField() tea.Cmd {
return func() tea.Msg {
return prevFieldMsg{}
}
}
type nextAreaMsg struct{}
func nextArea() tea.Cmd {
return func() tea.Msg {
return nextAreaMsg{}
}
}
type prevAreaMsg struct{}
func prevArea() tea.Cmd {
return func() tea.Msg {
return prevAreaMsg{}
}
}
type changeAreaMsg int
func changeArea(a int) tea.Cmd {
return func() tea.Msg {
return changeAreaMsg(a)
}
}
func changeMode(mode mode) tea.Cmd {
return func() tea.Msg {
return changeModeMsg(mode)
}
}
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)
})
}

View File

@@ -1,11 +0,0 @@
package pages
import (
tea "github.com/charmbracelet/bubbletea"
)
func BackCmd() tea.Msg {
return BackMsg{}
}
type BackMsg struct{}

View File

@@ -1,136 +0,0 @@
package pages
import (
"log/slog"
"tasksquire/common"
"tasksquire/components/picker"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type ProjectPickerPage struct {
common *common.Common
picker *picker.Picker
}
func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage {
p := &ProjectPickerPage{
common: common,
}
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
}
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return projectSelectedMsg{item: item} }
}
// 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.picker.Init()
}
type projectSelectedMsg struct {
item list.Item
}
func (p *ProjectPickerPage) 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 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()
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 *ProjectPickerPage) 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),
)
}
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
return nil
}
type UpdateProjectMsg string

View File

@@ -1,221 +0,0 @@
// TODO: update table every second (to show correct relative time)
package pages
import (
"tasksquire/common"
"tasksquire/components/table"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
// "github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
)
type ReportPage struct {
common *common.Common
activeReport *taskwarrior.Report
activeContext *taskwarrior.Context
activeProject string
selectedTask *taskwarrior.Task
taskCursor int
tasks taskwarrior.Tasks
taskTable table.Model
subpage common.Component
}
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
// return &ReportPage{
// common: com,
// activeReport: report,
// activeContext: com.TW.GetActiveContext(),
// activeProject: "",
// taskTable: table.New(com),
// }
p := &ReportPage{
common: com,
activeReport: report,
activeContext: com.TW.GetActiveContext(),
activeProject: "",
taskTable: table.New(com),
}
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
p.subpage.Init()
p.common.PushPage(p)
return p
}
func (p *ReportPage) SetSize(width int, height int) {
p.common.SetSize(width, height)
p.taskTable.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
p.taskTable.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
}
func (p *ReportPage) Init() tea.Cmd {
return tea.Batch(p.getTasks(), doTick())
}
func (p *ReportPage) 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 BackMsg:
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:
p.activeReport = msg
cmds = append(cmds, p.getTasks())
case UpdateContextMsg:
p.activeContext = msg
p.common.TW.SetContext(msg)
cmds = append(cmds, p.getTasks())
case UpdateProjectMsg:
p.activeProject = string(msg)
cmds = append(cmds, p.getTasks())
case UpdatedTasksMsg:
cmds = append(cmds, p.getTasks())
case tea.KeyMsg:
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)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.SetContext):
p.subpage = NewContextPickerPage(p.common)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Add):
p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Edit):
p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Ok):
p.common.TW.SetTaskDone(p.selectedTask)
return p, p.getTasks()
case key.Matches(msg, p.common.Keymap.Delete):
p.common.TW.DeleteTask(p.selectedTask)
return p, p.getTasks()
case key.Matches(msg, p.common.Keymap.SetProject):
p.subpage = NewProjectPickerPage(p.common, p.activeProject)
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")
if p.selectedTask.HasTag(tag) {
p.selectedTask.RemoveTag(tag)
} else {
p.selectedTask.AddTag(tag)
}
p.common.TW.ImportTask(p.selectedTask)
return p, p.getTasks()
}
return p, nil
case key.Matches(msg, p.common.Keymap.Undo):
p.common.TW.Undo()
return p, p.getTasks()
case key.Matches(msg, p.common.Keymap.StartStop):
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
if p.selectedTask.Start == "" {
p.common.TW.StartTask(p.selectedTask)
} else {
p.common.TW.StopTask(p.selectedTask)
}
return p, p.getTasks()
}
}
}
var cmd tea.Cmd
p.taskTable, cmd = p.taskTable.Update(msg)
cmds = append(cmds, cmd)
if p.tasks != nil && len(p.tasks) > 0 {
p.selectedTask = p.tasks[p.taskTable.Cursor()]
} else {
p.selectedTask = nil
}
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()
}
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
if len(tasks) == 0 {
return
}
selected := p.taskTable.Cursor()
if p.selectedTask != nil {
for i, task := range tasks {
if task.Uuid == p.selectedTask.Uuid {
selected = i
}
}
}
if selected > len(tasks)-1 {
selected = len(tasks) - 1
}
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.WithStyles(p.common.Styles.TableStyle),
)
if selected == 0 {
selected = p.taskTable.Cursor()
}
if selected < len(tasks) {
p.taskTable.SetCursor(selected)
} else {
p.taskTable.SetCursor(len(p.tasks) - 1)
}
}
func (p *ReportPage) getTasks() tea.Cmd {
return func() tea.Msg {
filters := []string{}
if p.activeProject != "" {
filters = append(filters, "project:"+p.activeProject)
}
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
return taskMsg(tasks)
}
}

View File

@@ -1,132 +0,0 @@
package pages
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/lipgloss"
)
type ReportPickerPage struct {
common *common.Common
reports taskwarrior.Reports
picker *picker.Picker
}
func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage {
p := &ReportPickerPage{
common: common,
reports: common.TW.GetReports(),
}
itemProvider := func() []list.Item {
options := make([]string, 0)
for _, r := range p.reports {
options = append(options, r.Name)
}
slices.Sort(options)
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.picker.Init()
}
type reportSelectedMsg struct {
item list.Item
}
func (p *ReportPickerPage) 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 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()
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 *ReportPickerPage) 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 UpdateReportMsg *taskwarrior.Report

File diff suppressed because it is too large Load Diff

View File

@@ -1,168 +0,0 @@
package pages
import (
"log/slog"
"strings"
"tasksquire/common"
"tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
type TimeEditorPage struct {
common *common.Common
interval *timewarrior.Interval
form *huh.Form
startStr string
endStr string
tagsStr string
}
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
p := &TimeEditorPage{
common: com,
interval: interval,
startStr: interval.Start,
endStr: interval.End,
tagsStr: formatTags(interval.Tags),
}
p.form = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Start").
Value(&p.startStr).
Validate(func(s string) error {
return timewarrior.ValidateDate(s)
}),
huh.NewInput().
Title("End").
Value(&p.endStr).
Validate(func(s string) error {
if s == "" {
return nil // End can be empty (active)
}
return timewarrior.ValidateDate(s)
}),
huh.NewInput().
Title("Tags").
Value(&p.tagsStr).
Description("Space separated, use \"\" for tags with spaces"),
),
).WithTheme(com.Styles.Form)
return p
}
func (p *TimeEditorPage) Init() tea.Cmd {
return p.form.Init()
}
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if key.Matches(msg, p.common.Keymap.Back) {
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
}
form, cmd := p.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
p.form = f
}
cmds = append(cmds, cmd)
if p.form.State == huh.StateCompleted {
p.saveInterval()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
// Return with a command to refresh the intervals
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
}
return p, tea.Batch(cmds...)
}
func (p *TimeEditorPage) View() string {
return p.form.View()
}
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.startStr
p.interval.End = p.endStr
// Parse tags
p.interval.Tags = parseTags(p.tagsStr)
err := p.common.TimeW.ModifyInterval(p.interval)
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, " ")
}

View File

@@ -1,185 +0,0 @@
package pages
import (
"time"
"tasksquire/common"
"tasksquire/components/timetable"
"tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
type TimePage struct {
common *common.Common
intervals timetable.Model
data timewarrior.Intervals
shouldSelectActive bool
}
func NewTimePage(com *common.Common) *TimePage {
p := &TimePage{
common: com,
}
p.populateTable(timewarrior.Intervals{})
return p
}
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 intervalsMsg:
p.data = timewarrior.Intervals(msg)
p.populateTable(p.data)
case RefreshIntervalsMsg:
cmds = append(cmds, p.getIntervals())
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.StartStop):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
if interval.IsActive() {
p.common.TimeW.StopTracking()
} else {
p.common.TimeW.ContinueInterval(interval.ID)
p.shouldSelectActive = true
}
return p, tea.Batch(p.getIntervals(), doTick())
}
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()
}
}
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 {
if len(p.data) == 0 {
return p.common.Styles.Base.Render("No intervals found for today")
}
return p.intervals.View()
}
func (p *TimePage) SetSize(width int, height int) {
p.common.SetSize(width, height)
p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
p.intervals.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
}
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
var selectedStart string
currentIdx := p.intervals.Cursor()
if row := p.intervals.SelectedRow(); row != nil {
selectedStart = row.Start
}
columns := []timetable.Column{
{Title: "ID", Name: "id", Width: 4},
{Title: "Start", Name: "start", Width: 16},
{Title: "End", Name: "end", Width: 16},
{Title: "Duration", Name: "duration", Width: 10},
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
}
p.intervals = timetable.New(
p.common,
timetable.WithColumns(columns),
timetable.WithIntervals(intervals),
timetable.WithFocused(true),
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
timetable.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()),
timetable.WithStyles(p.common.Styles.TableStyle),
)
if len(intervals) > 0 {
newIdx := -1
if p.shouldSelectActive {
for i, interval := range intervals {
if interval.IsActive() {
newIdx = i
break
}
}
p.shouldSelectActive = false
}
if newIdx == -1 && selectedStart != "" {
for i, interval := range intervals {
if interval.Start == selectedStart {
newIdx = i
break
}
}
}
if newIdx == -1 {
newIdx = currentIdx
}
if newIdx >= len(intervals) {
newIdx = len(intervals) - 1
}
if newIdx < 0 {
newIdx = 0
}
p.intervals.SetCursor(newIdx)
}
}
type intervalsMsg timewarrior.Intervals
func (p *TimePage) getIntervals() tea.Cmd {
return func() tea.Msg {
// ":day" is a timewarrior hint for "today"
intervals := p.common.TimeW.GetIntervals(":day")
return intervalsMsg(intervals)
}
}

Binary file not shown.

View File

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

View File

@@ -1,321 +0,0 @@
// 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
GetIntervals(filter ...string) Intervals
StartTracking(tags []string) error
StopTracking() error
ContinueTracking() error
ContinueInterval(id int) error
CancelTracking() error
DeleteInterval(id int) error
ModifyInterval(interval *Interval) 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
}
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
ts.mutex.Lock()
defer ts.mutex.Unlock()
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
}
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) ModifyInterval(interval *Interval) 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
}
// Import the modified interval
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import"}...)...)
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
intervals := ts.GetIntervals()
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
}