19 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
56 changed files with 2263 additions and 7010 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:*)"
]
}
}

3
.gitignore vendored
View File

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

View File

@@ -201,7 +201,7 @@ ts.StopTask(&task)
## Development Notes
- **Logging**: Application logs to `app.log` in current directory
- **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`
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

View File

@@ -1,194 +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
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
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.Tab = lipgloss.NewStyle().
Padding(0, 1).
Foreground(lipgloss.Color("240"))
styles.ActiveTab = styles.Tab.
Foreground(lipgloss.Color("252")).
Bold(true)
styles.TabBar = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(lipgloss.Color("240")).
MarginBottom(1)
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
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,282 +0,0 @@
package autocomplete
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sahilm/fuzzy"
)
type Autocomplete struct {
input textinput.Model
allSuggestions []string // All available suggestions (newest first)
filteredSuggestions []string // Currently matching suggestions
matchedIndexes [][]int // Matched character positions for each suggestion
selectedIndex int // -1 = input focused, 0+ = suggestion selected
showSuggestions bool // Whether to display suggestion box
maxVisible int // Max suggestions to show
minChars int // Min chars before showing suggestions
focused bool
width int
placeholder string
}
// New creates a new autocomplete component
func New(suggestions []string, minChars int, maxVisible int) *Autocomplete {
ti := textinput.New()
ti.Width = 50
return &Autocomplete{
input: ti,
allSuggestions: suggestions,
selectedIndex: -1,
maxVisible: maxVisible,
minChars: minChars,
width: 50,
}
}
// SetValue sets the input value
func (a *Autocomplete) SetValue(value string) {
a.input.SetValue(value)
a.updateFilteredSuggestions()
}
// GetValue returns the current input value
func (a *Autocomplete) GetValue() string {
return a.input.Value()
}
// Focus focuses the autocomplete input
func (a *Autocomplete) Focus() {
a.focused = true
a.input.Focus()
}
// Blur blurs the autocomplete input
func (a *Autocomplete) Blur() {
a.focused = false
a.input.Blur()
a.showSuggestions = false
}
// SetPlaceholder sets the placeholder text
func (a *Autocomplete) SetPlaceholder(placeholder string) {
a.placeholder = placeholder
a.input.Placeholder = placeholder
}
// SetWidth sets the width of the autocomplete
func (a *Autocomplete) SetWidth(width int) {
a.width = width
a.input.Width = width
}
// SetMaxVisible sets the maximum number of visible suggestions
func (a *Autocomplete) SetMaxVisible(max int) {
a.maxVisible = max
}
// SetMinChars sets the minimum characters required before showing suggestions
func (a *Autocomplete) SetMinChars(min int) {
a.minChars = min
}
// Init initializes the autocomplete
func (a *Autocomplete) Init() tea.Cmd {
return textinput.Blink
}
// Update handles messages for the autocomplete
func (a *Autocomplete) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !a.focused {
return a, nil
}
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
a.selectedIndex++
if a.selectedIndex >= len(a.filteredSuggestions) {
a.selectedIndex = 0
}
return a, nil
}
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
a.selectedIndex--
if a.selectedIndex < 0 {
a.selectedIndex = len(a.filteredSuggestions) - 1
}
return a, nil
}
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if a.showSuggestions && a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
// Accept selected suggestion
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
a.showSuggestions = false
a.selectedIndex = -1
return a, nil
}
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
// Accept first or selected suggestion
if a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
} else {
a.input.SetValue(a.filteredSuggestions[0])
}
a.showSuggestions = false
a.selectedIndex = -1
return a, nil
}
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
if a.showSuggestions {
a.showSuggestions = false
a.selectedIndex = -1
return a, nil
}
default:
// Handle regular text input
prevValue := a.input.Value()
a.input, cmd = a.input.Update(msg)
// Update suggestions if value changed
if a.input.Value() != prevValue {
a.updateFilteredSuggestions()
}
return a, cmd
}
}
a.input, cmd = a.input.Update(msg)
return a, cmd
}
// View renders the autocomplete
func (a *Autocomplete) View() string {
// Input field
inputView := a.input.View()
if !a.showSuggestions || len(a.filteredSuggestions) == 0 {
return inputView
}
// Suggestion box
var suggestionViews []string
for i, suggestion := range a.filteredSuggestions {
if i >= a.maxVisible {
break
}
prefix := " "
baseStyle := lipgloss.NewStyle().Padding(0, 1)
if i == a.selectedIndex {
// Highlight selected suggestion
baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12"))
prefix = "→ "
}
// Build suggestion with highlighted matched characters
var rendered string
if i < len(a.matchedIndexes) {
rendered = a.renderWithHighlights(suggestion, a.matchedIndexes[i], i == a.selectedIndex)
} else {
rendered = suggestion
}
suggestionViews = append(suggestionViews, baseStyle.Render(prefix+rendered))
}
// Box style
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8")).
Width(a.width)
suggestionsBox := boxStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, suggestionViews...),
)
return lipgloss.JoinVertical(lipgloss.Left, inputView, suggestionsBox)
}
// renderWithHighlights renders a suggestion with matched characters highlighted
func (a *Autocomplete) renderWithHighlights(str string, matchedIndexes []int, isSelected bool) string {
if len(matchedIndexes) == 0 {
return str
}
// Create a map for quick lookup
matchedMap := make(map[int]bool)
for _, idx := range matchedIndexes {
matchedMap[idx] = true
}
// Choose highlight style based on selection state
var highlightStyle lipgloss.Style
if isSelected {
// When selected, use underline to distinguish from selection bold
highlightStyle = lipgloss.NewStyle().Underline(true)
} else {
// When not selected, use bold and accent color
highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
}
// Build the string with highlights
var result string
runes := []rune(str)
for i, r := range runes {
if matchedMap[i] {
result += highlightStyle.Render(string(r))
} else {
result += string(r)
}
}
return result
}
// updateFilteredSuggestions filters suggestions based on current input
func (a *Autocomplete) updateFilteredSuggestions() {
value := a.input.Value()
// Only show if >= minChars
if len(value) < a.minChars {
a.showSuggestions = false
a.filteredSuggestions = nil
a.matchedIndexes = nil
a.selectedIndex = -1
return
}
// Fuzzy match using sahilm/fuzzy
matches := fuzzy.Find(value, a.allSuggestions)
var filtered []string
var indexes [][]int
for _, match := range matches {
filtered = append(filtered, match.Str)
indexes = append(indexes, match.MatchedIndexes)
if len(filtered) >= a.maxVisible {
break
}
}
a.filteredSuggestions = filtered
a.matchedIndexes = indexes
a.showSuggestions = len(filtered) > 0 && a.focused
a.selectedIndex = -1 // Reset to input
}

View File

@@ -1,672 +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
}
// IsFiltering returns true if the multi-select is currently filtering.
func (m *MultiSelect) IsFiltering() bool {
return m.filtering
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func clamp(n, low, high int) int {
if low > high {
low, high = high, low
}
return min(high, max(low, n))
}
type SuppressBackMsg struct{}
func SuppressBack() tea.Cmd {
return func() tea.Msg {
return SuppressBackMsg{}
}
}

View File

@@ -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,241 +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
focused bool
}
type PickerOption func(*Picker)
func WithFilterByDefault(enabled bool) PickerOption {
return func(p *Picker) {
p.filterByDefault = enabled
}
}
func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption {
return func(p *Picker) {
p.onCreate = onCreate
}
}
func (p *Picker) Focus() tea.Cmd {
p.focused = true
return nil
}
func (p *Picker) Blur() tea.Cmd {
p.focused = false
return nil
}
func (p *Picker) GetValue() string {
item := p.list.SelectedItem()
if item == nil {
return ""
}
return item.FilterValue()
}
func New(
c *common.Common,
title string,
itemProvider func() []list.Item,
onSelect func(list.Item) tea.Cmd,
opts ...PickerOption,
) *Picker {
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = false
delegate.SetSpacing(0)
l := list.New([]list.Item{}, delegate, 0, 0)
l.SetShowTitle(false)
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true)
// 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,
focused: true,
}
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
p.filterByDefault = true
}
for _, opt := range opts {
opt(p)
}
if p.filterByDefault {
// Manually trigger filter mode on the list so it doesn't require a global key press
var cmd tea.Cmd
p.list, cmd = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
// We can ignore the command here as it's likely just for blinking, which will happen on Init anyway
_ = cmd
}
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 {
return nil
}
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !p.focused {
return p, nil
}
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 {
var title string
if p.focused {
title = p.common.Styles.Form.Focused.Title.Render(p.title)
} else {
title = p.common.Styles.Form.Blurred.Title.Render(p.title)
}
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
}
func (p *Picker) IsFiltering() bool {
return p.list.FilterState() == list.Filtering
}
// SelectItemByFilterValue selects the item with the given filter value
func (p *Picker) SelectItemByFilterValue(filterValue string) {
items := p.list.Items()
for i, item := range items {
if item.FilterValue() == filterValue {
p.list.Select(i)
break
}
}
}

View File

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

View File

@@ -1,409 +0,0 @@
package timestampeditor
import (
"log/slog"
"strings"
"tasksquire/common"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
timeFormat = "20060102T150405Z" // Timewarrior format
)
// Field represents which field is currently focused
type Field int
const (
TimeField Field = iota
DateField
)
// TimestampEditor is a component for editing timestamps with separate time and date fields
type TimestampEditor struct {
common *common.Common
// Current timestamp value
timestamp time.Time
isEmpty bool // Track if timestamp is unset
// UI state
focused bool
currentField Field
// Dimensions
width int
height int
// Title and description
title string
description string
// Validation
validate func(time.Time) error
err error
}
// New creates a new TimestampEditor with no initial timestamp
func New(com *common.Common) *TimestampEditor {
return &TimestampEditor{
common: com,
timestamp: time.Time{}, // Zero time
isEmpty: true, // Start empty
focused: false,
currentField: TimeField,
validate: func(time.Time) error { return nil },
}
}
// Title sets the title of the timestamp editor
func (t *TimestampEditor) Title(title string) *TimestampEditor {
t.title = title
return t
}
// Description sets the description of the timestamp editor
func (t *TimestampEditor) Description(description string) *TimestampEditor {
t.description = description
return t
}
// Value sets the initial timestamp value
func (t *TimestampEditor) Value(timestamp time.Time) *TimestampEditor {
t.timestamp = timestamp
t.isEmpty = timestamp.IsZero()
return t
}
// ValueFromString sets the initial timestamp from a timewarrior format string
func (t *TimestampEditor) ValueFromString(s string) *TimestampEditor {
if s == "" {
t.timestamp = time.Time{}
t.isEmpty = true
return t
}
parsed, err := time.Parse(timeFormat, s)
if err != nil {
slog.Error("Failed to parse timestamp", "error", err)
t.timestamp = time.Time{}
t.isEmpty = true
return t
}
t.timestamp = parsed.Local()
t.isEmpty = false
return t
}
// GetValue returns the current timestamp
func (t *TimestampEditor) GetValue() time.Time {
return t.timestamp
}
// GetValueString returns the timestamp in timewarrior format, or empty string if unset
func (t *TimestampEditor) GetValueString() string {
if t.isEmpty {
return ""
}
return t.timestamp.UTC().Format(timeFormat)
}
// Validate sets the validation function
func (t *TimestampEditor) Validate(validate func(time.Time) error) *TimestampEditor {
t.validate = validate
return t
}
// Error returns the validation error
func (t *TimestampEditor) Error() error {
return t.err
}
// Focus focuses the timestamp editor
func (t *TimestampEditor) Focus() tea.Cmd {
t.focused = true
return nil
}
// Blur blurs the timestamp editor
func (t *TimestampEditor) Blur() tea.Cmd {
t.focused = false
t.err = t.validate(t.timestamp)
return nil
}
// SetSize sets the size of the timestamp editor
func (t *TimestampEditor) SetSize(width, height int) {
t.width = width
t.height = height
}
// Init initializes the timestamp editor
func (t *TimestampEditor) Init() tea.Cmd {
return nil
}
// Update handles messages for the timestamp editor
func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !t.focused {
return t, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
t.err = nil
switch msg.String() {
// Navigation between fields
case "h", "left":
t.currentField = TimeField
case "l", "right":
t.currentField = DateField
// Time field adjustments (lowercase - 5 minutes)
case "j":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(-5)
} else {
t.adjustDate(-1)
}
case "k":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(5)
} else {
t.adjustDate(1)
}
// Time field adjustments (uppercase - 30 minutes) or date adjustments (week)
case "J":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(-30)
} else {
t.adjustDate(-7)
}
case "K":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(30)
} else {
t.adjustDate(7)
}
// Remove timestamp
case "d":
t.timestamp = time.Time{}
t.isEmpty = true
}
}
return t, nil
}
// setCurrentTime sets the timestamp to the current time and marks it as not empty
func (t *TimestampEditor) setCurrentTime() {
now := time.Now()
// Snap to nearest 5 minutes
minute := now.Minute()
remainder := minute % 5
if remainder != 0 {
if remainder < 3 {
// Round down
now = now.Add(-time.Duration(remainder) * time.Minute)
} else {
// Round up
now = now.Add(time.Duration(5-remainder) * time.Minute)
}
}
// Zero out seconds and nanoseconds
t.timestamp = time.Date(
now.Year(),
now.Month(),
now.Day(),
now.Hour(),
now.Minute(),
0, 0,
now.Location(),
)
t.isEmpty = false
}
// adjustTime adjusts the time by the given number of minutes and snaps to nearest 5 minutes
func (t *TimestampEditor) adjustTime(minutes int) {
// Add the minutes
t.timestamp = t.timestamp.Add(time.Duration(minutes) * time.Minute)
// Snap to nearest 5 minutes
minute := t.timestamp.Minute()
remainder := minute % 5
if remainder != 0 {
if remainder < 3 {
// Round down
t.timestamp = t.timestamp.Add(-time.Duration(remainder) * time.Minute)
} else {
// Round up
t.timestamp = t.timestamp.Add(time.Duration(5-remainder) * time.Minute)
}
}
// Zero out seconds and nanoseconds
t.timestamp = time.Date(
t.timestamp.Year(),
t.timestamp.Month(),
t.timestamp.Day(),
t.timestamp.Hour(),
t.timestamp.Minute(),
0, 0,
t.timestamp.Location(),
)
}
// adjustDate adjusts the date by the given number of days
func (t *TimestampEditor) adjustDate(days int) {
t.timestamp = t.timestamp.AddDate(0, 0, days)
}
// View renders the timestamp editor
func (t *TimestampEditor) View() string {
var sb strings.Builder
styles := t.getStyles()
// Render title if present
if t.title != "" {
sb.WriteString(styles.title.Render(t.title))
if t.err != nil {
sb.WriteString(styles.errorIndicator.String())
}
sb.WriteString("\n")
}
// Render description if present
if t.description != "" {
sb.WriteString(styles.description.Render(t.description))
sb.WriteString("\n")
}
// Render the time and date fields side by side
var timeStr, dateStr string
if t.isEmpty {
timeStr = "--:--"
dateStr = "--- ----------"
} else {
timeStr = t.timestamp.Format("15:04")
dateStr = t.timestamp.Format("Mon 2006-01-02")
}
var timeField, dateField string
if t.currentField == TimeField {
timeField = styles.selectedField.Render(timeStr)
dateField = styles.unselectedField.Render(dateStr)
} else {
timeField = styles.unselectedField.Render(timeStr)
dateField = styles.selectedField.Render(dateStr)
}
fieldsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeField, " ", dateField)
sb.WriteString(fieldsRow)
return styles.base.Render(sb.String())
}
// getHelpText returns the help text based on the current field
func (t *TimestampEditor) getHelpText() string {
if t.currentField == TimeField {
return "h/l: switch field • j/k: ±5min • J/K: ±30min • d: remove"
}
return "h/l: switch field • j/k: ±1day • J/K: ±1week • d: remove"
}
// Styles for the timestamp editor
type timestampEditorStyles struct {
base lipgloss.Style
title lipgloss.Style
description lipgloss.Style
errorIndicator lipgloss.Style
selectedField lipgloss.Style
unselectedField lipgloss.Style
help lipgloss.Style
}
// getStyles returns the styles for the timestamp editor
func (t *TimestampEditor) getStyles() timestampEditorStyles {
theme := t.common.Styles.Form
var styles timestampEditorStyles
if t.focused {
styles.base = lipgloss.NewStyle()
styles.title = theme.Focused.Title
styles.description = theme.Focused.Description
styles.errorIndicator = theme.Focused.ErrorIndicator
styles.selectedField = lipgloss.NewStyle().
Bold(true).
Padding(0, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("12"))
styles.unselectedField = lipgloss.NewStyle().
Padding(0, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8"))
styles.help = lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Italic(true)
} else {
styles.base = lipgloss.NewStyle()
styles.title = theme.Blurred.Title
styles.description = theme.Blurred.Description
styles.errorIndicator = theme.Blurred.ErrorIndicator
styles.selectedField = lipgloss.NewStyle().
Padding(0, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8"))
styles.unselectedField = lipgloss.NewStyle().
Padding(0, 2).
Border(lipgloss.HiddenBorder())
styles.help = lipgloss.NewStyle().
Foreground(lipgloss.Color("8"))
}
return styles
}
// Skip returns whether the timestamp editor should be skipped
func (t *TimestampEditor) Skip() bool {
return false
}
// Zoom returns whether the timestamp editor should be zoomed
func (t *TimestampEditor) Zoom() bool {
return false
}
// KeyBinds returns the key bindings for the timestamp editor
func (t *TimestampEditor) KeyBinds() []key.Binding {
return []key.Binding{
t.common.Keymap.Left,
t.common.Keymap.Right,
t.common.Keymap.Up,
t.common.Keymap.Down,
}
}

63
flake.lock generated
View File

@@ -1,58 +1,59 @@
{
"nodes": {
"flake-utils": {
"flake-parts": {
"inputs": {
"systems": "systems"
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1715787315,
"narHash": "sha256-cYApT0NXJfqBkKcci7D9Kr4CBYZKOQKDYA23q8XNuWg=",
"owner": "NixOS",
"lastModified": 1771848320,
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "33d1e753c82ffc557b4a585c77de43d4c922ebb5",
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
"type": "github"
},
"original": {
"id": "nixpkgs",
"owner": "nixos",
"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": {
"inputs": {
"flake-utils": "flake-utils",
"flake-parts": "flake-parts",
"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",

View File

@@ -2,23 +2,44 @@
description = "Tasksquire";
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
};
outputs = inputs@{ self, nixpkgs, flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
buildDeps = with pkgs; [
go_1_22
gcc
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
];
devDeps = with pkgs; buildDeps ++ [
meta = with pkgs.lib; {
description = "A Terminal User Interface (TUI) for Taskwarrior";
mainProgram = "tasksquire";
};
};
# Set the default package
packages.default = self'.packages.tasksquire;
# Development shell
devShells.default = pkgs.mkShell {
inputsFrom = [ self'.packages.tasksquire ];
buildInputs = with pkgs; [
go
gcc
gotools
golangci-lint
gopls
@@ -28,12 +49,8 @@
gotests
delve
];
in
{
devShell = pkgs.mkShell {
buildInputs = devDeps;
CGO_CFLAGS = "-O";
};
});
};
};
}

51
go.mod
View File

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

View File

@@ -5,8 +5,8 @@ import (
"log/slog"
"os"
"tasksquire/taskwarrior"
"tasksquire/timewarrior"
"tasksquire/internal/taskwarrior"
"tasksquire/internal/timewarrior"
"golang.org/x/term"
)

View File

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

View File

@@ -1,7 +1,7 @@
package common
import (
"github.com/charmbracelet/bubbles/key"
"charm.land/bubbles/v2/key"
)
// Keymap is a collection of key bindings.
@@ -24,6 +24,7 @@ type Keymap struct {
SetReport key.Binding
SetContext key.Binding
SetProject key.Binding
PickProjectTask key.Binding
Select key.Binding
Insert key.Binding
Tag key.Binding
@@ -31,6 +32,8 @@ type Keymap struct {
Fill key.Binding
StartStop key.Binding
Join key.Binding
ViewDetails key.Binding
Subtask key.Binding
}
// TODO: use config values for key bindings
@@ -103,13 +106,13 @@ func NewKeymap() *Keymap {
),
NextPage: key.NewBinding(
key.WithKeys("]"),
key.WithHelp("[", "Next page"),
key.WithKeys("]", "L"),
key.WithHelp("]/L", "Next page"),
),
PrevPage: key.NewBinding(
key.WithKeys("["),
key.WithHelp("]", "Previous page"),
key.WithKeys("[", "H"),
key.WithHelp("[/H", "Previous page"),
),
SetReport: key.NewBinding(
@@ -127,6 +130,11 @@ func NewKeymap() *Keymap {
key.WithHelp("p", "Set project"),
),
PickProjectTask: key.NewBinding(
key.WithKeys("P"),
key.WithHelp("P", "Pick project task"),
),
Select: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "Select"),
@@ -161,5 +169,15 @@ func NewKeymap() *Keymap {
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)
})
}

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

View File

@@ -1,30 +1,26 @@
package timetable
package tasktable
import (
"strings"
taskw "tasksquire/internal/taskwarrior"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"tasksquire/common"
"tasksquire/timewarrior"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
// Model defines a state for the table widget.
type Model struct {
common *common.Common
KeyMap KeyMap
Help help.Model
cols []Column
rows timewarrior.Intervals
rowStyles []lipgloss.Style
rows []Row
cursor int
focus bool
styles common.TableStyle
styleFunc StyleFunc
styles Styles
viewport viewport.Model
start int
@@ -32,19 +28,20 @@ type Model struct {
}
// 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.
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.
// is used to render the help menu.
type KeyMap struct {
LineUp key.Binding
LineDown key.Binding
@@ -71,7 +68,6 @@ func (km KeyMap) FullHelp() [][]key.Binding {
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
const spacebar = " "
return KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "k"),
@@ -86,7 +82,7 @@ func DefaultKeyMap() KeyMap {
key.WithHelp("b/pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("f", "pgdown", spacebar),
key.WithKeys("f", "pgdown", "space"),
key.WithHelp("f/pgdn", "page down"),
),
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.
func (m *Model) SetStyles(s common.TableStyle) {
func (m *Model) SetStyles(s Styles) {
m.styles = s
m.UpdateViewport()
}
@@ -120,83 +133,25 @@ func (m *Model) SetStyles(s common.TableStyle) {
type Option func(*Model)
// New creates a new model for the table widget.
func New(com *common.Common, opts ...Option) Model {
func New(opts ...Option) Model {
m := Model{
common: com,
cursor: 0,
viewport: viewport.New(0, 20),
viewport: viewport.New(viewport.WithHeight(20)), //nolint:mnd
KeyMap: DefaultKeyMap(),
Help: help.New(),
styles: DefaultStyles(),
}
for _, opt := range opts {
opt(&m)
}
m.cols = m.parseColumns(m.cols)
m.rowStyles = m.parseRowStyles(m.rows)
m.UpdateViewport()
return m
}
func (m *Model) parseRowStyles(rows timewarrior.Intervals) []lipgloss.Style {
styles := make([]lipgloss.Style, len(rows))
if len(rows) == 0 {
return styles
}
for i := range rows {
// Default style
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
// If active, maybe highlight?
if rows[i].IsActive() {
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
}
}
}
return styles
}
func (m *Model) parseColumns(cols []Column) []Column {
if len(cols) == 0 {
return cols
}
for i, col := range cols {
for _, interval := range m.rows {
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(interval.GetString(col.Name)))
}
cols[i] = col
}
combinedSize := 0
nonZeroWidths := 0
tagIndex := -1
for i, col := range cols {
if col.ContentWidth > 0 {
col.Width = max(col.ContentWidth, lipgloss.Width(col.Title))
nonZeroWidths++
}
if !strings.Contains(col.Name, "tags") {
combinedSize += col.Width
} else {
tagIndex = i
}
cols[i] = col
}
if tagIndex >= 0 {
cols[tagIndex].Width = m.Width() - combinedSize - nonZeroWidths
}
return cols
}
// WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option {
return func(m *Model) {
@@ -205,7 +160,7 @@ func WithColumns(cols []Column) Option {
}
// WithRows sets the table rows (data).
func WithIntervals(rows timewarrior.Intervals) Option {
func WithRows(rows []Row) Option {
return func(m *Model) {
m.rows = rows
}
@@ -214,14 +169,14 @@ func WithIntervals(rows timewarrior.Intervals) Option {
// WithHeight sets the height of the table.
func WithHeight(h int) Option {
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.
func WithWidth(w int) Option {
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.
func WithStyles(s common.TableStyle) Option {
func WithStyles(s Styles) 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) {
@@ -260,22 +208,20 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
switch msg := msg.(type) {
case tea.KeyMsg:
case tea.KeyPressMsg:
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)
m.MoveUp(m.viewport.Height())
case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height)
m.MoveDown(m.viewport.Height())
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):
m.MoveDown(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
m.MoveDown(m.viewport.Height() / 2) //nolint:mnd
case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom):
@@ -309,17 +255,27 @@ func (m Model) View() string {
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
// 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)
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))
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))
}
@@ -330,22 +286,17 @@ func (m *Model) UpdateViewport() {
}
// SelectedRow returns the selected row.
// Returns nil if cursor is on a gap row or out of bounds.
// You can cast it to your own implementation.
func (m Model) SelectedRow() Row {
if m.cursor < 0 || m.cursor >= len(m.rows) {
return nil
}
// Don't return gap rows as selected
if m.rows[m.cursor].IsGap {
return nil
return Row{}
}
return m.rows[m.cursor]
}
// Rows returns the current rows.
func (m Model) Rows() timewarrior.Intervals {
func (m Model) Rows() []Row {
return m.rows
}
@@ -355,8 +306,13 @@ func (m Model) Columns() []Column {
}
// SetRows sets a new rows state.
func (m *Model) SetRows(r timewarrior.Intervals) {
func (m *Model) SetRows(r []Row) {
m.rows = r
if m.cursor > len(m.rows)-1 {
m.cursor = len(m.rows) - 1
}
m.UpdateViewport()
}
@@ -368,24 +324,24 @@ func (m *Model) SetColumns(c []Column) {
// SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) {
m.viewport.Width = w
m.viewport.SetWidth(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.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
m.UpdateViewport()
}
// Height returns the viewport height of the table.
func (m Model) Height() int {
return m.viewport.Height
return m.viewport.Height()
}
// Width returns the viewport width of the table.
func (m Model) Width() int {
return m.viewport.Width
return m.viewport.Width()
}
// Cursor returns the index of the selected row.
@@ -394,109 +350,46 @@ func (m Model) Cursor() int {
}
// SetCursor sets the cursor position in the table.
// Skips gap rows by moving to the nearest non-gap row.
func (m *Model) SetCursor(n int) {
m.cursor = clamp(n, 0, len(m.rows)-1)
// Skip gap rows - try moving down first, then up
if m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
// Try moving down to find non-gap
found := false
for i := m.cursor; i < len(m.rows); i++ {
if !m.rows[i].IsGap {
m.cursor = i
found = true
break
}
}
// If not found down, try moving up
if !found {
for i := m.cursor; i >= 0; i-- {
if !m.rows[i].IsGap {
m.cursor = i
break
}
}
}
}
m.UpdateViewport()
}
// MoveUp moves the selection up by any number of rows.
// It can not go above the first row. Skips gap rows.
// It can not go above the first row.
func (m *Model) MoveUp(n int) {
originalCursor := m.cursor
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
// Skip gap rows
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
m.cursor--
}
// If we went past the beginning, find the first non-gap row
if m.cursor < 0 {
for i := 0; i < len(m.rows); i++ {
if !m.rows[i].IsGap {
m.cursor = i
break
}
}
}
// If no non-gap row found, restore original cursor
if m.cursor < 0 || (m.cursor < len(m.rows) && m.rows[m.cursor].IsGap) {
m.cursor = originalCursor
}
offset := m.viewport.YOffset()
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)
offset = clamp(offset, 0, m.cursor)
case m.start < m.viewport.Height():
offset = clamp(clamp(offset+n, 0, m.cursor), 0, m.viewport.Height())
case offset >= 1:
offset = clamp(offset+n, 1, m.viewport.Height())
}
m.viewport.SetYOffset(offset)
m.UpdateViewport()
}
// MoveDown moves the selection down by any number of rows.
// It can not go below the last row. Skips gap rows.
// It can not go below the last row.
func (m *Model) MoveDown(n int) {
originalCursor := m.cursor
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
// Skip gap rows
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
m.cursor++
}
// If we went past the end, find the last non-gap row
if m.cursor >= len(m.rows) {
for i := len(m.rows) - 1; i >= 0; i-- {
if !m.rows[i].IsGap {
m.cursor = i
break
}
}
}
// If no non-gap row found, restore original cursor
if m.cursor >= len(m.rows) || (m.cursor >= 0 && m.rows[m.cursor].IsGap) {
m.cursor = originalCursor
}
m.UpdateViewport()
offset := m.viewport.YOffset()
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))
case m.end == len(m.rows) && offset > 0:
offset = clamp(offset-n, 1, m.viewport.Height())
case m.cursor > (m.end-m.start)/2 && offset > 0:
offset = clamp(offset-n, 1, m.cursor)
case offset > 1:
case m.cursor > offset+m.viewport.Height()-1:
offset = clamp(offset+1, 0, 1)
}
m.viewport.SetYOffset(offset)
}
// GotoTop moves the selection to the first row.
@@ -509,50 +402,36 @@ func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows))
}
// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index.
type StyleFunc func(row, col int, value string) lipgloss.Style
func (m Model) headersView() string {
var s = make([]string, 0, len(m.cols))
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, "…"))
renderedCell := style.Render(ansi.Truncate(col.Title, col.Width, "…"))
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 {
// Special rendering for gap rows
if m.rows[r].IsGap {
gapText := m.rows[r].GetString("gap_display")
gapStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Align(lipgloss.Center).
Width(m.Width())
return gapStyle.Render(gapText)
}
var s = make([]string, 0, len(m.cols))
s := make([]string, 0, len(m.cols))
for i, col := range m.cols {
if m.cols[i].Width <= 0 {
continue
}
var cellStyle lipgloss.Style
cellStyle = m.rowStyles[r]
cellStyle := m.rows[r].style
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, "…")))
renderedCell := cellStyle.Render(style.Render(ansi.Truncate(m.rows[r].task.GetString(col.Name), m.cols[i].Width, "…")))
s = append(s, renderedCell)
}
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
row := lipgloss.JoinHorizontal(lipgloss.Top, s...)
if r == m.cursor {
return m.styles.Selected.Render(row)
@@ -561,22 +440,6 @@ func (m *Model) renderRow(r int) string {
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)
}

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

View File

@@ -47,14 +47,14 @@ type Task struct {
Uuid string `json:"uuid,omitempty"`
Description string `json:"description,omitempty"`
Project string `json:"project"`
// Priority string `json:"priority"`
Priority string `json:"priority,omitempty"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags"`
VirtualTags []string `json:"-"`
Depends []string `json:"depends,omitempty"`
DependsIds string `json:"-"`
Urgency float32 `json:"urgency,omitempty"`
Parent string `json:"parent,omitempty"`
Parent string `json:"parenttask,omitempty"`
Due string `json:"due,omitempty"`
Wait string `json:"wait,omitempty"`
Scheduled string `json:"scheduled,omitempty"`
@@ -149,8 +149,8 @@ func (t *Task) GetString(fieldWFormat string) string {
}
return t.Project
// case "priority":
// return t.Priority
case "priority":
return t.Priority
case "status":
return t.Status
@@ -168,7 +168,7 @@ func (t *Task) GetString(fieldWFormat string) string {
}
return strings.Join(t.Tags, " ")
case "parent":
case "parenttask":
if format == "short" {
return t.Parent[:8]
}
@@ -229,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 ""
}
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)
if err != nil {
slog.Error("Failed to parse time:", err)
slog.Error("Failed to parse time", "error", err)
return time.Time{}
}
return dt
@@ -291,7 +317,7 @@ func (t *Task) UnmarshalJSON(data []byte) error {
delete(m, "tags")
delete(m, "depends")
delete(m, "urgency")
delete(m, "parent")
delete(m, "parenttask")
delete(m, "due")
delete(m, "wait")
delete(m, "scheduled")
@@ -358,7 +384,7 @@ func formatDate(date string, format string) string {
dt, err := time.Parse(dtformat, date)
if err != nil {
slog.Error("Failed to parse time:", err)
slog.Error("Failed to parse time", "error", err)
return ""
}
@@ -382,7 +408,7 @@ func formatDate(date string, format string) string {
case "countdown":
return parseCountdown(time.Since(dt))
default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
slog.Error("Date format not implemented", "format", format)
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 (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
@@ -98,55 +99,62 @@ type TaskWarrior interface {
DeleteTask(task *Task)
StartTask(task *Task)
StopTask(task *Task)
StopActiveTasks()
GetInformation(task *Task) string
AddTaskAnnotation(uuid string, annotation string)
Undo()
}
type TaskSquire struct {
type TaskwarriorInterop struct {
configLocation string
defaultArgs []string
config *TWConfig
reports Reports
contexts Contexts
ctx context.Context
mutex sync.Mutex
}
func NewTaskSquire(configLocation string) *TaskSquire {
func NewTaskwarriorInterop(ctx context.Context, configLocation string) *TaskwarriorInterop {
if _, err := exec.LookPath(twBinary); err != nil {
slog.Error("Taskwarrior not found")
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"}
ts := &TaskSquire{
ts := &TaskwarriorInterop{
configLocation: configLocation,
defaultArgs: defaultArgs,
ctx: ctx,
mutex: sync.Mutex{},
}
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.contexts = ts.extractContexts()
return ts
}
func (ts *TaskSquire) GetConfig() *TWConfig {
func (ts *TaskwarriorInterop) GetConfig() *TWConfig {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.config
}
func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
func (ts *TaskwarriorInterop) GetTasks(report *Report, filter ...string) Tasks {
ts.mutex.Lock()
defer ts.mutex.Unlock()
args := ts.defaultArgs
if report.Context {
if report != nil && report.Context {
for _, context := range ts.contexts {
if context.Active && context.Name != "none" {
args = append(args, context.ReadFilter)
@@ -159,22 +167,30 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args = append(args, filter...)
}
cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...)
exportArgs := []string{"export"}
if report != nil && report.Name != "" {
exportArgs = append(exportArgs, report.Name)
}
cmd := exec.CommandContext(ts.ctx, twBinary, append(args, exportArgs...)...)
output, err := cmd.CombinedOutput()
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
}
tasks := make(Tasks, 0)
err = json.Unmarshal(output, &tasks)
if err != nil {
slog.Error("Failed unmarshalling tasks:", err)
slog.Error("Failed unmarshalling tasks", "error", err)
return nil
}
for _, task := range tasks {
if task.Depends != nil && len(task.Depends) > 0 {
if len(task.Depends) > 0 {
ids := make([]string, len(task.Depends))
for i, dependUuid := range task.Depends {
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
}
func (ts *TaskSquire) getIds(filter []string) string {
cmd := exec.Command(twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
func (ts *TaskwarriorInterop) getIds(filter []string) string {
cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting field:", err)
slog.Error("Failed getting field", "error", err)
return ""
}
return strings.TrimSpace(string(out))
}
func (ts *TaskSquire) GetContext(context string) *Context {
func (ts *TaskwarriorInterop) GetContext(context string) *Context {
ts.mutex.Lock()
defer ts.mutex.Unlock()
@@ -209,12 +225,12 @@ func (ts *TaskSquire) GetContext(context string) *Context {
if context, ok := ts.contexts[context]; ok {
return context
} else {
slog.Error(fmt.Sprintf("Context not found: %s", context.Name))
slog.Error("Context not found", "name", context)
return nil
}
}
func (ts *TaskSquire) GetActiveContext() *Context {
func (ts *TaskwarriorInterop) GetActiveContext() *Context {
ts.mutex.Lock()
defer ts.mutex.Unlock()
@@ -227,22 +243,22 @@ func (ts *TaskSquire) GetActiveContext() *Context {
return ts.contexts["none"]
}
func (ts *TaskSquire) GetContexts() Contexts {
func (ts *TaskwarriorInterop) GetContexts() Contexts {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.contexts
}
func (ts *TaskSquire) GetProjects() []string {
func (ts *TaskwarriorInterop) GetProjects() []string {
ts.mutex.Lock()
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()
if err != nil {
slog.Error("Failed getting projects:", err)
slog.Error("Failed getting projects", "error", err)
return nil
}
@@ -258,7 +274,7 @@ func (ts *TaskSquire) GetProjects() []string {
return projects
}
func (ts *TaskSquire) GetPriorities() []string {
func (ts *TaskwarriorInterop) GetPriorities() []string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
@@ -272,15 +288,15 @@ func (ts *TaskSquire) GetPriorities() []string {
return priorities
}
func (ts *TaskSquire) GetTags() []string {
func (ts *TaskwarriorInterop) GetTags() []string {
ts.mutex.Lock()
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()
if err != nil {
slog.Error("Failed getting tags:", err)
slog.Error("Failed getting tags", "error", err)
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 {
tags = append(tags, tag)
}
@@ -305,28 +321,31 @@ func (ts *TaskSquire) GetTags() []string {
return tags
}
func (ts *TaskSquire) GetReport(report string) *Report {
func (ts *TaskwarriorInterop) GetReport(report string) *Report {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.reports[report]
}
func (ts *TaskSquire) GetReports() Reports {
func (ts *TaskwarriorInterop) GetReports() Reports {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.reports
}
func (ts *TaskSquire) GetUdas() []Uda {
func (ts *TaskwarriorInterop) GetUdas() []Uda {
ts.mutex.Lock()
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()
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
}
@@ -335,7 +354,7 @@ func (ts *TaskSquire) GetUdas() []Uda {
if uda != "" {
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
if udatype == "" {
slog.Error(fmt.Sprintf("UDA type not found: %s", uda))
slog.Error("UDA type not found", "uda", uda)
continue
}
@@ -358,7 +377,7 @@ func (ts *TaskSquire) GetUdas() []Uda {
return udas
}
func (ts *TaskSquire) SetContext(context *Context) error {
func (ts *TaskwarriorInterop) SetContext(context *Context) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
@@ -366,9 +385,9 @@ func (ts *TaskSquire) SetContext(context *Context) error {
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 {
slog.Error("Failed setting context:", err)
slog.Error("Failed setting context", "error", err)
return err
}
@@ -379,7 +398,7 @@ func (ts *TaskSquire) SetContext(context *Context) error {
return nil
}
// func (ts *TaskSquire) AddTask(task *Task) error {
// func (ts *TaskwarriorInterop) AddTask(task *Task) error {
// ts.mutex.Lock()
// defer ts.mutex.Unlock()
@@ -417,117 +436,156 @@ func (ts *TaskSquire) SetContext(context *Context) error {
// }
// TODO error handling
func (ts *TaskSquire) ImportTask(task *Task) {
func (ts *TaskwarriorInterop) ImportTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
tasks, err := json.Marshal(Tasks{task})
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)
out, err := cmd.CombinedOutput()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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 string(output)
}
func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) {
func (ts *TaskwarriorInterop) AddTaskAnnotation(uuid string, annotation string) {
ts.mutex.Lock()
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()
if err != nil {
slog.Error("Failed adding annotation:", err)
slog.Error("Failed adding annotation", "error", err)
}
}
func (ts *TaskSquire) extractConfig() *TWConfig {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
func (ts *TaskwarriorInterop) 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:", err)
if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting config", "error", err, "output", string(output))
return nil
}
return NewConfig(strings.Split(string(output), "\n"))
}
func (ts *TaskSquire) extractReports() Reports {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
func (ts *TaskwarriorInterop) extractReports() Reports {
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
return nil
@@ -572,12 +630,15 @@ func extractReports(config string) []string {
return reports
}
func (ts *TaskSquire) extractContexts() Contexts {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
func (ts *TaskwarriorInterop) extractContexts() Contexts {
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
output, err := cmd.CombinedOutput()
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
}

View File

@@ -1,6 +1,7 @@
package taskwarrior
import (
"context"
"fmt"
"os"
"testing"
@@ -56,7 +57,7 @@ func TestTaskSquire_GetContext(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.prep()
ts := NewTaskSquire(tt.fields.configLocation)
ts := NewTaskSquire(context.Background(), tt.fields.configLocation)
if got := ts.GetActiveContext(); got.Name != 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

@@ -83,7 +83,16 @@ func (i *Interval) GetString(field string) string {
if len(i.Tags) == 0 {
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":
return i.GetDuration()
@@ -95,7 +104,7 @@ func (i *Interval) GetString(field string) string {
return ""
default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
slog.Error("Field not implemented", "field", field)
return ""
}
}
@@ -103,7 +112,7 @@ func (i *Interval) GetString(field string) string {
func (i *Interval) GetDuration() string {
start, err := time.Parse(dtformat, i.Start)
if err != nil {
slog.Error("Failed to parse start time:", err)
slog.Error("Failed to parse start time", "error", err)
return ""
}
@@ -113,7 +122,7 @@ func (i *Interval) GetDuration() string {
} else {
end, err = time.Parse(dtformat, i.End)
if err != nil {
slog.Error("Failed to parse end time:", err)
slog.Error("Failed to parse end time", "error", err)
return ""
}
}
@@ -125,7 +134,7 @@ func (i *Interval) GetDuration() string {
func (i *Interval) GetStartTime() time.Time {
dt, err := time.Parse(dtformat, i.Start)
if err != nil {
slog.Error("Failed to parse time:", err)
slog.Error("Failed to parse time", "error", err)
return time.Time{}
}
return dt
@@ -137,7 +146,7 @@ func (i *Interval) GetEndTime() time.Time {
}
dt, err := time.Parse(dtformat, i.End)
if err != nil {
slog.Error("Failed to parse time:", err)
slog.Error("Failed to parse time", "error", err)
return time.Time{}
}
return dt
@@ -178,7 +187,7 @@ func formatDate(date string, format string) string {
dt, err := time.Parse(dtformat, date)
if err != nil {
slog.Error("Failed to parse time:", err)
slog.Error("Failed to parse time", "error", err)
return ""
}
@@ -202,7 +211,7 @@ func formatDate(date string, format string) string {
case "relative":
return parseDurationVague(time.Until(dt))
default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
slog.Error("Date format not implemented", "format", format)
return ""
}
}

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

@@ -4,6 +4,7 @@ package timewarrior
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
@@ -40,23 +41,25 @@ type TimeWarrior interface {
Undo()
}
type TimeSquire struct {
type TimewarriorInterop struct {
configLocation string
defaultArgs []string
config *TWConfig
ctx context.Context
mutex sync.Mutex
}
func NewTimeSquire(configLocation string) *TimeSquire {
func NewTimewarriorInterop(ctx context.Context, configLocation string) *TimewarriorInterop {
if _, err := exec.LookPath(twBinary); err != nil {
slog.Error("Timewarrior not found")
return nil
}
ts := &TimeSquire{
ts := &TimewarriorInterop{
configLocation: configLocation,
defaultArgs: []string{},
ctx: ctx,
mutex: sync.Mutex{},
}
ts.config = ts.extractConfig()
@@ -64,22 +67,22 @@ func NewTimeSquire(configLocation string) *TimeSquire {
return ts
}
func (ts *TimeSquire) GetConfig() *TWConfig {
func (ts *TimewarriorInterop) GetConfig() *TWConfig {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.config
}
func (ts *TimeSquire) GetTags() []string {
func (ts *TimewarriorInterop) GetTags() []string {
ts.mutex.Lock()
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()
if err != nil {
slog.Error("Failed getting tags:", err)
slog.Error("Failed getting tags", "error", err)
return nil
}
@@ -105,7 +108,7 @@ func (ts *TimeSquire) GetTags() []string {
// GetTagCombinations returns unique tag combinations from intervals,
// ordered newest first (most recent intervals' tags appear first).
// Returns formatted strings like "dev client-work meeting".
func (ts *TimeSquire) GetTagCombinations() []string {
func (ts *TimewarriorInterop) GetTagCombinations() []string {
intervals := ts.GetIntervals() // Already sorted newest first
// Track unique combinations while preserving order
@@ -142,26 +145,28 @@ func formatTagsForCombination(tags []string) string {
return strings.Join(formatted, " ")
}
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
ts.mutex.Lock()
defer ts.mutex.Unlock()
// 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.Command(twBinary, args...)
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting intervals:", err)
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:", err)
slog.Error("Failed unmarshalling intervals", "error", err)
return nil
}
@@ -176,7 +181,15 @@ func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
return intervals
}
func (ts *TimeSquire) StartTracking(tags []string) error {
// 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()
@@ -187,116 +200,116 @@ func (ts *TimeSquire) StartTracking(tags []string) error {
args := append(ts.defaultArgs, "start")
args = append(args, tags...)
cmd := exec.Command(twBinary, args...)
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
if err := cmd.Run(); err != nil {
slog.Error("Failed starting tracking:", err)
slog.Error("Failed starting tracking", "error", err)
return err
}
return nil
}
func (ts *TimeSquire) StopTracking() error {
func (ts *TimewarriorInterop) StopTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...)
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "stop")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed stopping tracking:", err)
slog.Error("Failed stopping tracking", "error", err)
return err
}
return nil
}
func (ts *TimeSquire) ContinueTracking() error {
func (ts *TimewarriorInterop) ContinueTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...)
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "continue")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed continuing tracking:", err)
slog.Error("Failed continuing tracking", "error", err)
return err
}
return nil
}
func (ts *TimeSquire) ContinueInterval(id int) error {
func (ts *TimewarriorInterop) ContinueInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
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:", err)
slog.Error("Failed continuing interval", "error", err)
return err
}
return nil
}
func (ts *TimeSquire) CancelTracking() error {
func (ts *TimewarriorInterop) CancelTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...)
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "cancel")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed canceling tracking:", err)
slog.Error("Failed canceling tracking", "error", err)
return err
}
return nil
}
func (ts *TimeSquire) DeleteInterval(id int) error {
func (ts *TimewarriorInterop) DeleteInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
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:", err)
slog.Error("Failed deleting interval", "error", err)
return err
}
return nil
}
func (ts *TimeSquire) FillInterval(id int) error {
func (ts *TimewarriorInterop) FillInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
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:", err)
slog.Error("Failed filling interval", "error", err)
return err
}
return nil
}
func (ts *TimeSquire) JoinInterval(id int) error {
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.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
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:", err)
slog.Error("Failed joining interval", "error", err)
return err
}
return nil
}
func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
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:", err)
slog.Error("Failed marshalling interval", "error", err)
return err
}
@@ -307,18 +320,18 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
}
// Import the modified interval
cmd := exec.Command(twBinary, args...)
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
cmd.Stdin = bytes.NewBuffer(intervals)
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed modifying interval:", err, string(out))
slog.Error("Failed modifying interval", "error", err, "output", string(out))
return err
}
return nil
}
func (ts *TimeSquire) GetSummary(filter ...string) string {
func (ts *TimewarriorInterop) GetSummary(filter ...string) string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
@@ -327,28 +340,28 @@ func (ts *TimeSquire) GetSummary(filter ...string) string {
args = append(args, filter...)
}
cmd := exec.Command(twBinary, args...)
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting summary:", err)
slog.Error("Failed getting summary", "error", err)
return ""
}
return string(output)
}
func (ts *TimeSquire) GetActive() *Interval {
func (ts *TimewarriorInterop) GetActive() *Interval {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
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
intervals := ts.GetIntervals()
// Get the active interval using unlocked version (we already hold the mutex)
intervals := ts.getIntervalsUnlocked()
for _, interval := range intervals {
if interval.End == "" {
return interval
@@ -358,22 +371,22 @@ func (ts *TimeSquire) GetActive() *Interval {
return nil
}
func (ts *TimeSquire) Undo() {
func (ts *TimewarriorInterop) Undo() {
ts.mutex.Lock()
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()
if err != nil {
slog.Error("Failed undoing:", err)
slog.Error("Failed undoing", "error", err)
}
}
func (ts *TimeSquire) extractConfig() *TWConfig {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...)
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:", err)
slog.Error("Failed getting config", "error", err)
return nil
}

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,108 +0,0 @@
package pages
import (
"tasksquire/common"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type MainPage struct {
common *common.Common
activePage common.Component
taskPage common.Component
timePage common.Component
currentTab int
width int
height int
}
func NewMainPage(common *common.Common) *MainPage {
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
m.currentTab = 0
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.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() string {
return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), 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,217 +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),
}
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,300 +0,0 @@
package pages
import (
"log/slog"
"strings"
"tasksquire/common"
"tasksquire/components/autocomplete"
"tasksquire/components/timestampeditor"
"tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type TimeEditorPage struct {
common *common.Common
interval *timewarrior.Interval
// Fields
startEditor *timestampeditor.TimestampEditor
endEditor *timestampeditor.TimestampEditor
tagsInput *autocomplete.Autocomplete
adjust bool
// State
currentField int
totalFields int
}
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
// Create start timestamp editor
startEditor := timestampeditor.New(com).
Title("Start").
ValueFromString(interval.Start)
// Create end timestamp editor
endEditor := timestampeditor.New(com).
Title("End").
ValueFromString(interval.End)
// Create tags autocomplete with combinations from past intervals
tagCombinations := com.TimeW.GetTagCombinations()
tagsInput := autocomplete.New(tagCombinations, 3, 10)
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces")
tagsInput.SetValue(formatTags(interval.Tags))
tagsInput.SetWidth(50)
p := &TimeEditorPage{
common: com,
interval: interval,
startEditor: startEditor,
endEditor: endEditor,
tagsInput: tagsInput,
adjust: true, // Enable :adjust by default
currentField: 0,
totalFields: 4, // Updated to include adjust field
}
return p
}
func (p *TimeEditorPage) Init() tea.Cmd {
// Focus the first field (tags)
p.currentField = 0
p.tagsInput.Focus()
return p.tagsInput.Init()
}
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
case key.Matches(msg, p.common.Keymap.Ok):
// Save and exit
p.saveInterval()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
case key.Matches(msg, p.common.Keymap.Next):
// Move to next field
p.blurCurrentField()
p.currentField = (p.currentField + 1) % p.totalFields
cmds = append(cmds, p.focusCurrentField())
case key.Matches(msg, p.common.Keymap.Prev):
// Move to previous field
p.blurCurrentField()
p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields
cmds = append(cmds, p.focusCurrentField())
}
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
}
// Update the currently focused field
var cmd tea.Cmd
switch p.currentField {
case 0:
var model tea.Model
model, cmd = p.tagsInput.Update(msg)
if ac, ok := model.(*autocomplete.Autocomplete); ok {
p.tagsInput = ac
}
case 1:
var model tea.Model
model, cmd = p.startEditor.Update(msg)
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
p.startEditor = editor
}
case 2:
var model tea.Model
model, cmd = p.endEditor.Update(msg)
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
p.endEditor = editor
}
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)
return p, tea.Batch(cmds...)
}
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
switch p.currentField {
case 0:
p.tagsInput.Focus()
return p.tagsInput.Init()
case 1:
return p.startEditor.Focus()
case 2:
return p.endEditor.Focus()
case 3:
// Adjust checkbox doesn't need focus action
return nil
}
return nil
}
func (p *TimeEditorPage) blurCurrentField() {
switch p.currentField {
case 0:
p.tagsInput.Blur()
case 1:
p.startEditor.Blur()
case 2:
p.endEditor.Blur()
case 3:
// Adjust checkbox doesn't need blur action
}
}
func (p *TimeEditorPage) View() string {
var sections []string
// Title
titleStyle := p.common.Styles.Form.Focused.Title
sections = append(sections, titleStyle.Render("Edit Time Interval"))
sections = append(sections, "")
// Tags input (now first)
tagsLabelStyle := p.common.Styles.Form.Focused.Title
tagsLabel := tagsLabelStyle.Render("Tags")
if p.currentField == 0 {
sections = append(sections, tagsLabel)
sections = append(sections, p.tagsInput.View())
descStyle := p.common.Styles.Form.Focused.Description
sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces"))
} else {
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
sections = append(sections, blurredLabelStyle.Render("Tags"))
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.GetValue()))
}
sections = append(sections, "")
sections = append(sections, "")
// Start editor
sections = append(sections, p.startEditor.View())
sections = append(sections, "")
// End editor
sections = append(sections, p.endEditor.View())
sections = append(sections, "")
// Adjust checkbox
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) {
p.common.SetSize(width, height)
}
func (p *TimeEditorPage) saveInterval() {
// If it's an existing interval (has ID), delete it first so we can replace it with the modified version
if p.interval.ID != 0 {
err := p.common.TimeW.DeleteInterval(p.interval.ID)
if err != nil {
slog.Error("Failed to delete old interval during edit", "err", err)
// Proceeding to import anyway, attempting to save user data
}
}
p.interval.Start = p.startEditor.GetValueString()
p.interval.End = p.endEditor.GetValueString()
// Parse tags
p.interval.Tags = parseTags(p.tagsInput.GetValue())
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
if err != nil {
slog.Error("Failed to modify interval", "err", err)
}
}
func parseTags(tagsStr string) []string {
var tags []string
var current strings.Builder
inQuotes := false
for _, r := range tagsStr {
switch {
case r == '"':
inQuotes = !inQuotes
case r == ' ' && !inQuotes:
if current.Len() > 0 {
tags = append(tags, current.String())
current.Reset()
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
tags = append(tags, current.String())
}
return tags
}
func formatTags(tags []string) string {
var formatted []string
for _, t := range tags {
if strings.Contains(t, " ") {
formatted = append(formatted, "\""+t+"\"")
} else {
formatted = append(formatted, t)
}
}
return strings.Join(formatted, " ")
}

View File

@@ -1,421 +0,0 @@
package pages
import (
"fmt"
"log/slog"
"time"
"tasksquire/common"
"tasksquire/components/timetable"
"tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type TimePage struct {
common *common.Common
intervals timetable.Model
data timewarrior.Intervals
shouldSelectActive bool
selectedTimespan string
subpage common.Component
}
func NewTimePage(com *common.Common) *TimePage {
p := &TimePage{
common: com,
selectedTimespan: ":day",
}
p.populateTable(timewarrior.Intervals{})
return p
}
func (p *TimePage) isMultiDayTimespan() bool {
switch p.selectedTimespan {
case ":day", ":yesterday":
return false
case ":week", ":lastweek", ":month", ":lastmonth", ":year":
return true
default:
return true
}
}
func (p *TimePage) getTimespanLabel() string {
switch p.selectedTimespan {
case ":day":
return "Today"
case ":yesterday":
return "Yesterday"
case ":week":
return "Week"
case ":lastweek":
return "Last Week"
case ":month":
return "Month"
case ":lastmonth":
return "Last Month"
case ":year":
return "Year"
default:
return p.selectedTimespan
}
}
func (p *TimePage) getTimespanDateRange() (start, end time.Time) {
now := time.Now()
switch p.selectedTimespan {
case ":day":
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 0, 1)
case ":yesterday":
start = time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 0, 1)
case ":week":
// Find the start of the week (Monday)
offset := int(time.Monday - now.Weekday())
if offset > 0 {
offset = -6
}
start = time.Date(now.Year(), now.Month(), now.Day()+offset, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 0, 7)
case ":lastweek":
// Find the start of last week
offset := int(time.Monday - now.Weekday())
if offset > 0 {
offset = -6
}
start = time.Date(now.Year(), now.Month(), now.Day()+offset-7, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 0, 7)
case ":month":
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 1, 0)
case ":lastmonth":
start = time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 1, 0)
case ":year":
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
end = start.AddDate(1, 0, 0)
default:
start = now
end = now
}
return start, end
}
func (p *TimePage) renderHeader() string {
label := p.getTimespanLabel()
start, end := p.getTimespanDateRange()
var headerText string
if p.isMultiDayTimespan() {
// Multi-day format: "Week: Feb 02 - Feb 08, 2026"
if start.Year() == end.AddDate(0, 0, -1).Year() {
headerText = fmt.Sprintf("%s: %s - %s, %d",
label,
start.Format("Jan 02"),
end.AddDate(0, 0, -1).Format("Jan 02"),
start.Year())
} else {
headerText = fmt.Sprintf("%s: %s, %d - %s, %d",
label,
start.Format("Jan 02"),
start.Year(),
end.AddDate(0, 0, -1).Format("Jan 02"),
end.AddDate(0, 0, -1).Year())
}
} else {
// Single-day format: "Today (Mon, Feb 02, 2026)"
headerText = fmt.Sprintf("%s (%s, %s, %d)",
label,
start.Format("Mon"),
start.Format("Jan 02"),
start.Year())
}
slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan)
// Make header bold and prominent
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
return headerStyle.Render(headerText)
}
func (p *TimePage) Init() tea.Cmd {
return tea.Batch(p.getIntervals(), doTick())
}
func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case UpdateTimespanMsg:
p.selectedTimespan = string(msg)
cmds = append(cmds, p.getIntervals())
case intervalsMsg:
p.data = timewarrior.Intervals(msg)
p.populateTable(p.data)
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.SetReport):
// Use 'r' key to show timespan picker
p.subpage = NewTimespanPickerPage(p.common, p.selectedTimespan)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.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()
case key.Matches(msg, p.common.Keymap.Fill):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
p.common.TimeW.FillInterval(interval.ID)
return p, tea.Batch(p.getIntervals(), doTick())
}
case key.Matches(msg, p.common.Keymap.Undo):
p.common.TimeW.Undo()
return p, tea.Batch(p.getIntervals(), doTick())
case key.Matches(msg, p.common.Keymap.Join):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
// Don't join if this is the last (oldest) interval
if interval.ID < len(p.data) {
p.common.TimeW.JoinInterval(interval.ID)
return p, tea.Batch(p.getIntervals(), doTick())
}
}
}
}
var cmd tea.Cmd
p.intervals, cmd = p.intervals.Update(msg)
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
}
type RefreshIntervalsMsg struct{}
func refreshIntervals() tea.Msg {
return RefreshIntervalsMsg{}
}
func (p *TimePage) View() string {
header := p.renderHeader()
if len(p.data) == 0 {
noDataMsg := p.common.Styles.Base.Render("No intervals found")
content := header + "\n\n" + noDataMsg
return lipgloss.Place(
p.common.Width(),
p.common.Height(),
lipgloss.Left,
lipgloss.Top,
content,
)
}
tableView := p.intervals.View()
content := header + "\n\n" + tableView
contentHeight := lipgloss.Height(content)
tableHeight := lipgloss.Height(tableView)
headerHeight := lipgloss.Height(header)
slog.Info("TimePage View rendered",
"headerLen", len(header),
"dataCount", len(p.data),
"headerHeight", headerHeight,
"tableHeight", tableHeight,
"contentHeight", contentHeight,
"termHeight", p.common.Height())
return content
}
func (p *TimePage) SetSize(width int, height int) {
p.common.SetSize(width, height)
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
tableHeight := height - frameSize - 3
slog.Info("TimePage SetSize", "totalHeight", height, "frameSize", frameSize, "tableHeight", tableHeight)
p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
// Subtract 3: 1 for header line, 1 for empty line, 1 for safety margin
p.intervals.SetHeight(tableHeight)
}
// insertGaps inserts gap intervals between actual intervals where there is untracked time.
// Gaps are not inserted before the first interval or after the last interval.
// Note: intervals are in reverse chronological order (newest first), so we need to account for that.
func insertGaps(intervals timewarrior.Intervals) timewarrior.Intervals {
if len(intervals) <= 1 {
return intervals
}
result := make(timewarrior.Intervals, 0, len(intervals)*2)
for i := 0; i < len(intervals); i++ {
result = append(result, intervals[i])
// Don't add gap after the last interval
if i < len(intervals)-1 {
// Since intervals are reversed (newest first), the gap is between
// the end of the NEXT interval and the start of the CURRENT interval
currentStart := intervals[i].GetStartTime()
nextEnd := intervals[i+1].GetEndTime()
// Calculate gap duration
gap := currentStart.Sub(nextEnd)
// Only insert gap if there is untracked time
if gap > 0 {
gapInterval := timewarrior.NewGapInterval(nextEnd, currentStart)
result = append(result, gapInterval)
}
}
}
return result
}
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
var selectedStart string
if row := p.intervals.SelectedRow(); row != nil {
selectedStart = row.Start
}
// Insert gap intervals between actual intervals
intervalsWithGaps := insertGaps(intervals)
// Determine column configuration based on timespan
var startEndWidth int
var startField, endField string
if p.isMultiDayTimespan() {
startEndWidth = 16 // "2006-01-02 15:04"
startField = "start"
endField = "end"
} else {
startEndWidth = 5 // "15:04"
startField = "start_time"
endField = "end_time"
}
columns := []timetable.Column{
{Title: "ID", Name: "id", Width: 4},
{Title: "Weekday", Name: "weekday", Width: 9},
{Title: "Start", Name: startField, Width: startEndWidth},
{Title: "End", Name: endField, Width: startEndWidth},
{Title: "Duration", Name: "duration", Width: 10},
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
}
// Calculate table height: total height - header (1 line) - blank line (1) - safety (1)
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
tableHeight := p.common.Height() - frameSize - 3
p.intervals = timetable.New(
p.common,
timetable.WithColumns(columns),
timetable.WithIntervals(intervalsWithGaps),
timetable.WithFocused(true),
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()),
timetable.WithHeight(tableHeight),
timetable.WithStyles(p.common.Styles.TableStyle),
)
if len(intervalsWithGaps) > 0 {
newIdx := -1
if p.shouldSelectActive {
for i, interval := range intervalsWithGaps {
if !interval.IsGap && interval.IsActive() {
newIdx = i
break
}
}
p.shouldSelectActive = false
}
if newIdx == -1 && selectedStart != "" {
for i, interval := range intervalsWithGaps {
if !interval.IsGap && interval.Start == selectedStart {
newIdx = i
break
}
}
}
if newIdx == -1 {
// Default to first non-gap interval
for i, interval := range intervalsWithGaps {
if !interval.IsGap {
newIdx = i
break
}
}
}
if newIdx >= len(intervalsWithGaps) {
newIdx = len(intervalsWithGaps) - 1
}
if newIdx < 0 {
newIdx = 0
}
p.intervals.SetCursor(newIdx)
}
}
type intervalsMsg timewarrior.Intervals
func (p *TimePage) getIntervals() tea.Cmd {
return func() tea.Msg {
intervals := p.common.TimeW.GetIntervals(p.selectedTimespan)
return intervalsMsg(intervals)
}
}

View File

@@ -1,146 +0,0 @@
package pages
import (
"testing"
"time"
"tasksquire/timewarrior"
)
func TestInsertGaps(t *testing.T) {
tests := []struct {
name string
intervals timewarrior.Intervals
expectedCount int
expectedGaps int
description string
}{
{
name: "empty intervals",
intervals: timewarrior.Intervals{},
expectedCount: 0,
expectedGaps: 0,
description: "Should return empty list for empty input",
},
{
name: "single interval",
intervals: timewarrior.Intervals{
{
ID: 1,
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().UTC().Format("20060102T150405Z"),
Tags: []string{"test"},
},
},
expectedCount: 1,
expectedGaps: 0,
description: "Should return single interval without gaps",
},
{
name: "two intervals with gap (reverse chronological)",
intervals: timewarrior.Intervals{
{
ID: 1,
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().UTC().Format("20060102T150405Z"),
Tags: []string{"test2"},
},
{
ID: 2,
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
Tags: []string{"test1"},
},
},
expectedCount: 3,
expectedGaps: 1,
description: "Should insert one gap between two intervals (newest first order)",
},
{
name: "three intervals with two gaps (reverse chronological)",
intervals: timewarrior.Intervals{
{
ID: 1,
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().UTC().Format("20060102T150405Z"),
Tags: []string{"test3"},
},
{
ID: 2,
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
Tags: []string{"test2"},
},
{
ID: 3,
Start: time.Now().Add(-5 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().Add(-4 * time.Hour).UTC().Format("20060102T150405Z"),
Tags: []string{"test1"},
},
},
expectedCount: 5,
expectedGaps: 2,
description: "Should insert two gaps between three intervals (newest first order)",
},
{
name: "consecutive intervals with no gap (reverse chronological)",
intervals: timewarrior.Intervals{
{
ID: 1,
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().UTC().Format("20060102T150405Z"),
Tags: []string{"test2"},
},
{
ID: 2,
Start: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
Tags: []string{"test1"},
},
},
expectedCount: 2,
expectedGaps: 0,
description: "Should not insert gap when intervals are consecutive (newest first order)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := insertGaps(tt.intervals)
if len(result) != tt.expectedCount {
t.Errorf("insertGaps() returned %d intervals, expected %d. %s",
len(result), tt.expectedCount, tt.description)
}
gapCount := 0
for _, interval := range result {
if interval.IsGap {
gapCount++
}
}
if gapCount != tt.expectedGaps {
t.Errorf("insertGaps() created %d gaps, expected %d. %s",
gapCount, tt.expectedGaps, tt.description)
}
// Verify gaps are properly interleaved with intervals
for i := 0; i < len(result)-1; i++ {
if result[i].IsGap && result[i+1].IsGap {
t.Errorf("insertGaps() created consecutive gap rows at indices %d and %d", i, i+1)
}
}
// Verify first and last items are never gaps
if len(result) > 0 {
if result[0].IsGap {
t.Errorf("insertGaps() created gap as first item")
}
if result[len(result)-1].IsGap {
t.Errorf("insertGaps() created gap as last item")
}
}
})
}
}

View File

@@ -1,128 +0,0 @@
package pages
import (
"log/slog"
"tasksquire/common"
"tasksquire/components/picker"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type TimespanPickerPage struct {
common *common.Common
picker *picker.Picker
selectedTimespan string
}
func NewTimespanPickerPage(common *common.Common, currentTimespan string) *TimespanPickerPage {
p := &TimespanPickerPage{
common: common,
selectedTimespan: currentTimespan,
}
timespanOptions := []list.Item{
picker.NewItem(":day"),
picker.NewItem(":yesterday"),
picker.NewItem(":week"),
picker.NewItem(":lastweek"),
picker.NewItem(":month"),
picker.NewItem(":lastmonth"),
picker.NewItem(":year"),
}
itemProvider := func() []list.Item {
return timespanOptions
}
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return timespanSelectedMsg{item: item} }
}
p.picker = picker.New(common, "Select Timespan", itemProvider, onSelect)
// Select the current timespan in the picker
p.picker.SelectItemByFilterValue(currentTimespan)
p.SetSize(common.Width(), common.Height())
return p
}
func (p *TimespanPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height)
// Set list size with some padding/limits to look like a picker
listWidth := width - 4
if listWidth > 40 {
listWidth = 40
}
listHeight := height - 6
if listHeight > 20 {
listHeight = 20
}
p.picker.SetSize(listWidth, listHeight)
}
func (p *TimespanPickerPage) Init() tea.Cmd {
return p.picker.Init()
}
type timespanSelectedMsg struct {
item list.Item
}
func (p *TimespanPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case timespanSelectedMsg:
timespan := msg.item.FilterValue()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, func() tea.Msg { return UpdateTimespanMsg(timespan) }
case tea.KeyMsg:
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
}
_, cmd = p.picker.Update(msg)
return p, cmd
}
func (p *TimespanPickerPage) View() string {
width := p.common.Width() - 4
if width > 40 {
width = 40
}
content := p.picker.View()
styledContent := lipgloss.NewStyle().Width(width).Render(content)
return lipgloss.Place(
p.common.Width(),
p.common.Height(),
lipgloss.Center,
lipgloss.Center,
p.common.Styles.Base.Render(styledContent),
)
}
type UpdateTimespanMsg string

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