Add timestamp editor
This commit is contained in:
207
AGENTS.md
Normal file
207
AGENTS.md
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
# Agent Development Guide for TaskSquire
|
||||||
|
|
||||||
|
This guide is for AI coding agents working on TaskSquire, a Go-based TUI (Terminal User Interface) for Taskwarrior.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- **Language**: Go 1.22.2
|
||||||
|
- **Architecture**: Model-View-Update (MVU) pattern using Bubble Tea framework
|
||||||
|
- **Module**: `tasksquire`
|
||||||
|
- **Main Dependencies**: Bubble Tea, Lip Gloss, Huh, Bubbles (Charm ecosystem)
|
||||||
|
|
||||||
|
## Build, Test, and Lint Commands
|
||||||
|
|
||||||
|
### Building and Running
|
||||||
|
```bash
|
||||||
|
# Run directly
|
||||||
|
go run main.go
|
||||||
|
|
||||||
|
# Build binary
|
||||||
|
go build -o tasksquire main.go
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests for a specific package
|
||||||
|
go test ./taskwarrior
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
go test ./taskwarrior -run TestTaskSquire_GetContext
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
go test -v ./taskwarrior
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
go test -cover ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting and Formatting
|
||||||
|
```bash
|
||||||
|
# Format code (always run before committing)
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
# Lint with golangci-lint (available via nix-shell)
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
# Vet code for suspicious constructs
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# Tidy dependencies
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
```bash
|
||||||
|
# Enter Nix development shell (provides all tools)
|
||||||
|
nix develop
|
||||||
|
|
||||||
|
# Or use direnv (automatically loads .envrc)
|
||||||
|
direnv allow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tasksquire/
|
||||||
|
├── main.go # Entry point: initializes TaskSquire, TimeSquire, and Bubble Tea
|
||||||
|
├── common/ # Shared state, components interface, keymaps, styles, utilities
|
||||||
|
├── pages/ # UI pages/views (report, taskEditor, timePage, pickers, etc.)
|
||||||
|
├── components/ # Reusable UI components (input, table, timetable, picker)
|
||||||
|
├── taskwarrior/ # Taskwarrior CLI wrapper, models, config
|
||||||
|
├── timewarrior/ # Timewarrior integration, models, config
|
||||||
|
└── test/ # Test fixtures and data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
- **Standard Library First**: Group standard library imports, then third-party, then local
|
||||||
|
- **Local Import Pattern**: Use `tasksquire/<package>` for internal imports
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"tasksquire/common"
|
||||||
|
"tasksquire/taskwarrior"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Exported Types**: PascalCase (e.g., `TaskSquire`, `ReportPage`, `Common`)
|
||||||
|
- **Unexported Fields**: camelCase (e.g., `configLocation`, `activeReport`, `pageStack`)
|
||||||
|
- **Interfaces**: Follow Go convention, often ending in 'er' (e.g., `TaskWarrior`, `TimeWarrior`, `Component`)
|
||||||
|
- **Constants**: PascalCase or SCREAMING_SNAKE_CASE for exported constants
|
||||||
|
- **Test Functions**: `TestFunctionName` or `TestType_Method`
|
||||||
|
|
||||||
|
### Types and Interfaces
|
||||||
|
- **Interface-Based Design**: Use interfaces for main abstractions (see `TaskWarrior`, `TimeWarrior`, `Component`)
|
||||||
|
- **Struct Composition**: Embed common state (e.g., pages embed or reference `*common.Common`)
|
||||||
|
- **Pointer Receivers**: Use pointer receivers for methods that modify state or for consistency
|
||||||
|
- **Generic Types**: Use generics where appropriate (e.g., `Stack[T]` in `common/stack.go`)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- **Logging Over Panicking**: Use `log/slog` for structured logging, typically continue execution
|
||||||
|
- **Error Returns**: Return errors from functions, don't log and return
|
||||||
|
- **Context**: Errors are often logged with `slog.Error()` or `slog.Warn()` and execution continues
|
||||||
|
```go
|
||||||
|
// Typical pattern
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get tasks", "error", err)
|
||||||
|
return nil // or continue with default behavior
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concurrency and Thread Safety
|
||||||
|
- **Mutex Protection**: Use `sync.Mutex` to protect shared state (see `TaskSquire.mu`)
|
||||||
|
- **Lock Pattern**: Lock before operations, defer unlock
|
||||||
|
```go
|
||||||
|
ts.mu.Lock()
|
||||||
|
defer ts.mu.Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration and Environment
|
||||||
|
- **Environment Variables**: Respect `TASKRC` and `TIMEWARRIORDB`
|
||||||
|
- **Fallback Paths**: Check standard locations (`~/.taskrc`, `~/.config/task/taskrc`)
|
||||||
|
- **Config Parsing**: Parse Taskwarrior config format manually (see `taskwarrior/config.go`)
|
||||||
|
|
||||||
|
### MVU Pattern (Bubble Tea)
|
||||||
|
- **Components Implement**: `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() string`
|
||||||
|
- **Custom Messages**: Define custom message types for inter-component communication
|
||||||
|
- **Cmd Chaining**: Return commands from Init/Update to trigger async operations
|
||||||
|
```go
|
||||||
|
type MyMsg struct {
|
||||||
|
data string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case MyMsg:
|
||||||
|
// Handle custom message
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling with Lip Gloss
|
||||||
|
- **Centralized Styles**: Define styles in `common/styles.go`
|
||||||
|
- **Theme Colors**: Parse colors from Taskwarrior config
|
||||||
|
- **Reusable Styles**: Create style functions, not inline styles
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- **Table-Driven Tests**: Use struct slices for test cases
|
||||||
|
- **Test Setup**: Create helper functions like `TaskWarriorTestSetup()`
|
||||||
|
- **Temp Directories**: Use `t.TempDir()` for isolated test environments
|
||||||
|
- **Prep Functions**: Include `prep func()` in test cases for setup
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **TODO Comments**: Mark future improvements with `// TODO: description`
|
||||||
|
- **Package Comments**: Document package purpose at the top of main files
|
||||||
|
- **Exported Functions**: Document exported functions, types, and methods
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Page Navigation
|
||||||
|
- Pages pushed onto stack via `common.PushPage()`
|
||||||
|
- Pop pages with `common.PopPage()`
|
||||||
|
- Check for subpages with `common.HasSubpages()`
|
||||||
|
|
||||||
|
### Task Operations
|
||||||
|
```go
|
||||||
|
// Get tasks for a report
|
||||||
|
tasks := ts.GetTasks(report, "filter", "args")
|
||||||
|
|
||||||
|
// Import/create task
|
||||||
|
ts.ImportTask(&task)
|
||||||
|
|
||||||
|
// Mark task done
|
||||||
|
ts.SetTaskDone(&task)
|
||||||
|
|
||||||
|
// Start/stop task
|
||||||
|
ts.StartTask(&task)
|
||||||
|
ts.StopTask(&task)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Handling
|
||||||
|
- Custom Marshal/Unmarshal for Task struct to handle UDAs (User Defined Attributes)
|
||||||
|
- Use `json.RawMessage` for flexible field handling
|
||||||
|
|
||||||
|
## Key Files to Reference
|
||||||
|
|
||||||
|
- `common/component.go` - Component interface definition
|
||||||
|
- `common/common.go` - Shared state container
|
||||||
|
- `taskwarrior/taskwarrior.go` - TaskWarrior interface and implementation
|
||||||
|
- `pages/main.go` - Main page router pattern
|
||||||
|
- `taskwarrior/models.go` - Data model examples
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- **Logging**: Application logs to `app.log` in current directory
|
||||||
|
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
|
||||||
|
- **Color Parsing**: Custom color parsing from Taskwarrior config format
|
||||||
|
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ type Item struct {
|
|||||||
text string
|
text string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewItem(text string) Item { return Item{text: text} }
|
func NewItem(text string) Item { return Item{text: text} }
|
||||||
func (i Item) Title() string { return i.text }
|
func (i Item) Title() string { return i.text }
|
||||||
func (i Item) Description() string { return "" }
|
func (i Item) Description() string { return "" }
|
||||||
func (i Item) FilterValue() string { return i.text }
|
func (i Item) FilterValue() string { return i.text }
|
||||||
|
|||||||
409
components/timestampeditor/timestampeditor.go
Normal file
409
components/timestampeditor/timestampeditor.go
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
package timestampeditor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"tasksquire/common"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
timeFormat = "20060102T150405Z" // Timewarrior format
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field represents which field is currently focused
|
||||||
|
type Field int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TimeField Field = iota
|
||||||
|
DateField
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimestampEditor is a component for editing timestamps with separate time and date fields
|
||||||
|
type TimestampEditor struct {
|
||||||
|
common *common.Common
|
||||||
|
|
||||||
|
// Current timestamp value
|
||||||
|
timestamp time.Time
|
||||||
|
isEmpty bool // Track if timestamp is unset
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
focused bool
|
||||||
|
currentField Field
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
|
||||||
|
// Title and description
|
||||||
|
title string
|
||||||
|
description string
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
validate func(time.Time) error
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new TimestampEditor with no initial timestamp
|
||||||
|
func New(com *common.Common) *TimestampEditor {
|
||||||
|
return &TimestampEditor{
|
||||||
|
common: com,
|
||||||
|
timestamp: time.Time{}, // Zero time
|
||||||
|
isEmpty: true, // Start empty
|
||||||
|
focused: false,
|
||||||
|
currentField: TimeField,
|
||||||
|
validate: func(time.Time) error { return nil },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title sets the title of the timestamp editor
|
||||||
|
func (t *TimestampEditor) Title(title string) *TimestampEditor {
|
||||||
|
t.title = title
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description sets the description of the timestamp editor
|
||||||
|
func (t *TimestampEditor) Description(description string) *TimestampEditor {
|
||||||
|
t.description = description
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value sets the initial timestamp value
|
||||||
|
func (t *TimestampEditor) Value(timestamp time.Time) *TimestampEditor {
|
||||||
|
t.timestamp = timestamp
|
||||||
|
t.isEmpty = timestamp.IsZero()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValueFromString sets the initial timestamp from a timewarrior format string
|
||||||
|
func (t *TimestampEditor) ValueFromString(s string) *TimestampEditor {
|
||||||
|
if s == "" {
|
||||||
|
t.timestamp = time.Time{}
|
||||||
|
t.isEmpty = true
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.Parse(timeFormat, s)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse timestamp", "error", err)
|
||||||
|
t.timestamp = time.Time{}
|
||||||
|
t.isEmpty = true
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
t.timestamp = parsed.Local()
|
||||||
|
t.isEmpty = false
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue returns the current timestamp
|
||||||
|
func (t *TimestampEditor) GetValue() time.Time {
|
||||||
|
return t.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValueString returns the timestamp in timewarrior format, or empty string if unset
|
||||||
|
func (t *TimestampEditor) GetValueString() string {
|
||||||
|
if t.isEmpty {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.timestamp.UTC().Format(timeFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sets the validation function
|
||||||
|
func (t *TimestampEditor) Validate(validate func(time.Time) error) *TimestampEditor {
|
||||||
|
t.validate = validate
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the validation error
|
||||||
|
func (t *TimestampEditor) Error() error {
|
||||||
|
return t.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus focuses the timestamp editor
|
||||||
|
func (t *TimestampEditor) Focus() tea.Cmd {
|
||||||
|
t.focused = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur blurs the timestamp editor
|
||||||
|
func (t *TimestampEditor) Blur() tea.Cmd {
|
||||||
|
t.focused = false
|
||||||
|
t.err = t.validate(t.timestamp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSize sets the size of the timestamp editor
|
||||||
|
func (t *TimestampEditor) SetSize(width, height int) {
|
||||||
|
t.width = width
|
||||||
|
t.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the timestamp editor
|
||||||
|
func (t *TimestampEditor) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the timestamp editor
|
||||||
|
func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !t.focused {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
t.err = nil
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
// Navigation between fields
|
||||||
|
case "h", "left":
|
||||||
|
t.currentField = TimeField
|
||||||
|
case "l", "right":
|
||||||
|
t.currentField = DateField
|
||||||
|
|
||||||
|
// Time field adjustments (lowercase - 5 minutes)
|
||||||
|
case "j":
|
||||||
|
// Set current time on first edit if empty
|
||||||
|
if t.isEmpty {
|
||||||
|
t.setCurrentTime()
|
||||||
|
}
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
t.adjustTime(-5)
|
||||||
|
} else {
|
||||||
|
t.adjustDate(-1)
|
||||||
|
}
|
||||||
|
case "k":
|
||||||
|
// Set current time on first edit if empty
|
||||||
|
if t.isEmpty {
|
||||||
|
t.setCurrentTime()
|
||||||
|
}
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
t.adjustTime(5)
|
||||||
|
} else {
|
||||||
|
t.adjustDate(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time field adjustments (uppercase - 30 minutes) or date adjustments (week)
|
||||||
|
case "J":
|
||||||
|
// Set current time on first edit if empty
|
||||||
|
if t.isEmpty {
|
||||||
|
t.setCurrentTime()
|
||||||
|
}
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
t.adjustTime(-30)
|
||||||
|
} else {
|
||||||
|
t.adjustDate(-7)
|
||||||
|
}
|
||||||
|
case "K":
|
||||||
|
// Set current time on first edit if empty
|
||||||
|
if t.isEmpty {
|
||||||
|
t.setCurrentTime()
|
||||||
|
}
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
t.adjustTime(30)
|
||||||
|
} else {
|
||||||
|
t.adjustDate(7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove timestamp
|
||||||
|
case "d":
|
||||||
|
t.timestamp = time.Time{}
|
||||||
|
t.isEmpty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCurrentTime sets the timestamp to the current time and marks it as not empty
|
||||||
|
func (t *TimestampEditor) setCurrentTime() {
|
||||||
|
now := time.Now()
|
||||||
|
// Snap to nearest 5 minutes
|
||||||
|
minute := now.Minute()
|
||||||
|
remainder := minute % 5
|
||||||
|
if remainder != 0 {
|
||||||
|
if remainder < 3 {
|
||||||
|
// Round down
|
||||||
|
now = now.Add(-time.Duration(remainder) * time.Minute)
|
||||||
|
} else {
|
||||||
|
// Round up
|
||||||
|
now = now.Add(time.Duration(5-remainder) * time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Zero out seconds and nanoseconds
|
||||||
|
t.timestamp = time.Date(
|
||||||
|
now.Year(),
|
||||||
|
now.Month(),
|
||||||
|
now.Day(),
|
||||||
|
now.Hour(),
|
||||||
|
now.Minute(),
|
||||||
|
0, 0,
|
||||||
|
now.Location(),
|
||||||
|
)
|
||||||
|
t.isEmpty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjustTime adjusts the time by the given number of minutes and snaps to nearest 5 minutes
|
||||||
|
func (t *TimestampEditor) adjustTime(minutes int) {
|
||||||
|
// Add the minutes
|
||||||
|
t.timestamp = t.timestamp.Add(time.Duration(minutes) * time.Minute)
|
||||||
|
|
||||||
|
// Snap to nearest 5 minutes
|
||||||
|
minute := t.timestamp.Minute()
|
||||||
|
remainder := minute % 5
|
||||||
|
if remainder != 0 {
|
||||||
|
if remainder < 3 {
|
||||||
|
// Round down
|
||||||
|
t.timestamp = t.timestamp.Add(-time.Duration(remainder) * time.Minute)
|
||||||
|
} else {
|
||||||
|
// Round up
|
||||||
|
t.timestamp = t.timestamp.Add(time.Duration(5-remainder) * time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero out seconds and nanoseconds
|
||||||
|
t.timestamp = time.Date(
|
||||||
|
t.timestamp.Year(),
|
||||||
|
t.timestamp.Month(),
|
||||||
|
t.timestamp.Day(),
|
||||||
|
t.timestamp.Hour(),
|
||||||
|
t.timestamp.Minute(),
|
||||||
|
0, 0,
|
||||||
|
t.timestamp.Location(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjustDate adjusts the date by the given number of days
|
||||||
|
func (t *TimestampEditor) adjustDate(days int) {
|
||||||
|
t.timestamp = t.timestamp.AddDate(0, 0, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the timestamp editor
|
||||||
|
func (t *TimestampEditor) View() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
styles := t.getStyles()
|
||||||
|
|
||||||
|
// Render title if present
|
||||||
|
if t.title != "" {
|
||||||
|
sb.WriteString(styles.title.Render(t.title))
|
||||||
|
if t.err != nil {
|
||||||
|
sb.WriteString(styles.errorIndicator.String())
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render description if present
|
||||||
|
if t.description != "" {
|
||||||
|
sb.WriteString(styles.description.Render(t.description))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the time and date fields side by side
|
||||||
|
var timeStr, dateStr string
|
||||||
|
if t.isEmpty {
|
||||||
|
timeStr = "--:--"
|
||||||
|
dateStr = "--- ----------"
|
||||||
|
} else {
|
||||||
|
timeStr = t.timestamp.Format("15:04")
|
||||||
|
dateStr = t.timestamp.Format("Mon 2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeField, dateField string
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
timeField = styles.selectedField.Render(timeStr)
|
||||||
|
dateField = styles.unselectedField.Render(dateStr)
|
||||||
|
} else {
|
||||||
|
timeField = styles.unselectedField.Render(timeStr)
|
||||||
|
dateField = styles.selectedField.Render(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeField, " ", dateField)
|
||||||
|
sb.WriteString(fieldsRow)
|
||||||
|
|
||||||
|
return styles.base.Render(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHelpText returns the help text based on the current field
|
||||||
|
func (t *TimestampEditor) getHelpText() string {
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
return "h/l: switch field • j/k: ±5min • J/K: ±30min • d: remove"
|
||||||
|
}
|
||||||
|
return "h/l: switch field • j/k: ±1day • J/K: ±1week • d: remove"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles for the timestamp editor
|
||||||
|
type timestampEditorStyles struct {
|
||||||
|
base lipgloss.Style
|
||||||
|
title lipgloss.Style
|
||||||
|
description lipgloss.Style
|
||||||
|
errorIndicator lipgloss.Style
|
||||||
|
selectedField lipgloss.Style
|
||||||
|
unselectedField lipgloss.Style
|
||||||
|
help lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStyles returns the styles for the timestamp editor
|
||||||
|
func (t *TimestampEditor) getStyles() timestampEditorStyles {
|
||||||
|
theme := t.common.Styles.Form
|
||||||
|
var styles timestampEditorStyles
|
||||||
|
|
||||||
|
if t.focused {
|
||||||
|
styles.base = lipgloss.NewStyle()
|
||||||
|
styles.title = theme.Focused.Title
|
||||||
|
styles.description = theme.Focused.Description
|
||||||
|
styles.errorIndicator = theme.Focused.ErrorIndicator
|
||||||
|
styles.selectedField = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Padding(0, 2).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("12"))
|
||||||
|
styles.unselectedField = lipgloss.NewStyle().
|
||||||
|
Padding(0, 2).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("8"))
|
||||||
|
styles.help = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("8")).
|
||||||
|
Italic(true)
|
||||||
|
} else {
|
||||||
|
styles.base = lipgloss.NewStyle()
|
||||||
|
styles.title = theme.Blurred.Title
|
||||||
|
styles.description = theme.Blurred.Description
|
||||||
|
styles.errorIndicator = theme.Blurred.ErrorIndicator
|
||||||
|
styles.selectedField = lipgloss.NewStyle().
|
||||||
|
Padding(0, 2).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("8"))
|
||||||
|
styles.unselectedField = lipgloss.NewStyle().
|
||||||
|
Padding(0, 2).
|
||||||
|
Border(lipgloss.HiddenBorder())
|
||||||
|
styles.help = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip returns whether the timestamp editor should be skipped
|
||||||
|
func (t *TimestampEditor) Skip() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom returns whether the timestamp editor should be zoomed
|
||||||
|
func (t *TimestampEditor) Zoom() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyBinds returns the key bindings for the timestamp editor
|
||||||
|
func (t *TimestampEditor) KeyBinds() []key.Binding {
|
||||||
|
return []key.Binding{
|
||||||
|
t.common.Keymap.Left,
|
||||||
|
t.common.Keymap.Right,
|
||||||
|
t.common.Keymap.Up,
|
||||||
|
t.common.Keymap.Down,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,7 +39,8 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.common.SetSize(msg.Width, msg.Height)
|
m.common.SetSize(msg.Width, msg.Height)
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if key.Matches(msg, m.common.Keymap.Next) {
|
// 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 {
|
if m.activePage == m.taskPage {
|
||||||
m.activePage = m.timePage
|
m.activePage = m.timePage
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -45,10 +45,6 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
|||||||
taskTable: table.New(com),
|
taskTable: table.New(com),
|
||||||
}
|
}
|
||||||
|
|
||||||
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
|
|
||||||
p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tasksquire/components/input"
|
"tasksquire/components/input"
|
||||||
|
"tasksquire/components/timestampeditor"
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
@ -676,45 +677,56 @@ func (t tagEdit) View() string {
|
|||||||
|
|
||||||
type timeEdit struct {
|
type timeEdit struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
fields []huh.Field
|
fields []*timestampeditor.TimestampEditor
|
||||||
|
|
||||||
cursor int
|
cursor int
|
||||||
|
|
||||||
|
// Store task field pointers to update them
|
||||||
|
due *string
|
||||||
|
scheduled *string
|
||||||
|
wait *string
|
||||||
|
until *string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit {
|
func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit {
|
||||||
// defaultKeymap := huh.NewDefaultKeyMap()
|
// Create timestamp editors for each date field
|
||||||
|
dueEditor := timestampeditor.New(common).
|
||||||
|
Title("Due").
|
||||||
|
Description("When the task is due").
|
||||||
|
ValueFromString(*due)
|
||||||
|
|
||||||
|
scheduledEditor := timestampeditor.New(common).
|
||||||
|
Title("Scheduled").
|
||||||
|
Description("When to start working on the task").
|
||||||
|
ValueFromString(*scheduled)
|
||||||
|
|
||||||
|
waitEditor := timestampeditor.New(common).
|
||||||
|
Title("Wait").
|
||||||
|
Description("Hide task until this date").
|
||||||
|
ValueFromString(*wait)
|
||||||
|
|
||||||
|
untilEditor := timestampeditor.New(common).
|
||||||
|
Title("Until").
|
||||||
|
Description("Task expires after this date").
|
||||||
|
ValueFromString(*until)
|
||||||
|
|
||||||
t := timeEdit{
|
t := timeEdit{
|
||||||
common: common,
|
common: common,
|
||||||
fields: []huh.Field{
|
fields: []*timestampeditor.TimestampEditor{
|
||||||
huh.NewInput().
|
dueEditor,
|
||||||
Title("Due").
|
scheduledEditor,
|
||||||
Value(due).
|
waitEditor,
|
||||||
Validate(taskwarrior.ValidateDate).
|
untilEditor,
|
||||||
Inline(true).
|
|
||||||
Prompt(": ").
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Scheduled").
|
|
||||||
Value(scheduled).
|
|
||||||
Validate(taskwarrior.ValidateDate).
|
|
||||||
Inline(true).
|
|
||||||
Prompt(": ").
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Wait").
|
|
||||||
Value(wait).
|
|
||||||
Validate(taskwarrior.ValidateDate).
|
|
||||||
Inline(true).
|
|
||||||
Prompt(": ").
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Until").
|
|
||||||
Value(until).
|
|
||||||
Validate(taskwarrior.ValidateDate).
|
|
||||||
Inline(true).
|
|
||||||
Prompt(": ").
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
},
|
},
|
||||||
|
due: due,
|
||||||
|
scheduled: scheduled,
|
||||||
|
wait: wait,
|
||||||
|
until: until,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the first field
|
||||||
|
if len(t.fields) > 0 {
|
||||||
|
t.fields[0].Focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &t
|
return &t
|
||||||
@ -725,12 +737,21 @@ func (t *timeEdit) GetName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *timeEdit) SetCursor(c int) {
|
func (t *timeEdit) SetCursor(c int) {
|
||||||
|
if len(t.fields) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur the current field
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
|
|
||||||
|
// Set new cursor position
|
||||||
if c < 0 {
|
if c < 0 {
|
||||||
t.cursor = len(t.fields) - 1
|
t.cursor = len(t.fields) - 1
|
||||||
} else {
|
} else {
|
||||||
t.cursor = c
|
t.cursor = c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Focus the new field
|
||||||
t.fields[t.cursor].Focus()
|
t.fields[t.cursor].Focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -739,42 +760,71 @@ func (t *timeEdit) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case nextFieldMsg:
|
case nextFieldMsg:
|
||||||
if t.cursor == len(t.fields)-1 {
|
if t.cursor == len(t.fields)-1 {
|
||||||
|
// Update task field before moving to next area
|
||||||
|
t.syncToTaskFields()
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
return t, nextArea()
|
return t, nextArea()
|
||||||
}
|
}
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
t.cursor++
|
t.cursor++
|
||||||
t.fields[t.cursor].Focus()
|
t.fields[t.cursor].Focus()
|
||||||
|
return t, nil
|
||||||
case prevFieldMsg:
|
case prevFieldMsg:
|
||||||
if t.cursor == 0 {
|
if t.cursor == 0 {
|
||||||
|
// Update task field before moving to previous area
|
||||||
|
t.syncToTaskFields()
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
return t, prevArea()
|
return t, prevArea()
|
||||||
}
|
}
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
t.cursor--
|
t.cursor--
|
||||||
t.fields[t.cursor].Focus()
|
t.fields[t.cursor].Focus()
|
||||||
|
return t, nil
|
||||||
default:
|
default:
|
||||||
field, cmd := t.fields[t.cursor].Update(msg)
|
// Update the current timestamp editor
|
||||||
t.fields[t.cursor] = field.(huh.Field)
|
model, cmd := t.fields[t.cursor].Update(msg)
|
||||||
|
t.fields[t.cursor] = model.(*timestampeditor.TimestampEditor)
|
||||||
return t, cmd
|
return t, cmd
|
||||||
}
|
}
|
||||||
return t, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *timeEdit) View() string {
|
func (t *timeEdit) View() string {
|
||||||
views := make([]string, len(t.fields))
|
views := make([]string, len(t.fields))
|
||||||
|
|
||||||
for i, field := range t.fields {
|
for i, field := range t.fields {
|
||||||
views[i] = field.View()
|
views[i] = field.View()
|
||||||
|
if i < len(t.fields)-1 {
|
||||||
|
views[i] += "\n"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lipgloss.JoinVertical(
|
return lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
views...,
|
views...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncToTaskFields converts the timestamp editor values back to task field strings
|
||||||
|
func (t *timeEdit) syncToTaskFields() {
|
||||||
|
// Update the task fields with values from the timestamp editors
|
||||||
|
// GetValueString() returns empty string for unset timestamps
|
||||||
|
if len(t.fields) > 0 {
|
||||||
|
*t.due = t.fields[0].GetValueString()
|
||||||
|
}
|
||||||
|
if len(t.fields) > 1 {
|
||||||
|
*t.scheduled = t.fields[1].GetValueString()
|
||||||
|
}
|
||||||
|
if len(t.fields) > 2 {
|
||||||
|
*t.wait = t.fields[2].GetValueString()
|
||||||
|
}
|
||||||
|
if len(t.fields) > 3 {
|
||||||
|
*t.until = t.fields[3].GetValueString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type detailsEdit struct {
|
type detailsEdit struct {
|
||||||
com *common.Common
|
com *common.Common
|
||||||
vp viewport.Model
|
vp viewport.Model
|
||||||
@ -1009,6 +1059,9 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync timestamp fields from the timeEdit area (area 2)
|
||||||
|
p.areas[2].(*timeEdit).syncToTaskFields()
|
||||||
|
|
||||||
if *(p.areas[0].(*taskEdit).newAnnotation) != "" {
|
if *(p.areas[0].(*taskEdit).newAnnotation) != "" {
|
||||||
p.task.Annotations = append(p.task.Annotations, taskwarrior.Annotation{
|
p.task.Annotations = append(p.task.Annotations, taskwarrior.Annotation{
|
||||||
Entry: time.Now().Format("20060102T150405Z"),
|
Entry: time.Now().Format("20060102T150405Z"),
|
||||||
|
|||||||
@ -4,61 +4,65 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
|
"tasksquire/components/timestampeditor"
|
||||||
"tasksquire/timewarrior"
|
"tasksquire/timewarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TimeEditorPage struct {
|
type TimeEditorPage struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
interval *timewarrior.Interval
|
interval *timewarrior.Interval
|
||||||
form *huh.Form
|
|
||||||
|
|
||||||
startStr string
|
// Fields
|
||||||
endStr string
|
startEditor *timestampeditor.TimestampEditor
|
||||||
tagsStr string
|
endEditor *timestampeditor.TimestampEditor
|
||||||
|
tagsInput textinput.Model
|
||||||
|
adjust bool
|
||||||
|
|
||||||
|
// State
|
||||||
|
currentField int
|
||||||
|
totalFields int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
|
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
|
||||||
p := &TimeEditorPage{
|
// Create start timestamp editor
|
||||||
common: com,
|
startEditor := timestampeditor.New(com).
|
||||||
interval: interval,
|
Title("Start").
|
||||||
startStr: interval.Start,
|
ValueFromString(interval.Start)
|
||||||
endStr: interval.End,
|
|
||||||
tagsStr: formatTags(interval.Tags),
|
|
||||||
}
|
|
||||||
|
|
||||||
p.form = huh.NewForm(
|
// Create end timestamp editor
|
||||||
huh.NewGroup(
|
endEditor := timestampeditor.New(com).
|
||||||
huh.NewInput().
|
Title("End").
|
||||||
Title("Start").
|
ValueFromString(interval.End)
|
||||||
Value(&p.startStr).
|
|
||||||
Validate(func(s string) error {
|
// Create tags input
|
||||||
return timewarrior.ValidateDate(s)
|
tagsInput := textinput.New()
|
||||||
}),
|
tagsInput.Placeholder = "Space separated, use \"\" for tags with spaces"
|
||||||
huh.NewInput().
|
tagsInput.SetValue(formatTags(interval.Tags))
|
||||||
Title("End").
|
tagsInput.Width = 50
|
||||||
Value(&p.endStr).
|
|
||||||
Validate(func(s string) error {
|
p := &TimeEditorPage{
|
||||||
if s == "" {
|
common: com,
|
||||||
return nil // End can be empty (active)
|
interval: interval,
|
||||||
}
|
startEditor: startEditor,
|
||||||
return timewarrior.ValidateDate(s)
|
endEditor: endEditor,
|
||||||
}),
|
tagsInput: tagsInput,
|
||||||
huh.NewInput().
|
adjust: true, // Enable :adjust by default
|
||||||
Title("Tags").
|
currentField: 0,
|
||||||
Value(&p.tagsStr).
|
totalFields: 4, // Updated to include adjust field
|
||||||
Description("Space separated, use \"\" for tags with spaces"),
|
}
|
||||||
),
|
|
||||||
).WithTheme(com.Styles.Form)
|
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimeEditorPage) Init() tea.Cmd {
|
func (p *TimeEditorPage) Init() tea.Cmd {
|
||||||
return p.form.Init()
|
// Focus the first field
|
||||||
|
p.currentField = 0
|
||||||
|
return p.startEditor.Focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@ -66,41 +70,165 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if key.Matches(msg, p.common.Keymap.Back) {
|
switch {
|
||||||
|
case key.Matches(msg, p.common.Keymap.Back):
|
||||||
model, err := p.common.PopPage()
|
model, err := p.common.PopPage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("page stack empty")
|
slog.Error("page stack empty")
|
||||||
return nil, tea.Quit
|
return nil, tea.Quit
|
||||||
}
|
}
|
||||||
return model, BackCmd
|
return model, BackCmd
|
||||||
|
|
||||||
|
case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
|
// Save and exit
|
||||||
|
p.saveInterval()
|
||||||
|
model, err := p.common.PopPage()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("page stack empty")
|
||||||
|
return nil, tea.Quit
|
||||||
|
}
|
||||||
|
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
|
||||||
|
|
||||||
|
case key.Matches(msg, p.common.Keymap.Next):
|
||||||
|
// Move to next field
|
||||||
|
p.blurCurrentField()
|
||||||
|
p.currentField = (p.currentField + 1) % p.totalFields
|
||||||
|
cmds = append(cmds, p.focusCurrentField())
|
||||||
|
|
||||||
|
case key.Matches(msg, p.common.Keymap.Prev):
|
||||||
|
// Move to previous field
|
||||||
|
p.blurCurrentField()
|
||||||
|
p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields
|
||||||
|
cmds = append(cmds, p.focusCurrentField())
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
p.SetSize(msg.Width, msg.Height)
|
p.SetSize(msg.Width, msg.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
form, cmd := p.form.Update(msg)
|
// Update the currently focused field
|
||||||
if f, ok := form.(*huh.Form); ok {
|
var cmd tea.Cmd
|
||||||
p.form = f
|
switch p.currentField {
|
||||||
|
case 0:
|
||||||
|
var model tea.Model
|
||||||
|
model, cmd = p.startEditor.Update(msg)
|
||||||
|
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||||
|
p.startEditor = editor
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
var model tea.Model
|
||||||
|
model, cmd = p.endEditor.Update(msg)
|
||||||
|
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||||
|
p.endEditor = editor
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
p.tagsInput, cmd = p.tagsInput.Update(msg)
|
||||||
|
case 3:
|
||||||
|
// Handle adjust toggle with space/enter
|
||||||
|
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if msg.String() == " " || msg.String() == "enter" {
|
||||||
|
p.adjust = !p.adjust
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cmds = append(cmds, cmd)
|
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...)
|
return p, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
||||||
|
switch p.currentField {
|
||||||
|
case 0:
|
||||||
|
return p.startEditor.Focus()
|
||||||
|
case 1:
|
||||||
|
return p.endEditor.Focus()
|
||||||
|
case 2:
|
||||||
|
p.tagsInput.Focus()
|
||||||
|
return nil
|
||||||
|
case 3:
|
||||||
|
// Adjust checkbox doesn't need focus action
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimeEditorPage) blurCurrentField() {
|
||||||
|
switch p.currentField {
|
||||||
|
case 0:
|
||||||
|
p.startEditor.Blur()
|
||||||
|
case 1:
|
||||||
|
p.endEditor.Blur()
|
||||||
|
case 2:
|
||||||
|
p.tagsInput.Blur()
|
||||||
|
case 3:
|
||||||
|
// Adjust checkbox doesn't need blur action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *TimeEditorPage) View() string {
|
func (p *TimeEditorPage) View() string {
|
||||||
return p.form.View()
|
var sections []string
|
||||||
|
|
||||||
|
// Title
|
||||||
|
titleStyle := p.common.Styles.Form.Focused.Title
|
||||||
|
sections = append(sections, titleStyle.Render("Edit Time Interval"))
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// Start editor
|
||||||
|
sections = append(sections, p.startEditor.View())
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// End editor
|
||||||
|
sections = append(sections, p.endEditor.View())
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// Tags input
|
||||||
|
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
||||||
|
tagsLabel := tagsLabelStyle.Render("Tags")
|
||||||
|
if p.currentField == 2 {
|
||||||
|
sections = append(sections, tagsLabel)
|
||||||
|
sections = append(sections, p.tagsInput.View())
|
||||||
|
descStyle := p.common.Styles.Form.Focused.Description
|
||||||
|
sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces"))
|
||||||
|
} else {
|
||||||
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||||
|
sections = append(sections, blurredLabelStyle.Render("Tags"))
|
||||||
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = append(sections, "")
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// Adjust checkbox
|
||||||
|
adjustLabelStyle := p.common.Styles.Form.Focused.Title
|
||||||
|
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
|
||||||
|
|
||||||
|
var checkbox string
|
||||||
|
if p.adjust {
|
||||||
|
checkbox = "[X]"
|
||||||
|
} else {
|
||||||
|
checkbox = "[ ]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.currentField == 3 {
|
||||||
|
focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||||
|
sections = append(sections, adjustLabel)
|
||||||
|
sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals"))
|
||||||
|
descStyle := p.common.Styles.Form.Focused.Description
|
||||||
|
sections = append(sections, descStyle.Render("Press space to toggle"))
|
||||||
|
} else {
|
||||||
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||||
|
sections = append(sections, blurredLabelStyle.Render("Adjust overlaps"))
|
||||||
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(checkbox+" Auto-adjust overlapping intervals"))
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = append(sections, "")
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||||
|
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel"))
|
||||||
|
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimeEditorPage) SetSize(width, height int) {
|
func (p *TimeEditorPage) SetSize(width, height int) {
|
||||||
@ -117,13 +245,13 @@ func (p *TimeEditorPage) saveInterval() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p.interval.Start = p.startStr
|
p.interval.Start = p.startEditor.GetValueString()
|
||||||
p.interval.End = p.endStr
|
p.interval.End = p.endEditor.GetValueString()
|
||||||
|
|
||||||
// Parse tags
|
// Parse tags
|
||||||
p.interval.Tags = parseTags(p.tagsStr)
|
p.interval.Tags = parseTags(p.tagsInput.Value())
|
||||||
|
|
||||||
err := p.common.TimeW.ModifyInterval(p.interval)
|
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to modify interval", "err", err)
|
slog.Error("Failed to modify interval", "err", err)
|
||||||
}
|
}
|
||||||
@ -164,5 +292,3 @@ func formatTags(tags []string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(formatted, " ")
|
return strings.Join(formatted, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
BIN
test/taskchampion.sqlite3-shm
Normal file
BIN
test/taskchampion.sqlite3-shm
Normal file
Binary file not shown.
0
test/taskchampion.sqlite3-wal
Normal file
0
test/taskchampion.sqlite3-wal
Normal file
@ -30,7 +30,7 @@ type TimeWarrior interface {
|
|||||||
ContinueInterval(id int) error
|
ContinueInterval(id int) error
|
||||||
CancelTracking() error
|
CancelTracking() error
|
||||||
DeleteInterval(id int) error
|
DeleteInterval(id int) error
|
||||||
ModifyInterval(interval *Interval) error
|
ModifyInterval(interval *Interval, adjust bool) error
|
||||||
GetSummary(filter ...string) string
|
GetSummary(filter ...string) string
|
||||||
GetActive() *Interval
|
GetActive() *Interval
|
||||||
|
|
||||||
@ -218,7 +218,7 @@ func (ts *TimeSquire) DeleteInterval(id int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) ModifyInterval(interval *Interval) error {
|
func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@ -229,8 +229,14 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval) error {
|
|||||||
return 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
|
// Import the modified interval
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import"}...)...)
|
cmd := exec.Command(twBinary, args...)
|
||||||
cmd.Stdin = bytes.NewBuffer(intervals)
|
cmd.Stdin = bytes.NewBuffer(intervals)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user