Compare commits
24 Commits
035d09900e
...
feat/taske
| Author | SHA1 | Date | |
|---|---|---|---|
| 70b6ee9bc7 | |||
| 2baf3859fd | |||
| 2940711b26 | |||
| f5d297e6ab | |||
| 938ed177f1 | |||
| 81b9d87935 | |||
| 9940316ace | |||
| fc8e9481c3 | |||
| 7032d0fa54 | |||
| 681ed7e635 | |||
| effd95f6c1 | |||
| 4767a6cd91 | |||
| ce193c336c | |||
| f19767fb10 | |||
| 82c41a22d2 | |||
| 73d51b956a | |||
| fac7ff81dd | |||
| 0d55a3b119 | |||
| c660b6cbb1 | |||
| 98d2d041d6 | |||
| bafd8958d4 | |||
| 3e1cb9d1bc | |||
| 0572763e31 | |||
| 9aa7b04b98 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
app.log
|
app.log
|
||||||
test/taskchampion.sqlite3
|
test/taskchampion.sqlite3
|
||||||
|
tasksquire
|
||||||
|
test/*.sqlite3*
|
||||||
|
|||||||
207
AGENTS.md
Normal file
207
AGENTS.md
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
# Agent Development Guide for TaskSquire
|
||||||
|
|
||||||
|
This guide is for AI coding agents working on TaskSquire, a Go-based TUI (Terminal User Interface) for Taskwarrior.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- **Language**: Go 1.22.2
|
||||||
|
- **Architecture**: Model-View-Update (MVU) pattern using Bubble Tea framework
|
||||||
|
- **Module**: `tasksquire`
|
||||||
|
- **Main Dependencies**: Bubble Tea, Lip Gloss, Huh, Bubbles (Charm ecosystem)
|
||||||
|
|
||||||
|
## Build, Test, and Lint Commands
|
||||||
|
|
||||||
|
### Building and Running
|
||||||
|
```bash
|
||||||
|
# Run directly
|
||||||
|
go run main.go
|
||||||
|
|
||||||
|
# Build binary
|
||||||
|
go build -o tasksquire main.go
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests for a specific package
|
||||||
|
go test ./taskwarrior
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
go test ./taskwarrior -run TestTaskSquire_GetContext
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
go test -v ./taskwarrior
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
go test -cover ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting and Formatting
|
||||||
|
```bash
|
||||||
|
# Format code (always run before committing)
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
# Lint with golangci-lint (available via nix-shell)
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
# Vet code for suspicious constructs
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# Tidy dependencies
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
```bash
|
||||||
|
# Enter Nix development shell (provides all tools)
|
||||||
|
nix develop
|
||||||
|
|
||||||
|
# Or use direnv (automatically loads .envrc)
|
||||||
|
direnv allow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tasksquire/
|
||||||
|
├── main.go # Entry point: initializes TaskSquire, TimeSquire, and Bubble Tea
|
||||||
|
├── common/ # Shared state, components interface, keymaps, styles, utilities
|
||||||
|
├── pages/ # UI pages/views (report, taskEditor, timePage, pickers, etc.)
|
||||||
|
├── components/ # Reusable UI components (input, table, timetable, picker)
|
||||||
|
├── taskwarrior/ # Taskwarrior CLI wrapper, models, config
|
||||||
|
├── timewarrior/ # Timewarrior integration, models, config
|
||||||
|
└── test/ # Test fixtures and data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
- **Standard Library First**: Group standard library imports, then third-party, then local
|
||||||
|
- **Local Import Pattern**: Use `tasksquire/<package>` for internal imports
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"tasksquire/common"
|
||||||
|
"tasksquire/taskwarrior"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Exported Types**: PascalCase (e.g., `TaskSquire`, `ReportPage`, `Common`)
|
||||||
|
- **Unexported Fields**: camelCase (e.g., `configLocation`, `activeReport`, `pageStack`)
|
||||||
|
- **Interfaces**: Follow Go convention, often ending in 'er' (e.g., `TaskWarrior`, `TimeWarrior`, `Component`)
|
||||||
|
- **Constants**: PascalCase or SCREAMING_SNAKE_CASE for exported constants
|
||||||
|
- **Test Functions**: `TestFunctionName` or `TestType_Method`
|
||||||
|
|
||||||
|
### Types and Interfaces
|
||||||
|
- **Interface-Based Design**: Use interfaces for main abstractions (see `TaskWarrior`, `TimeWarrior`, `Component`)
|
||||||
|
- **Struct Composition**: Embed common state (e.g., pages embed or reference `*common.Common`)
|
||||||
|
- **Pointer Receivers**: Use pointer receivers for methods that modify state or for consistency
|
||||||
|
- **Generic Types**: Use generics where appropriate (e.g., `Stack[T]` in `common/stack.go`)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- **Logging Over Panicking**: Use `log/slog` for structured logging, typically continue execution
|
||||||
|
- **Error Returns**: Return errors from functions, don't log and return
|
||||||
|
- **Context**: Errors are often logged with `slog.Error()` or `slog.Warn()` and execution continues
|
||||||
|
```go
|
||||||
|
// Typical pattern
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get tasks", "error", err)
|
||||||
|
return nil // or continue with default behavior
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concurrency and Thread Safety
|
||||||
|
- **Mutex Protection**: Use `sync.Mutex` to protect shared state (see `TaskSquire.mu`)
|
||||||
|
- **Lock Pattern**: Lock before operations, defer unlock
|
||||||
|
```go
|
||||||
|
ts.mu.Lock()
|
||||||
|
defer ts.mu.Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration and Environment
|
||||||
|
- **Environment Variables**: Respect `TASKRC` and `TIMEWARRIORDB`
|
||||||
|
- **Fallback Paths**: Check standard locations (`~/.taskrc`, `~/.config/task/taskrc`)
|
||||||
|
- **Config Parsing**: Parse Taskwarrior config format manually (see `taskwarrior/config.go`)
|
||||||
|
|
||||||
|
### MVU Pattern (Bubble Tea)
|
||||||
|
- **Components Implement**: `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() string`
|
||||||
|
- **Custom Messages**: Define custom message types for inter-component communication
|
||||||
|
- **Cmd Chaining**: Return commands from Init/Update to trigger async operations
|
||||||
|
```go
|
||||||
|
type MyMsg struct {
|
||||||
|
data string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case MyMsg:
|
||||||
|
// Handle custom message
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling with Lip Gloss
|
||||||
|
- **Centralized Styles**: Define styles in `common/styles.go`
|
||||||
|
- **Theme Colors**: Parse colors from Taskwarrior config
|
||||||
|
- **Reusable Styles**: Create style functions, not inline styles
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- **Table-Driven Tests**: Use struct slices for test cases
|
||||||
|
- **Test Setup**: Create helper functions like `TaskWarriorTestSetup()`
|
||||||
|
- **Temp Directories**: Use `t.TempDir()` for isolated test environments
|
||||||
|
- **Prep Functions**: Include `prep func()` in test cases for setup
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **TODO Comments**: Mark future improvements with `// TODO: description`
|
||||||
|
- **Package Comments**: Document package purpose at the top of main files
|
||||||
|
- **Exported Functions**: Document exported functions, types, and methods
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Page Navigation
|
||||||
|
- Pages pushed onto stack via `common.PushPage()`
|
||||||
|
- Pop pages with `common.PopPage()`
|
||||||
|
- Check for subpages with `common.HasSubpages()`
|
||||||
|
|
||||||
|
### Task Operations
|
||||||
|
```go
|
||||||
|
// Get tasks for a report
|
||||||
|
tasks := ts.GetTasks(report, "filter", "args")
|
||||||
|
|
||||||
|
// Import/create task
|
||||||
|
ts.ImportTask(&task)
|
||||||
|
|
||||||
|
// Mark task done
|
||||||
|
ts.SetTaskDone(&task)
|
||||||
|
|
||||||
|
// Start/stop task
|
||||||
|
ts.StartTask(&task)
|
||||||
|
ts.StopTask(&task)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Handling
|
||||||
|
- Custom Marshal/Unmarshal for Task struct to handle UDAs (User Defined Attributes)
|
||||||
|
- Use `json.RawMessage` for flexible field handling
|
||||||
|
|
||||||
|
## Key Files to Reference
|
||||||
|
|
||||||
|
- `common/component.go` - Component interface definition
|
||||||
|
- `common/common.go` - Shared state container
|
||||||
|
- `taskwarrior/taskwarrior.go` - TaskWarrior interface and implementation
|
||||||
|
- `pages/main.go` - Main page router pattern
|
||||||
|
- `taskwarrior/models.go` - Data model examples
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- **Logging**: Application logs to `app.log` in current directory
|
||||||
|
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
|
||||||
|
- **Color Parsing**: Custom color parsing from Taskwarrior config format
|
||||||
|
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000
|
||||||
71
GEMINI.md
Normal file
71
GEMINI.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Tasksquire
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Tasksquire is a Terminal User Interface (TUI) for [Taskwarrior](https://taskwarrior.org/), built using Go and the [Charm](https://charm.sh/) ecosystem (Bubble Tea, Lip Gloss, Huh). It provides a visual and interactive way to manage your tasks, contexts, and reports directly from the terminal.
|
||||||
|
|
||||||
|
The application functions as a wrapper around the `task` command-line tool, parsing its output (JSON, config) and executing commands to read and modify task data.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The project follows the standard [Bubble Tea](https://github.com/charmbracelet/bubbletea) Model-View-Update (MVU) architecture.
|
||||||
|
|
||||||
|
### Key Directories & Files
|
||||||
|
|
||||||
|
* **`main.go`**: The entry point of the application. It initializes the `TaskSquire` wrapper, sets up logging, and starts the Bubble Tea program with the `MainPage`.
|
||||||
|
* **`taskwarrior/`**: Contains the logic for interacting with the Taskwarrior CLI.
|
||||||
|
* `taskwarrior.go`: The core wrapper (`TaskSquire` struct) that executes `task` commands (`export`, `add`, `modify`, etc.) and parses results.
|
||||||
|
* `models.go`: Defines the Go structs matching Taskwarrior's data model (Tasks, Reports, Config).
|
||||||
|
* **`pages/`**: Contains the different views of the application.
|
||||||
|
* `main.go`: The top-level component (`MainPage`) that manages routing/switching between different pages.
|
||||||
|
* `report.go`: Displays lists of tasks (Taskwarrior reports).
|
||||||
|
* `taskEditor.go`: UI for creating or editing tasks.
|
||||||
|
* **`common/`**: Shared utilities, global state, and data structures used across the application.
|
||||||
|
* **`components/`**: Reusable UI components (e.g., inputs, tables).
|
||||||
|
* **`timewarrior/`**: Contains logic for integration with Timewarrior (likely in progress or planned).
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
* **Go**: Version 1.22 or higher.
|
||||||
|
* **Taskwarrior**: The `task` binary must be installed and available in your system's `PATH`.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
To run the application directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
To build a binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o tasksquire main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix Support
|
||||||
|
|
||||||
|
This project includes a `flake.nix` for users of the Nix package manager. You can enter a development shell with all dependencies (Go, tools) by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It looks for the configuration file in the following order:
|
||||||
|
|
||||||
|
1. `TASKRC` environment variable.
|
||||||
|
2. `$HOME/.taskrc`
|
||||||
|
3. `$HOME/.config/task/taskrc`
|
||||||
|
|
||||||
|
Logging is written to `app.log` in the current working directory.
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
* **UI Framework**: Uses [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI loop.
|
||||||
|
* **Styling**: Uses [Lip Gloss](https://github.com/charmbracelet/lipgloss) for terminal styling.
|
||||||
|
* **Forms**: Uses [Huh](https://github.com/charmbracelet/huh) for form inputs.
|
||||||
|
* **Logging**: Uses `log/slog` for structured logging.
|
||||||
20
README.md
Normal file
20
README.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# TODO
|
||||||
|
- [>] Add default tags
|
||||||
|
- Default tags should be defined in the config and always displayed in the tag picker
|
||||||
|
- [ ] Add project manager
|
||||||
|
- [ ] Add projects that are always displayed in the project picker
|
||||||
|
- Saved in config
|
||||||
|
- [ ] Remove/archive projects
|
||||||
|
- [ ] Integrate timewarrior
|
||||||
|
- [ ] Add default timetracking items when addind projects
|
||||||
|
- [ ] Create interface for timewarrior input
|
||||||
|
- [ ] Create daily/weekly reports for HRM input
|
||||||
|
- Combine by project
|
||||||
|
- Combine by task names
|
||||||
|
- Combine by tags
|
||||||
|
- [ ] Add tag manager
|
||||||
|
- [ ] Edit default tags
|
||||||
|
- [ ] Update to bubbletea 2.0
|
||||||
|
|
||||||
|
# Done
|
||||||
|
- [x] Use JJ
|
||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
"tasksquire/timewarrior"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
@ -13,20 +14,24 @@ import (
|
|||||||
type Common struct {
|
type Common struct {
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
TW taskwarrior.TaskWarrior
|
TW taskwarrior.TaskWarrior
|
||||||
|
TimeW timewarrior.TimeWarrior
|
||||||
Keymap *Keymap
|
Keymap *Keymap
|
||||||
Styles *Styles
|
Styles *Styles
|
||||||
|
Udas []taskwarrior.Uda
|
||||||
|
|
||||||
pageStack *Stack[Component]
|
pageStack *Stack[Component]
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common {
|
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior, timeW timewarrior.TimeWarrior) *Common {
|
||||||
return &Common{
|
return &Common{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
TW: tw,
|
TW: tw,
|
||||||
|
TimeW: timeW,
|
||||||
Keymap: NewKeymap(),
|
Keymap: NewKeymap(),
|
||||||
Styles: NewStyles(tw.GetConfig()),
|
Styles: NewStyles(tw.GetConfig()),
|
||||||
|
Udas: tw.GetUdas(),
|
||||||
|
|
||||||
pageStack: NewStack[Component](),
|
pageStack: NewStack[Component](),
|
||||||
}
|
}
|
||||||
@ -52,5 +57,15 @@ func (c *Common) PushPage(page Component) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Common) PopPage() (Component, error) {
|
func (c *Common) PopPage() (Component, error) {
|
||||||
return c.pageStack.Pop()
|
component, err := c.pageStack.Pop()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
component.SetSize(c.width, c.height)
|
||||||
|
return component, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Common) HasSubpages() bool {
|
||||||
|
return !c.pageStack.IsEmpty()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,10 @@ type Keymap struct {
|
|||||||
Down key.Binding
|
Down key.Binding
|
||||||
Left key.Binding
|
Left key.Binding
|
||||||
Right key.Binding
|
Right key.Binding
|
||||||
|
Next key.Binding
|
||||||
|
Prev key.Binding
|
||||||
|
NextPage key.Binding
|
||||||
|
PrevPage key.Binding
|
||||||
SetReport key.Binding
|
SetReport key.Binding
|
||||||
SetContext key.Binding
|
SetContext key.Binding
|
||||||
SetProject key.Binding
|
SetProject key.Binding
|
||||||
@ -24,7 +28,9 @@ type Keymap struct {
|
|||||||
Insert key.Binding
|
Insert key.Binding
|
||||||
Tag key.Binding
|
Tag key.Binding
|
||||||
Undo key.Binding
|
Undo key.Binding
|
||||||
|
Fill key.Binding
|
||||||
StartStop key.Binding
|
StartStop key.Binding
|
||||||
|
Join key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use config values for key bindings
|
// TODO: use config values for key bindings
|
||||||
@ -86,6 +92,26 @@ func NewKeymap() *Keymap {
|
|||||||
key.WithHelp("→/l", "Right"),
|
key.WithHelp("→/l", "Right"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Next: key.NewBinding(
|
||||||
|
key.WithKeys("tab"),
|
||||||
|
key.WithHelp("tab", "Next"),
|
||||||
|
),
|
||||||
|
|
||||||
|
Prev: key.NewBinding(
|
||||||
|
key.WithKeys("shift+tab"),
|
||||||
|
key.WithHelp("shift+tab", "Previous"),
|
||||||
|
),
|
||||||
|
|
||||||
|
NextPage: key.NewBinding(
|
||||||
|
key.WithKeys("]"),
|
||||||
|
key.WithHelp("[", "Next page"),
|
||||||
|
),
|
||||||
|
|
||||||
|
PrevPage: key.NewBinding(
|
||||||
|
key.WithKeys("["),
|
||||||
|
key.WithHelp("]", "Previous page"),
|
||||||
|
),
|
||||||
|
|
||||||
SetReport: key.NewBinding(
|
SetReport: key.NewBinding(
|
||||||
key.WithKeys("r"),
|
key.WithKeys("r"),
|
||||||
key.WithHelp("r", "Set report"),
|
key.WithHelp("r", "Set report"),
|
||||||
@ -102,8 +128,8 @@ func NewKeymap() *Keymap {
|
|||||||
),
|
),
|
||||||
|
|
||||||
Select: key.NewBinding(
|
Select: key.NewBinding(
|
||||||
key.WithKeys("enter"),
|
key.WithKeys(" "),
|
||||||
key.WithHelp("enter", "Select"),
|
key.WithHelp("space", "Select"),
|
||||||
),
|
),
|
||||||
|
|
||||||
Insert: key.NewBinding(
|
Insert: key.NewBinding(
|
||||||
@ -121,9 +147,19 @@ func NewKeymap() *Keymap {
|
|||||||
key.WithHelp("undo", "Undo"),
|
key.WithHelp("undo", "Undo"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Fill: key.NewBinding(
|
||||||
|
key.WithKeys("f"),
|
||||||
|
key.WithHelp("fill", "Fill gaps"),
|
||||||
|
),
|
||||||
|
|
||||||
StartStop: key.NewBinding(
|
StartStop: key.NewBinding(
|
||||||
key.WithKeys("s"),
|
key.WithKeys("s"),
|
||||||
key.WithHelp("start/stop", "Start/Stop"),
|
key.WithHelp("start/stop", "Start/Stop"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Join: key.NewBinding(
|
||||||
|
key.WithKeys("J"),
|
||||||
|
key.WithHelp("J", "Join with previous"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,3 +38,10 @@ func (s *Stack[T]) Pop() (T, error) {
|
|||||||
|
|
||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Stack[T]) IsEmpty() bool {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
return len(s.items) == 0
|
||||||
|
}
|
||||||
|
|||||||
205
common/styles.go
205
common/styles.go
@ -20,67 +20,36 @@ type TableStyle struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Styles struct {
|
type Styles struct {
|
||||||
|
Colors map[string]*lipgloss.Style
|
||||||
|
|
||||||
Base lipgloss.Style
|
Base lipgloss.Style
|
||||||
|
|
||||||
Form *huh.Theme
|
Form *huh.Theme
|
||||||
TableStyle TableStyle
|
TableStyle TableStyle
|
||||||
|
|
||||||
|
Tab lipgloss.Style
|
||||||
|
ActiveTab lipgloss.Style
|
||||||
|
TabBar lipgloss.Style
|
||||||
|
|
||||||
ColumnFocused lipgloss.Style
|
ColumnFocused lipgloss.Style
|
||||||
ColumnBlurred lipgloss.Style
|
ColumnBlurred lipgloss.Style
|
||||||
ColumnInsert lipgloss.Style
|
ColumnInsert lipgloss.Style
|
||||||
|
|
||||||
// TODO: make color config completely dynamic to account for keyword., project., tag. and uda. colors
|
|
||||||
Active lipgloss.Style
|
|
||||||
Alternate lipgloss.Style
|
|
||||||
Blocked lipgloss.Style
|
|
||||||
Blocking lipgloss.Style
|
|
||||||
BurndownDone lipgloss.Style
|
|
||||||
BurndownPending lipgloss.Style
|
|
||||||
BurndownStarted lipgloss.Style
|
|
||||||
CalendarDue lipgloss.Style
|
|
||||||
CalendarDueToday lipgloss.Style
|
|
||||||
CalendarHoliday lipgloss.Style
|
|
||||||
CalendarOverdue lipgloss.Style
|
|
||||||
CalendarScheduled lipgloss.Style
|
|
||||||
CalendarToday lipgloss.Style
|
|
||||||
CalendarWeekend lipgloss.Style
|
|
||||||
CalendarWeeknumber lipgloss.Style
|
|
||||||
Completed lipgloss.Style
|
|
||||||
Debug lipgloss.Style
|
|
||||||
Deleted lipgloss.Style
|
|
||||||
Due lipgloss.Style
|
|
||||||
DueToday lipgloss.Style
|
|
||||||
Error lipgloss.Style
|
|
||||||
Footnote lipgloss.Style
|
|
||||||
Header lipgloss.Style
|
|
||||||
HistoryAdd lipgloss.Style
|
|
||||||
HistoryDelete lipgloss.Style
|
|
||||||
HistoryDone lipgloss.Style
|
|
||||||
Label lipgloss.Style
|
|
||||||
LabelSort lipgloss.Style
|
|
||||||
Overdue lipgloss.Style
|
|
||||||
ProjectNone lipgloss.Style
|
|
||||||
Recurring lipgloss.Style
|
|
||||||
Scheduled lipgloss.Style
|
|
||||||
SummaryBackground lipgloss.Style
|
|
||||||
SummaryBar lipgloss.Style
|
|
||||||
SyncAdded lipgloss.Style
|
|
||||||
SyncChanged lipgloss.Style
|
|
||||||
SyncRejected lipgloss.Style
|
|
||||||
TagNext lipgloss.Style
|
|
||||||
TagNone lipgloss.Style
|
|
||||||
Tagged lipgloss.Style
|
|
||||||
UdaPriorityH lipgloss.Style
|
|
||||||
UdaPriorityL lipgloss.Style
|
|
||||||
UdaPriorityM lipgloss.Style
|
|
||||||
UndoAfter lipgloss.Style
|
|
||||||
UndoBefore lipgloss.Style
|
|
||||||
Until lipgloss.Style
|
|
||||||
Warning lipgloss.Style
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
||||||
styles := parseColors(config.GetConfig())
|
styles := Styles{}
|
||||||
|
|
||||||
|
colors := make(map[string]*lipgloss.Style)
|
||||||
|
|
||||||
|
for key, value := range config.GetConfig() {
|
||||||
|
if strings.HasPrefix(key, "color.") {
|
||||||
|
_, color, _ := strings.Cut(key, ".")
|
||||||
|
colors[color] = parseColorString(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
styles.Colors = colors
|
||||||
|
|
||||||
styles.Base = lipgloss.NewStyle()
|
styles.Base = lipgloss.NewStyle()
|
||||||
|
|
||||||
styles.TableStyle = TableStyle{
|
styles.TableStyle = TableStyle{
|
||||||
@ -92,11 +61,12 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|||||||
|
|
||||||
formTheme := huh.ThemeBase()
|
formTheme := huh.ThemeBase()
|
||||||
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
|
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
|
||||||
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("> ")
|
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ")
|
||||||
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
|
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
|
||||||
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("> ")
|
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ")
|
||||||
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ")
|
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ")
|
||||||
formTheme.Focused.UnselectedPrefix = 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.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
||||||
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
||||||
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
||||||
@ -105,127 +75,36 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|||||||
|
|
||||||
styles.Form = formTheme
|
styles.Form = formTheme
|
||||||
|
|
||||||
styles.ColumnFocused = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true)
|
styles.Tab = lipgloss.NewStyle().
|
||||||
styles.ColumnBlurred = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.HiddenBorder(), true)
|
Padding(0, 1).
|
||||||
styles.ColumnInsert = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true).BorderForeground(styles.Active.GetForeground())
|
Foreground(lipgloss.Color("240"))
|
||||||
|
|
||||||
return styles
|
styles.ActiveTab = styles.Tab.
|
||||||
}
|
Foreground(lipgloss.Color("252")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
func parseColors(config map[string]string) *Styles {
|
styles.TabBar = lipgloss.NewStyle().
|
||||||
styles := Styles{}
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||||
|
BorderForeground(lipgloss.Color("240")).
|
||||||
|
MarginBottom(1)
|
||||||
|
|
||||||
for key, value := range config {
|
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||||
if strings.HasPrefix(key, "color.") {
|
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
||||||
_, colorValue, _ := strings.Cut(key, ".")
|
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||||
switch colorValue {
|
if styles.Colors["active"] != nil {
|
||||||
case "active":
|
styles.ColumnInsert = styles.ColumnInsert.BorderForeground(styles.Colors["active"].GetForeground())
|
||||||
styles.Active = parseColorString(value)
|
|
||||||
case "alternate":
|
|
||||||
styles.Alternate = parseColorString(value)
|
|
||||||
case "blocked":
|
|
||||||
styles.Blocked = parseColorString(value)
|
|
||||||
case "blocking":
|
|
||||||
styles.Blocking = parseColorString(value)
|
|
||||||
case "burndown.done":
|
|
||||||
styles.BurndownDone = parseColorString(value)
|
|
||||||
case "burndown.pending":
|
|
||||||
styles.BurndownPending = parseColorString(value)
|
|
||||||
case "burndown.started":
|
|
||||||
styles.BurndownStarted = parseColorString(value)
|
|
||||||
case "calendar.due":
|
|
||||||
styles.CalendarDue = parseColorString(value)
|
|
||||||
case "calendar.due.today":
|
|
||||||
styles.CalendarDueToday = parseColorString(value)
|
|
||||||
case "calendar.holiday":
|
|
||||||
styles.CalendarHoliday = parseColorString(value)
|
|
||||||
case "calendar.overdue":
|
|
||||||
styles.CalendarOverdue = parseColorString(value)
|
|
||||||
case "calendar.scheduled":
|
|
||||||
styles.CalendarScheduled = parseColorString(value)
|
|
||||||
case "calendar.today":
|
|
||||||
styles.CalendarToday = parseColorString(value)
|
|
||||||
case "calendar.weekend":
|
|
||||||
styles.CalendarWeekend = parseColorString(value)
|
|
||||||
case "calendar.weeknumber":
|
|
||||||
styles.CalendarWeeknumber = parseColorString(value)
|
|
||||||
case "completed":
|
|
||||||
styles.Completed = parseColorString(value)
|
|
||||||
case "debug":
|
|
||||||
styles.Debug = parseColorString(value)
|
|
||||||
case "deleted":
|
|
||||||
styles.Deleted = parseColorString(value)
|
|
||||||
case "due":
|
|
||||||
styles.Due = parseColorString(value)
|
|
||||||
case "due.today":
|
|
||||||
styles.DueToday = parseColorString(value)
|
|
||||||
case "error":
|
|
||||||
styles.Error = parseColorString(value)
|
|
||||||
case "footnote":
|
|
||||||
styles.Footnote = parseColorString(value)
|
|
||||||
case "header":
|
|
||||||
styles.Header = parseColorString(value)
|
|
||||||
case "history.add":
|
|
||||||
styles.HistoryAdd = parseColorString(value)
|
|
||||||
case "history.delete":
|
|
||||||
styles.HistoryDelete = parseColorString(value)
|
|
||||||
case "history.done":
|
|
||||||
styles.HistoryDone = parseColorString(value)
|
|
||||||
case "label":
|
|
||||||
styles.Label = parseColorString(value)
|
|
||||||
case "label.sort":
|
|
||||||
styles.LabelSort = parseColorString(value)
|
|
||||||
case "overdue":
|
|
||||||
styles.Overdue = parseColorString(value)
|
|
||||||
case "project.none":
|
|
||||||
styles.ProjectNone = parseColorString(value)
|
|
||||||
case "recurring":
|
|
||||||
styles.Recurring = parseColorString(value)
|
|
||||||
case "scheduled":
|
|
||||||
styles.Scheduled = parseColorString(value)
|
|
||||||
case "summary.background":
|
|
||||||
styles.SummaryBackground = parseColorString(value)
|
|
||||||
case "summary.bar":
|
|
||||||
styles.SummaryBar = parseColorString(value)
|
|
||||||
case "sync.added":
|
|
||||||
styles.SyncAdded = parseColorString(value)
|
|
||||||
case "sync.changed":
|
|
||||||
styles.SyncChanged = parseColorString(value)
|
|
||||||
case "sync.rejected":
|
|
||||||
styles.SyncRejected = parseColorString(value)
|
|
||||||
case "tag.next":
|
|
||||||
styles.TagNext = parseColorString(value)
|
|
||||||
case "tag.none":
|
|
||||||
styles.TagNone = parseColorString(value)
|
|
||||||
case "tagged":
|
|
||||||
styles.Tagged = parseColorString(value)
|
|
||||||
case "uda.priority.H":
|
|
||||||
styles.UdaPriorityH = parseColorString(value)
|
|
||||||
case "uda.priority.L":
|
|
||||||
styles.UdaPriorityL = parseColorString(value)
|
|
||||||
case "uda.priority.M":
|
|
||||||
styles.UdaPriorityM = parseColorString(value)
|
|
||||||
case "undo.after":
|
|
||||||
styles.UndoAfter = parseColorString(value)
|
|
||||||
case "undo.before":
|
|
||||||
styles.UndoBefore = parseColorString(value)
|
|
||||||
case "until":
|
|
||||||
styles.Until = parseColorString(value)
|
|
||||||
case "warning":
|
|
||||||
styles.Warning = parseColorString(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &styles
|
return &styles
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseColorString(color string) lipgloss.Style {
|
func parseColorString(color string) *lipgloss.Style {
|
||||||
style := lipgloss.NewStyle()
|
|
||||||
if color == "" {
|
if color == "" {
|
||||||
return style
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle()
|
||||||
|
|
||||||
if strings.Contains(color, "on") {
|
if strings.Contains(color, "on") {
|
||||||
fgbg := strings.Split(color, "on")
|
fgbg := strings.Split(color, "on")
|
||||||
fg := strings.TrimSpace(fgbg[0])
|
fg := strings.TrimSpace(fgbg[0])
|
||||||
@ -240,7 +119,7 @@ func parseColorString(color string) lipgloss.Style {
|
|||||||
style = style.Foreground(parseColor(strings.TrimSpace(color)))
|
style = style.Foreground(parseColor(strings.TrimSpace(color)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return style
|
return &style
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseColor(color string) lipgloss.Color {
|
func parseColor(color string) lipgloss.Color {
|
||||||
|
|||||||
282
components/autocomplete/autocomplete.go
Normal file
282
components/autocomplete/autocomplete.go
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
672
components/input/multiselect.go
Normal file
672
components/input/multiselect.go
Normal file
@ -0,0 +1,672 @@
|
|||||||
|
package input
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"tasksquire/common"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/huh/accessibility"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MultiSelect is a form multi-select field.
|
||||||
|
type MultiSelect struct {
|
||||||
|
common *common.Common
|
||||||
|
|
||||||
|
value *[]string
|
||||||
|
key string
|
||||||
|
|
||||||
|
// customization
|
||||||
|
title string
|
||||||
|
description string
|
||||||
|
options []Option[string]
|
||||||
|
filterable bool
|
||||||
|
filteredOptions []Option[string]
|
||||||
|
limit int
|
||||||
|
height int
|
||||||
|
|
||||||
|
// error handling
|
||||||
|
validate func([]string) error
|
||||||
|
err error
|
||||||
|
|
||||||
|
// state
|
||||||
|
cursor int
|
||||||
|
focused bool
|
||||||
|
filtering bool
|
||||||
|
filter textinput.Model
|
||||||
|
viewport viewport.Model
|
||||||
|
|
||||||
|
// options
|
||||||
|
width int
|
||||||
|
accessible bool
|
||||||
|
theme *huh.Theme
|
||||||
|
keymap huh.MultiSelectKeyMap
|
||||||
|
|
||||||
|
// new
|
||||||
|
hasNewOption bool
|
||||||
|
newInput textinput.Model
|
||||||
|
newInputActive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMultiSelect returns a new multi-select field.
|
||||||
|
func NewMultiSelect(common *common.Common) *MultiSelect {
|
||||||
|
filter := textinput.New()
|
||||||
|
filter.Prompt = "/"
|
||||||
|
|
||||||
|
newInput := textinput.New()
|
||||||
|
newInput.Prompt = "New: "
|
||||||
|
|
||||||
|
return &MultiSelect{
|
||||||
|
common: common,
|
||||||
|
options: []Option[string]{},
|
||||||
|
value: new([]string),
|
||||||
|
validate: func([]string) error { return nil },
|
||||||
|
filtering: false,
|
||||||
|
filter: filter,
|
||||||
|
newInput: newInput,
|
||||||
|
newInputActive: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value sets the value of the multi-select field.
|
||||||
|
func (m *MultiSelect) Value(value *[]string) *MultiSelect {
|
||||||
|
m.value = value
|
||||||
|
for i, o := range m.options {
|
||||||
|
for _, v := range *value {
|
||||||
|
if o.Value == v {
|
||||||
|
m.options[i].selected = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key sets the key of the select field which can be used to retrieve the value
|
||||||
|
// after submission.
|
||||||
|
func (m *MultiSelect) Key(key string) *MultiSelect {
|
||||||
|
m.key = key
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title sets the title of the multi-select field.
|
||||||
|
func (m *MultiSelect) Title(title string) *MultiSelect {
|
||||||
|
m.title = title
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description sets the description of the multi-select field.
|
||||||
|
func (m *MultiSelect) Description(description string) *MultiSelect {
|
||||||
|
m.description = description
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options sets the options of the multi-select field.
|
||||||
|
func (m *MultiSelect) Options(hasNewOption bool, options ...Option[string]) *MultiSelect {
|
||||||
|
m.hasNewOption = hasNewOption
|
||||||
|
|
||||||
|
if m.hasNewOption {
|
||||||
|
newOption := []Option[string]{
|
||||||
|
{Key: "(new)", Value: ""},
|
||||||
|
}
|
||||||
|
options = append(newOption, options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(options) <= 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, o := range options {
|
||||||
|
for _, v := range *m.value {
|
||||||
|
if o.Value == v {
|
||||||
|
options[i].selected = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.options = options
|
||||||
|
m.filteredOptions = options
|
||||||
|
m.updateViewportHeight()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filterable sets the multi-select field as filterable.
|
||||||
|
func (m *MultiSelect) Filterable(filterable bool) *MultiSelect {
|
||||||
|
m.filterable = filterable
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit sets the limit of the multi-select field.
|
||||||
|
func (m *MultiSelect) Limit(limit int) *MultiSelect {
|
||||||
|
m.limit = limit
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height sets the height of the multi-select field.
|
||||||
|
func (m *MultiSelect) Height(height int) *MultiSelect {
|
||||||
|
// What we really want to do is set the height of the viewport, but we
|
||||||
|
// need a theme applied before we can calcualate its height.
|
||||||
|
m.height = height
|
||||||
|
m.updateViewportHeight()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sets the validation function of the multi-select field.
|
||||||
|
func (m *MultiSelect) Validate(validate func([]string) error) *MultiSelect {
|
||||||
|
m.validate = validate
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the error of the multi-select field.
|
||||||
|
func (m *MultiSelect) Error() error {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip returns whether the multiselect should be skipped or should be blocking.
|
||||||
|
func (*MultiSelect) Skip() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom returns whether the multiselect should be zoomed.
|
||||||
|
func (*MultiSelect) Zoom() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus focuses the multi-select field.
|
||||||
|
func (m *MultiSelect) Focus() tea.Cmd {
|
||||||
|
m.focused = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur blurs the multi-select field.
|
||||||
|
func (m *MultiSelect) Blur() tea.Cmd {
|
||||||
|
m.focused = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyBinds returns the help message for the multi-select field.
|
||||||
|
func (m *MultiSelect) KeyBinds() []key.Binding {
|
||||||
|
return []key.Binding{
|
||||||
|
m.keymap.Toggle,
|
||||||
|
m.keymap.Up,
|
||||||
|
m.keymap.Down,
|
||||||
|
m.keymap.Filter,
|
||||||
|
m.keymap.SetFilter,
|
||||||
|
m.keymap.ClearFilter,
|
||||||
|
m.keymap.Prev,
|
||||||
|
m.keymap.Submit,
|
||||||
|
m.keymap.Next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the multi-select field.
|
||||||
|
func (m *MultiSelect) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates the multi-select field.
|
||||||
|
func (m *MultiSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
// Enforce height on the viewport during update as we need themes to
|
||||||
|
// be applied before we can calculate the height.
|
||||||
|
m.updateViewportHeight()
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if m.filtering {
|
||||||
|
m.filter, cmd = m.filter.Update(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.newInputActive {
|
||||||
|
m.newInput, cmd = m.newInput.Update(msg)
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.common.Keymap.Ok):
|
||||||
|
newOptions := []Option[string]{}
|
||||||
|
for _, item := range strings.Split(m.newInput.Value(), " ") {
|
||||||
|
newOptions = append(newOptions, Option[string]{
|
||||||
|
Key: item,
|
||||||
|
Value: item,
|
||||||
|
selected: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
m.options = append(m.options, newOptions...)
|
||||||
|
filteredNewOptions := []Option[string]{}
|
||||||
|
for _, item := range newOptions {
|
||||||
|
if m.filterFunc(item.Key) {
|
||||||
|
filteredNewOptions = append(filteredNewOptions, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.filteredOptions = append(m.filteredOptions, filteredNewOptions...)
|
||||||
|
m.newInputActive = false
|
||||||
|
m.newInput.SetValue("")
|
||||||
|
m.newInput.Blur()
|
||||||
|
case key.Matches(msg, m.common.Keymap.Back):
|
||||||
|
m.newInputActive = false
|
||||||
|
m.newInput.Blur()
|
||||||
|
return m, SuppressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
|
||||||
|
m.err = nil
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keymap.Filter):
|
||||||
|
m.setFilter(true)
|
||||||
|
return m, m.filter.Focus()
|
||||||
|
case key.Matches(msg, m.keymap.SetFilter) && m.filtering:
|
||||||
|
if len(m.filteredOptions) <= 0 {
|
||||||
|
m.filter.SetValue("")
|
||||||
|
m.filteredOptions = m.options
|
||||||
|
}
|
||||||
|
m.setFilter(false)
|
||||||
|
case key.Matches(msg, m.common.Keymap.Back) && m.filtering:
|
||||||
|
m.filter.SetValue("")
|
||||||
|
m.filteredOptions = m.options
|
||||||
|
m.setFilter(false)
|
||||||
|
case key.Matches(msg, m.keymap.ClearFilter):
|
||||||
|
m.filter.SetValue("")
|
||||||
|
m.filteredOptions = m.options
|
||||||
|
m.setFilter(false)
|
||||||
|
case key.Matches(msg, m.keymap.Up):
|
||||||
|
if m.filtering && msg.String() == "k" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cursor = max(m.cursor-1, 0)
|
||||||
|
if m.cursor < m.viewport.YOffset {
|
||||||
|
m.viewport.SetYOffset(m.cursor)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keymap.Down):
|
||||||
|
if m.filtering && msg.String() == "j" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cursor = min(m.cursor+1, len(m.filteredOptions)-1)
|
||||||
|
if m.cursor >= m.viewport.YOffset+m.viewport.Height {
|
||||||
|
m.viewport.LineDown(1)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keymap.GotoTop):
|
||||||
|
if m.filtering {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
m.cursor = 0
|
||||||
|
m.viewport.GotoTop()
|
||||||
|
case key.Matches(msg, m.keymap.GotoBottom):
|
||||||
|
if m.filtering {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
m.cursor = len(m.filteredOptions) - 1
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
case key.Matches(msg, m.keymap.HalfPageUp):
|
||||||
|
m.cursor = max(m.cursor-m.viewport.Height/2, 0)
|
||||||
|
m.viewport.HalfViewUp()
|
||||||
|
case key.Matches(msg, m.keymap.HalfPageDown):
|
||||||
|
m.cursor = min(m.cursor+m.viewport.Height/2, len(m.filteredOptions)-1)
|
||||||
|
m.viewport.HalfViewDown()
|
||||||
|
case key.Matches(msg, m.keymap.Toggle) && !m.filtering:
|
||||||
|
if m.hasNewOption && m.cursor == 0 {
|
||||||
|
m.newInputActive = true
|
||||||
|
m.newInput.Focus()
|
||||||
|
} else {
|
||||||
|
for i, option := range m.options {
|
||||||
|
if option.Key == m.filteredOptions[m.cursor].Key {
|
||||||
|
if !m.options[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
selected := m.options[i].selected
|
||||||
|
m.options[i].selected = !selected
|
||||||
|
m.filteredOptions[m.cursor].selected = !selected
|
||||||
|
m.finalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keymap.Prev):
|
||||||
|
m.finalize()
|
||||||
|
if m.err != nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, huh.PrevField
|
||||||
|
case key.Matches(msg, m.keymap.Next, m.keymap.Submit):
|
||||||
|
m.finalize()
|
||||||
|
if m.err != nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, huh.NextField
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.filtering {
|
||||||
|
m.filteredOptions = m.options
|
||||||
|
if m.filter.Value() != "" {
|
||||||
|
m.filteredOptions = nil
|
||||||
|
for _, option := range m.options {
|
||||||
|
if m.filterFunc(option.Key) {
|
||||||
|
m.filteredOptions = append(m.filteredOptions, option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.filteredOptions) > 0 {
|
||||||
|
m.cursor = min(m.cursor, len(m.filteredOptions)-1)
|
||||||
|
m.viewport.SetYOffset(clamp(m.cursor, 0, len(m.filteredOptions)-m.viewport.Height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateViewportHeight updates the viewport size according to the Height setting
|
||||||
|
// on this multi-select field.
|
||||||
|
func (m *MultiSelect) updateViewportHeight() {
|
||||||
|
// If no height is set size the viewport to the number of options.
|
||||||
|
if m.height <= 0 {
|
||||||
|
m.viewport.Height = len(m.options)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const minHeight = 1
|
||||||
|
m.viewport.Height = max(minHeight, m.height-
|
||||||
|
lipgloss.Height(m.titleView())-
|
||||||
|
lipgloss.Height(m.descriptionView()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) numSelected() int {
|
||||||
|
var count int
|
||||||
|
for _, o := range m.options {
|
||||||
|
if o.selected {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) finalize() {
|
||||||
|
*m.value = make([]string, 0)
|
||||||
|
for _, option := range m.options {
|
||||||
|
if option.selected {
|
||||||
|
*m.value = append(*m.value, option.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.err = m.validate(*m.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) activeStyles() *huh.FieldStyles {
|
||||||
|
theme := m.theme
|
||||||
|
if theme == nil {
|
||||||
|
theme = huh.ThemeCharm()
|
||||||
|
}
|
||||||
|
if m.focused {
|
||||||
|
return &theme.Focused
|
||||||
|
}
|
||||||
|
return &theme.Blurred
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) titleView() string {
|
||||||
|
if m.title == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
styles = m.activeStyles()
|
||||||
|
sb = strings.Builder{}
|
||||||
|
)
|
||||||
|
if m.filtering {
|
||||||
|
sb.WriteString(m.filter.View())
|
||||||
|
} else if m.filter.Value() != "" {
|
||||||
|
sb.WriteString(styles.Title.Render(m.title) + styles.Description.Render("/"+m.filter.Value()))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(styles.Title.Render(m.title))
|
||||||
|
}
|
||||||
|
if m.err != nil {
|
||||||
|
sb.WriteString(styles.ErrorIndicator.String())
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) descriptionView() string {
|
||||||
|
return m.activeStyles().Description.Render(m.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) choicesView() string {
|
||||||
|
var (
|
||||||
|
styles = m.activeStyles()
|
||||||
|
c = styles.MultiSelectSelector.String()
|
||||||
|
sb strings.Builder
|
||||||
|
)
|
||||||
|
for i, option := range m.filteredOptions {
|
||||||
|
if m.newInputActive && i == 0 {
|
||||||
|
sb.WriteString(c)
|
||||||
|
sb.WriteString(m.newInput.View())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
continue
|
||||||
|
} else if m.cursor == i {
|
||||||
|
sb.WriteString(c)
|
||||||
|
} else {
|
||||||
|
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.filteredOptions[i].selected {
|
||||||
|
sb.WriteString(styles.SelectedPrefix.String())
|
||||||
|
sb.WriteString(styles.SelectedOption.Render(option.Key))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(styles.UnselectedPrefix.String())
|
||||||
|
sb.WriteString(styles.UnselectedOption.Render(option.Key))
|
||||||
|
}
|
||||||
|
if i < len(m.options)-1 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(m.filteredOptions); i < len(m.options)-1; i++ {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the multi-select field.
|
||||||
|
func (m *MultiSelect) View() string {
|
||||||
|
styles := m.activeStyles()
|
||||||
|
m.viewport.SetContent(m.choicesView())
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
if m.title != "" {
|
||||||
|
sb.WriteString(m.titleView())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
if m.description != "" {
|
||||||
|
sb.WriteString(m.descriptionView() + "\n")
|
||||||
|
}
|
||||||
|
sb.WriteString(m.viewport.View())
|
||||||
|
return styles.Base.Render(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) printOptions() {
|
||||||
|
styles := m.activeStyles()
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(styles.Title.Render(m.title))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
for i, option := range m.options {
|
||||||
|
if option.selected {
|
||||||
|
sb.WriteString(styles.SelectedOption.Render(fmt.Sprintf("%d. %s %s", i+1, "✓", option.Key)))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s %s", i+1, " ", option.Key))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFilter sets the filter of the select field.
|
||||||
|
func (m *MultiSelect) setFilter(filter bool) {
|
||||||
|
m.filtering = filter
|
||||||
|
m.keymap.SetFilter.SetEnabled(filter)
|
||||||
|
m.keymap.Filter.SetEnabled(!filter)
|
||||||
|
m.keymap.Next.SetEnabled(!filter)
|
||||||
|
m.keymap.Submit.SetEnabled(!filter)
|
||||||
|
m.keymap.Prev.SetEnabled(!filter)
|
||||||
|
m.keymap.ClearFilter.SetEnabled(!filter && m.filter.Value() != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterFunc returns true if the option matches the filter.
|
||||||
|
func (m *MultiSelect) filterFunc(option string) bool {
|
||||||
|
// XXX: remove diacritics or allow customization of filter function.
|
||||||
|
return strings.Contains(strings.ToLower(option), strings.ToLower(m.filter.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs the multi-select field.
|
||||||
|
func (m *MultiSelect) Run() error {
|
||||||
|
if m.accessible {
|
||||||
|
return m.runAccessible()
|
||||||
|
}
|
||||||
|
return huh.Run(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAccessible() runs the multi-select field in accessible mode.
|
||||||
|
func (m *MultiSelect) runAccessible() error {
|
||||||
|
m.printOptions()
|
||||||
|
styles := m.activeStyles()
|
||||||
|
|
||||||
|
var choice int
|
||||||
|
for {
|
||||||
|
fmt.Printf("Select up to %d options. 0 to continue.\n", m.limit)
|
||||||
|
|
||||||
|
choice = accessibility.PromptInt("Select: ", 0, len(m.options))
|
||||||
|
if choice == 0 {
|
||||||
|
m.finalize()
|
||||||
|
err := m.validate(*m.value)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.options[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit {
|
||||||
|
fmt.Printf("You can't select more than %d options.\n", m.limit)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.options[choice-1].selected = !m.options[choice-1].selected
|
||||||
|
if m.options[choice-1].selected {
|
||||||
|
fmt.Printf("Selected: %s\n\n", m.options[choice-1].Key)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Deselected: %s\n\n", m.options[choice-1].Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.printOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
|
||||||
|
for _, option := range m.options {
|
||||||
|
if option.selected {
|
||||||
|
*m.value = append(*m.value, option.Value)
|
||||||
|
values = append(values, option.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(styles.SelectedOption.Render("Selected:", strings.Join(values, ", ")+"\n"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTheme sets the theme of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithTheme(theme *huh.Theme) huh.Field {
|
||||||
|
if m.theme != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.theme = theme
|
||||||
|
m.filter.Cursor.Style = m.theme.Focused.TextInput.Cursor
|
||||||
|
m.filter.PromptStyle = m.theme.Focused.TextInput.Prompt
|
||||||
|
m.updateViewportHeight()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeyMap sets the keymap of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithKeyMap(k *huh.KeyMap) huh.Field {
|
||||||
|
m.keymap = k.MultiSelect
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAccessible sets the accessible mode of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithAccessible(accessible bool) huh.Field {
|
||||||
|
m.accessible = accessible
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWidth sets the width of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithWidth(width int) huh.Field {
|
||||||
|
m.width = width
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHeight sets the height of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithHeight(height int) huh.Field {
|
||||||
|
m.height = height
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPosition sets the position of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithPosition(p huh.FieldPosition) huh.Field {
|
||||||
|
if m.filtering {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.keymap.Prev.SetEnabled(!p.IsFirst())
|
||||||
|
m.keymap.Next.SetEnabled(!p.IsLast())
|
||||||
|
m.keymap.Submit.SetEnabled(p.IsLast())
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKey returns the multi-select's key.
|
||||||
|
func (m *MultiSelect) GetKey() string {
|
||||||
|
return m.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue returns the multi-select's value.
|
||||||
|
func (m *MultiSelect) GetValue() any {
|
||||||
|
return *m.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFiltering returns true if the multi-select is currently filtering.
|
||||||
|
func (m *MultiSelect) IsFiltering() bool {
|
||||||
|
return m.filtering
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(n, low, high int) int {
|
||||||
|
if low > high {
|
||||||
|
low, high = high, low
|
||||||
|
}
|
||||||
|
return min(high, max(low, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
type SuppressBackMsg struct{}
|
||||||
|
|
||||||
|
func SuppressBack() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return SuppressBackMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
components/input/option.go
Normal file
38
components/input/option.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package input
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Option is an option for select fields.
|
||||||
|
type Option[T comparable] struct {
|
||||||
|
Key string
|
||||||
|
Value T
|
||||||
|
selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOptions returns new options from a list of values.
|
||||||
|
func NewOptions[T comparable](values ...T) []Option[T] {
|
||||||
|
options := make([]Option[T], len(values))
|
||||||
|
for i, o := range values {
|
||||||
|
options[i] = Option[T]{
|
||||||
|
Key: fmt.Sprint(o),
|
||||||
|
Value: o,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOption returns a new select option.
|
||||||
|
func NewOption[T comparable](key string, value T) Option[T] {
|
||||||
|
return Option[T]{Key: key, Value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected sets whether the option is currently selected.
|
||||||
|
func (o Option[T]) Selected(selected bool) Option[T] {
|
||||||
|
o.selected = selected
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the key of the option.
|
||||||
|
func (o Option[T]) String() string {
|
||||||
|
return o.Key
|
||||||
|
}
|
||||||
618
components/input/select.go
Normal file
618
components/input/select.go
Normal file
@ -0,0 +1,618 @@
|
|||||||
|
package input
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"tasksquire/common"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/huh/accessibility"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Select is a form select field.
|
||||||
|
type Select struct {
|
||||||
|
common *common.Common
|
||||||
|
|
||||||
|
value *string
|
||||||
|
key string
|
||||||
|
viewport viewport.Model
|
||||||
|
|
||||||
|
// customization
|
||||||
|
title string
|
||||||
|
description string
|
||||||
|
options []Option[string]
|
||||||
|
filteredOptions []Option[string]
|
||||||
|
height int
|
||||||
|
|
||||||
|
// error handling
|
||||||
|
validate func(string) error
|
||||||
|
err error
|
||||||
|
|
||||||
|
// state
|
||||||
|
selected int
|
||||||
|
focused bool
|
||||||
|
filtering bool
|
||||||
|
filter textinput.Model
|
||||||
|
|
||||||
|
// options
|
||||||
|
inline bool
|
||||||
|
width int
|
||||||
|
accessible bool
|
||||||
|
theme *huh.Theme
|
||||||
|
keymap huh.SelectKeyMap
|
||||||
|
|
||||||
|
// new
|
||||||
|
hasNewOption bool
|
||||||
|
newInput textinput.Model
|
||||||
|
newInputActive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSelect returns a new select field.
|
||||||
|
func NewSelect(com *common.Common) *Select {
|
||||||
|
filter := textinput.New()
|
||||||
|
filter.Prompt = "/"
|
||||||
|
|
||||||
|
newInput := textinput.New()
|
||||||
|
newInput.Prompt = "New: "
|
||||||
|
|
||||||
|
return &Select{
|
||||||
|
common: com,
|
||||||
|
options: []Option[string]{},
|
||||||
|
value: new(string),
|
||||||
|
validate: func(string) error { return nil },
|
||||||
|
filtering: false,
|
||||||
|
filter: filter,
|
||||||
|
newInput: newInput,
|
||||||
|
newInputActive: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value sets the value of the select field.
|
||||||
|
func (s *Select) Value(value *string) *Select {
|
||||||
|
s.value = value
|
||||||
|
s.selectValue(*value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) selectValue(value string) {
|
||||||
|
for i, o := range s.options {
|
||||||
|
if o.Value == value {
|
||||||
|
s.selected = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key sets the key of the select field which can be used to retrieve the value
|
||||||
|
// after submission.
|
||||||
|
func (s *Select) Key(key string) *Select {
|
||||||
|
s.key = key
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title sets the title of the select field.
|
||||||
|
func (s *Select) Title(title string) *Select {
|
||||||
|
s.title = title
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description sets the description of the select field.
|
||||||
|
func (s *Select) Description(description string) *Select {
|
||||||
|
s.description = description
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options sets the options of the select field.
|
||||||
|
func (s *Select) Options(hasNewOption bool, options ...Option[string]) *Select {
|
||||||
|
s.hasNewOption = hasNewOption
|
||||||
|
|
||||||
|
if s.hasNewOption {
|
||||||
|
newOption := []Option[string]{
|
||||||
|
{Key: "(new)", Value: ""},
|
||||||
|
}
|
||||||
|
options = append(newOption, options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(options) <= 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.options = options
|
||||||
|
s.filteredOptions = options
|
||||||
|
|
||||||
|
// Set the cursor to the existing value or the last selected option.
|
||||||
|
for i, option := range options {
|
||||||
|
if option.Value == *s.value {
|
||||||
|
s.selected = i
|
||||||
|
break
|
||||||
|
} else if option.selected {
|
||||||
|
s.selected = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.updateViewportHeight()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline sets whether the select input should be inline.
|
||||||
|
func (s *Select) Inline(v bool) *Select {
|
||||||
|
s.inline = v
|
||||||
|
if v {
|
||||||
|
s.Height(1)
|
||||||
|
}
|
||||||
|
s.keymap.Left.SetEnabled(v)
|
||||||
|
s.keymap.Right.SetEnabled(v)
|
||||||
|
s.keymap.Up.SetEnabled(!v)
|
||||||
|
s.keymap.Down.SetEnabled(!v)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height sets the height of the select field. If the number of options
|
||||||
|
// exceeds the height, the select field will become scrollable.
|
||||||
|
func (s *Select) Height(height int) *Select {
|
||||||
|
s.height = height
|
||||||
|
s.updateViewportHeight()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sets the validation function of the select field.
|
||||||
|
func (s *Select) Validate(validate func(string) error) *Select {
|
||||||
|
s.validate = validate
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the error of the select field.
|
||||||
|
func (s *Select) Error() error {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip returns whether the select should be skipped or should be blocking.
|
||||||
|
func (*Select) Skip() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom returns whether the input should be zoomed.
|
||||||
|
func (*Select) Zoom() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus focuses the select field.
|
||||||
|
func (s *Select) Focus() tea.Cmd {
|
||||||
|
s.focused = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur blurs the select field.
|
||||||
|
func (s *Select) Blur() tea.Cmd {
|
||||||
|
value := *s.value
|
||||||
|
if s.inline {
|
||||||
|
s.clearFilter()
|
||||||
|
s.selectValue(value)
|
||||||
|
}
|
||||||
|
s.focused = false
|
||||||
|
s.err = s.validate(value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyBinds returns the help keybindings for the select field.
|
||||||
|
func (s *Select) KeyBinds() []key.Binding {
|
||||||
|
return []key.Binding{
|
||||||
|
s.keymap.Up,
|
||||||
|
s.keymap.Down,
|
||||||
|
s.keymap.Left,
|
||||||
|
s.keymap.Right,
|
||||||
|
s.keymap.Filter,
|
||||||
|
s.keymap.SetFilter,
|
||||||
|
s.keymap.ClearFilter,
|
||||||
|
s.keymap.Prev,
|
||||||
|
s.keymap.Next,
|
||||||
|
s.keymap.Submit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the select field.
|
||||||
|
func (s *Select) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates the select field.
|
||||||
|
func (s *Select) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
s.updateViewportHeight()
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if s.filtering {
|
||||||
|
s.filter, cmd = s.filter.Update(msg)
|
||||||
|
|
||||||
|
// Keep the selected item in view.
|
||||||
|
if s.selected < s.viewport.YOffset || s.selected >= s.viewport.YOffset+s.viewport.Height {
|
||||||
|
s.viewport.SetYOffset(s.selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.newInputActive {
|
||||||
|
s.newInput, cmd = s.newInput.Update(msg)
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, s.common.Keymap.Ok):
|
||||||
|
if s.newInput.Value() != "" {
|
||||||
|
newOption := Option[string]{
|
||||||
|
Key: s.newInput.Value(),
|
||||||
|
Value: s.newInput.Value(),
|
||||||
|
selected: true,
|
||||||
|
}
|
||||||
|
s.options = append(s.options, newOption)
|
||||||
|
if s.filterFunc(newOption.Key) {
|
||||||
|
s.filteredOptions = append(s.filteredOptions, newOption)
|
||||||
|
}
|
||||||
|
s.selected = len(s.options) - 1
|
||||||
|
|
||||||
|
value := newOption.Value
|
||||||
|
s.setFiltering(false)
|
||||||
|
s.err = s.validate(value)
|
||||||
|
if s.err != nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
*s.value = value
|
||||||
|
}
|
||||||
|
s.newInputActive = false
|
||||||
|
s.newInput.SetValue("")
|
||||||
|
s.newInput.Blur()
|
||||||
|
return s, nil
|
||||||
|
case key.Matches(msg, s.common.Keymap.Back):
|
||||||
|
s.newInputActive = false
|
||||||
|
s.newInput.Blur()
|
||||||
|
return s, SuppressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
s.err = nil
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, s.keymap.Filter):
|
||||||
|
s.setFiltering(true)
|
||||||
|
return s, s.filter.Focus()
|
||||||
|
case key.Matches(msg, s.keymap.SetFilter):
|
||||||
|
if len(s.filteredOptions) <= 0 {
|
||||||
|
s.filter.SetValue("")
|
||||||
|
s.filteredOptions = s.options
|
||||||
|
}
|
||||||
|
s.setFiltering(false)
|
||||||
|
case key.Matches(msg, s.keymap.ClearFilter):
|
||||||
|
s.clearFilter()
|
||||||
|
case key.Matches(msg, s.keymap.Up, s.keymap.Left):
|
||||||
|
// When filtering we should ignore j/k keybindings
|
||||||
|
//
|
||||||
|
// XXX: Currently, the below check doesn't account for keymap
|
||||||
|
// changes. When making this fix it's worth considering ignoring
|
||||||
|
// whether to ignore all up/down keybindings as ignoring a-zA-Z0-9
|
||||||
|
// may not be enough when international keyboards are considered.
|
||||||
|
if s.filtering && (msg.String() == "k" || msg.String() == "h") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.selected = max(s.selected-1, 0)
|
||||||
|
if s.selected < s.viewport.YOffset {
|
||||||
|
s.viewport.SetYOffset(s.selected)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, s.keymap.GotoTop):
|
||||||
|
if s.filtering {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.selected = 0
|
||||||
|
s.viewport.GotoTop()
|
||||||
|
case key.Matches(msg, s.keymap.GotoBottom):
|
||||||
|
if s.filtering {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.selected = len(s.filteredOptions) - 1
|
||||||
|
s.viewport.GotoBottom()
|
||||||
|
case key.Matches(msg, s.keymap.HalfPageUp):
|
||||||
|
s.selected = max(s.selected-s.viewport.Height/2, 0)
|
||||||
|
s.viewport.HalfViewUp()
|
||||||
|
case key.Matches(msg, s.keymap.HalfPageDown):
|
||||||
|
s.selected = min(s.selected+s.viewport.Height/2, len(s.filteredOptions)-1)
|
||||||
|
s.viewport.HalfViewDown()
|
||||||
|
case key.Matches(msg, s.keymap.Down, s.keymap.Right):
|
||||||
|
// When filtering we should ignore j/k keybindings
|
||||||
|
//
|
||||||
|
// XXX: See note in the previous case match.
|
||||||
|
if s.filtering && (msg.String() == "j" || msg.String() == "l") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.selected = min(s.selected+1, len(s.filteredOptions)-1)
|
||||||
|
if s.selected >= s.viewport.YOffset+s.viewport.Height {
|
||||||
|
s.viewport.LineDown(1)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, s.keymap.Prev):
|
||||||
|
if s.selected >= len(s.filteredOptions) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := s.filteredOptions[s.selected].Value
|
||||||
|
s.err = s.validate(value)
|
||||||
|
if s.err != nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
*s.value = value
|
||||||
|
return s, huh.PrevField
|
||||||
|
case key.Matches(msg, s.common.Keymap.Select):
|
||||||
|
if s.hasNewOption && s.selected == 0 {
|
||||||
|
s.newInputActive = true
|
||||||
|
s.newInput.Focus()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
case key.Matches(msg, s.keymap.Next, s.keymap.Submit):
|
||||||
|
if s.selected >= len(s.filteredOptions) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := s.filteredOptions[s.selected].Value
|
||||||
|
s.setFiltering(false)
|
||||||
|
s.err = s.validate(value)
|
||||||
|
if s.err != nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
*s.value = value
|
||||||
|
return s, huh.NextField
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.filtering {
|
||||||
|
s.filteredOptions = s.options
|
||||||
|
if s.filter.Value() != "" {
|
||||||
|
s.filteredOptions = nil
|
||||||
|
for _, option := range s.options {
|
||||||
|
if s.filterFunc(option.Key) {
|
||||||
|
s.filteredOptions = append(s.filteredOptions, option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.filteredOptions) > 0 {
|
||||||
|
s.selected = min(s.selected, len(s.filteredOptions)-1)
|
||||||
|
s.viewport.SetYOffset(clamp(s.selected, 0, len(s.filteredOptions)-s.viewport.Height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateViewportHeight updates the viewport size according to the Height setting
|
||||||
|
// on this select field.
|
||||||
|
func (s *Select) updateViewportHeight() {
|
||||||
|
// If no height is set size the viewport to the number of options.
|
||||||
|
if s.height <= 0 {
|
||||||
|
s.viewport.Height = len(s.options)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const minHeight = 1
|
||||||
|
s.viewport.Height = max(minHeight, s.height-
|
||||||
|
lipgloss.Height(s.titleView())-
|
||||||
|
lipgloss.Height(s.descriptionView()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) activeStyles() *huh.FieldStyles {
|
||||||
|
theme := s.theme
|
||||||
|
if theme == nil {
|
||||||
|
theme = huh.ThemeCharm()
|
||||||
|
}
|
||||||
|
if s.focused {
|
||||||
|
return &theme.Focused
|
||||||
|
}
|
||||||
|
return &theme.Blurred
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) titleView() string {
|
||||||
|
if s.title == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
styles = s.activeStyles()
|
||||||
|
sb = strings.Builder{}
|
||||||
|
)
|
||||||
|
if s.filtering {
|
||||||
|
sb.WriteString(styles.Title.Render(s.filter.View()))
|
||||||
|
} else if s.filter.Value() != "" && !s.inline {
|
||||||
|
sb.WriteString(styles.Title.Render(s.title) + styles.Description.Render("/"+s.filter.Value()))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(styles.Title.Render(s.title))
|
||||||
|
}
|
||||||
|
if s.err != nil {
|
||||||
|
sb.WriteString(styles.ErrorIndicator.String())
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) descriptionView() string {
|
||||||
|
return s.activeStyles().Description.Render(s.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) choicesView() string {
|
||||||
|
var (
|
||||||
|
styles = s.activeStyles()
|
||||||
|
c = styles.SelectSelector.String()
|
||||||
|
sb strings.Builder
|
||||||
|
)
|
||||||
|
|
||||||
|
if s.inline {
|
||||||
|
sb.WriteString(styles.PrevIndicator.Faint(s.selected <= 0).String())
|
||||||
|
if len(s.filteredOptions) > 0 {
|
||||||
|
sb.WriteString(styles.SelectedOption.Render(s.filteredOptions[s.selected].Key))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(styles.TextInput.Placeholder.Render("No matches"))
|
||||||
|
}
|
||||||
|
sb.WriteString(styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String())
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, option := range s.filteredOptions {
|
||||||
|
if s.newInputActive && i == 0 {
|
||||||
|
sb.WriteString(c)
|
||||||
|
sb.WriteString(s.newInput.View())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
continue
|
||||||
|
} else if s.selected == i {
|
||||||
|
sb.WriteString(c + styles.SelectedOption.Render(option.Key))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.Option.Render(option.Key))
|
||||||
|
}
|
||||||
|
if i < len(s.options)-1 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(s.filteredOptions); i < len(s.options)-1; i++ {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the select field.
|
||||||
|
func (s *Select) View() string {
|
||||||
|
styles := s.activeStyles()
|
||||||
|
s.viewport.SetContent(s.choicesView())
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
if s.title != "" {
|
||||||
|
sb.WriteString(s.titleView())
|
||||||
|
if !s.inline {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.description != "" {
|
||||||
|
sb.WriteString(s.descriptionView())
|
||||||
|
if !s.inline {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(s.viewport.View())
|
||||||
|
return styles.Base.Render(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearFilter clears the value of the filter.
|
||||||
|
func (s *Select) clearFilter() {
|
||||||
|
s.filter.SetValue("")
|
||||||
|
s.filteredOptions = s.options
|
||||||
|
s.setFiltering(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFiltering sets the filter of the select field.
|
||||||
|
func (s *Select) setFiltering(filtering bool) {
|
||||||
|
if s.inline && filtering {
|
||||||
|
s.filter.Width = lipgloss.Width(s.titleView()) - 1 - 1
|
||||||
|
}
|
||||||
|
s.filtering = filtering
|
||||||
|
s.keymap.SetFilter.SetEnabled(filtering)
|
||||||
|
s.keymap.Filter.SetEnabled(!filtering)
|
||||||
|
s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterFunc returns true if the option matches the filter.
|
||||||
|
func (s *Select) filterFunc(option string) bool {
|
||||||
|
// XXX: remove diacritics or allow customization of filter function.
|
||||||
|
return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs the select field.
|
||||||
|
func (s *Select) Run() error {
|
||||||
|
if s.accessible {
|
||||||
|
return s.runAccessible()
|
||||||
|
}
|
||||||
|
return huh.Run(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAccessible runs an accessible select field.
|
||||||
|
func (s *Select) runAccessible() error {
|
||||||
|
var sb strings.Builder
|
||||||
|
styles := s.activeStyles()
|
||||||
|
|
||||||
|
sb.WriteString(styles.Title.Render(s.title) + "\n")
|
||||||
|
|
||||||
|
for i, option := range s.options {
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s", i+1, option.Key))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(sb.String())
|
||||||
|
|
||||||
|
for {
|
||||||
|
choice := accessibility.PromptInt("Choose: ", 1, len(s.options))
|
||||||
|
option := s.options[choice-1]
|
||||||
|
if err := s.validate(option.Value); err != nil {
|
||||||
|
fmt.Println(err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Println(styles.SelectedOption.Render("Chose: " + option.Key + "\n"))
|
||||||
|
*s.value = option.Value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTheme sets the theme of the select field.
|
||||||
|
func (s *Select) WithTheme(theme *huh.Theme) huh.Field {
|
||||||
|
if s.theme != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.theme = theme
|
||||||
|
s.filter.Cursor.Style = s.theme.Focused.TextInput.Cursor
|
||||||
|
s.filter.PromptStyle = s.theme.Focused.TextInput.Prompt
|
||||||
|
s.updateViewportHeight()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeyMap sets the keymap on a select field.
|
||||||
|
func (s *Select) WithKeyMap(k *huh.KeyMap) huh.Field {
|
||||||
|
s.keymap = k.Select
|
||||||
|
s.keymap.Left.SetEnabled(s.inline)
|
||||||
|
s.keymap.Right.SetEnabled(s.inline)
|
||||||
|
s.keymap.Up.SetEnabled(!s.inline)
|
||||||
|
s.keymap.Down.SetEnabled(!s.inline)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAccessible sets the accessible mode of the select field.
|
||||||
|
func (s *Select) WithAccessible(accessible bool) huh.Field {
|
||||||
|
s.accessible = accessible
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWidth sets the width of the select field.
|
||||||
|
func (s *Select) WithWidth(width int) huh.Field {
|
||||||
|
s.width = width
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHeight sets the height of the select field.
|
||||||
|
func (s *Select) WithHeight(height int) huh.Field {
|
||||||
|
return s.Height(height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPosition sets the position of the select field.
|
||||||
|
func (s *Select) WithPosition(p huh.FieldPosition) huh.Field {
|
||||||
|
if s.filtering {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.keymap.Prev.SetEnabled(!p.IsFirst())
|
||||||
|
s.keymap.Next.SetEnabled(!p.IsLast())
|
||||||
|
s.keymap.Submit.SetEnabled(p.IsLast())
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKey returns the key of the field.
|
||||||
|
func (s *Select) GetKey() string {
|
||||||
|
return s.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue returns the value of the field.
|
||||||
|
func (s *Select) GetValue() any {
|
||||||
|
return *s.value
|
||||||
|
}
|
||||||
241
components/picker/picker.go
Normal file
241
components/picker/picker.go
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package table
|
package table
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -148,59 +149,89 @@ func (m *Model) parseRowStyles(rows taskwarrior.Tasks) []lipgloss.Style {
|
|||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return styles
|
return styles
|
||||||
}
|
}
|
||||||
|
taskstyle:
|
||||||
for i, task := range rows {
|
for i, task := range rows {
|
||||||
if task.Status == "deleted" {
|
if task.Status == "deleted" {
|
||||||
styles[i] = m.common.Styles.Deleted.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["deleted"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if task.Status == "completed" {
|
if task.Status == "completed" {
|
||||||
styles[i] = m.common.Styles.Completed.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["completed"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if task.Status == "pending" && task.Start != "" {
|
if task.Status == "pending" && task.Start != "" {
|
||||||
styles[i] = m.common.Styles.Active.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// TODO: implement keyword
|
// TODO: implement keyword
|
||||||
// TODO: implement tag
|
// 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
|
// TODO: implement project
|
||||||
if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) {
|
if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) {
|
||||||
styles[i] = m.common.Styles.Overdue.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["overdue"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if task.Scheduled != "" {
|
if task.Scheduled != "" {
|
||||||
styles[i] = m.common.Styles.Scheduled.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["scheduled"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) {
|
if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) {
|
||||||
styles[i] = m.common.Styles.DueToday.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["due.today"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if task.Due != "" {
|
if task.Due != "" {
|
||||||
styles[i] = m.common.Styles.Due.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["due"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if len(task.Depends) > 0 {
|
if len(task.Depends) > 0 {
|
||||||
styles[i] = m.common.Styles.Blocked.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["blocked"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// TODO implement blocking
|
// TODO implement blocking
|
||||||
if task.Recur != "" {
|
if task.Recur != "" {
|
||||||
styles[i] = m.common.Styles.Recurring.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["recurring"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// TODO: make styles optional and discard if empty
|
||||||
if len(task.Tags) > 0 {
|
if len(task.Tags) > 0 {
|
||||||
styles[i] = m.common.Styles.Tagged.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["tagged"]; ok && c != nil {
|
||||||
taskIteration:
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
for _, tag := range task.Tags {
|
|
||||||
if tag == "next" {
|
|
||||||
styles[i] = m.common.Styles.TagNext.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
break taskIteration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// TODO implement uda
|
}
|
||||||
|
if len(m.common.Udas) > 0 {
|
||||||
|
for _, uda := range m.common.Udas {
|
||||||
|
if u, ok := task.Udas[uda.Name]; ok {
|
||||||
|
if c, ok := m.common.Styles.Colors[fmt.Sprintf("uda.%s.%s", uda.Name, u)]; ok {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue taskstyle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
}
|
}
|
||||||
return styles
|
return styles
|
||||||
|
|||||||
409
components/timestampeditor/timestampeditor.go
Normal file
409
components/timestampeditor/timestampeditor.go
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
package timestampeditor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"tasksquire/common"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
timeFormat = "20060102T150405Z" // Timewarrior format
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field represents which field is currently focused
|
||||||
|
type Field int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TimeField Field = iota
|
||||||
|
DateField
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimestampEditor is a component for editing timestamps with separate time and date fields
|
||||||
|
type TimestampEditor struct {
|
||||||
|
common *common.Common
|
||||||
|
|
||||||
|
// Current timestamp value
|
||||||
|
timestamp time.Time
|
||||||
|
isEmpty bool // Track if timestamp is unset
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
focused bool
|
||||||
|
currentField Field
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
|
||||||
|
// Title and description
|
||||||
|
title string
|
||||||
|
description string
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
validate func(time.Time) error
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new TimestampEditor with no initial timestamp
|
||||||
|
func New(com *common.Common) *TimestampEditor {
|
||||||
|
return &TimestampEditor{
|
||||||
|
common: com,
|
||||||
|
timestamp: time.Time{}, // Zero time
|
||||||
|
isEmpty: true, // Start empty
|
||||||
|
focused: false,
|
||||||
|
currentField: TimeField,
|
||||||
|
validate: func(time.Time) error { return nil },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title sets the title of the timestamp editor
|
||||||
|
func (t *TimestampEditor) Title(title string) *TimestampEditor {
|
||||||
|
t.title = title
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description sets the description of the timestamp editor
|
||||||
|
func (t *TimestampEditor) Description(description string) *TimestampEditor {
|
||||||
|
t.description = description
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value sets the initial timestamp value
|
||||||
|
func (t *TimestampEditor) Value(timestamp time.Time) *TimestampEditor {
|
||||||
|
t.timestamp = timestamp
|
||||||
|
t.isEmpty = timestamp.IsZero()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValueFromString sets the initial timestamp from a timewarrior format string
|
||||||
|
func (t *TimestampEditor) ValueFromString(s string) *TimestampEditor {
|
||||||
|
if s == "" {
|
||||||
|
t.timestamp = time.Time{}
|
||||||
|
t.isEmpty = true
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.Parse(timeFormat, s)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse timestamp", "error", err)
|
||||||
|
t.timestamp = time.Time{}
|
||||||
|
t.isEmpty = true
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
t.timestamp = parsed.Local()
|
||||||
|
t.isEmpty = false
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue returns the current timestamp
|
||||||
|
func (t *TimestampEditor) GetValue() time.Time {
|
||||||
|
return t.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValueString returns the timestamp in timewarrior format, or empty string if unset
|
||||||
|
func (t *TimestampEditor) GetValueString() string {
|
||||||
|
if t.isEmpty {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.timestamp.UTC().Format(timeFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sets the validation function
|
||||||
|
func (t *TimestampEditor) Validate(validate func(time.Time) error) *TimestampEditor {
|
||||||
|
t.validate = validate
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the validation error
|
||||||
|
func (t *TimestampEditor) Error() error {
|
||||||
|
return t.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus focuses the timestamp editor
|
||||||
|
func (t *TimestampEditor) Focus() tea.Cmd {
|
||||||
|
t.focused = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur blurs the timestamp editor
|
||||||
|
func (t *TimestampEditor) Blur() tea.Cmd {
|
||||||
|
t.focused = false
|
||||||
|
t.err = t.validate(t.timestamp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSize sets the size of the timestamp editor
|
||||||
|
func (t *TimestampEditor) SetSize(width, height int) {
|
||||||
|
t.width = width
|
||||||
|
t.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the timestamp editor
|
||||||
|
func (t *TimestampEditor) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the timestamp editor
|
||||||
|
func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !t.focused {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
t.err = nil
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
// Navigation between fields
|
||||||
|
case "h", "left":
|
||||||
|
t.currentField = TimeField
|
||||||
|
case "l", "right":
|
||||||
|
t.currentField = DateField
|
||||||
|
|
||||||
|
// Time field adjustments (lowercase - 5 minutes)
|
||||||
|
case "j":
|
||||||
|
// Set current time on first edit if empty
|
||||||
|
if t.isEmpty {
|
||||||
|
t.setCurrentTime()
|
||||||
|
}
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
t.adjustTime(-5)
|
||||||
|
} else {
|
||||||
|
t.adjustDate(-1)
|
||||||
|
}
|
||||||
|
case "k":
|
||||||
|
// Set current time on first edit if empty
|
||||||
|
if t.isEmpty {
|
||||||
|
t.setCurrentTime()
|
||||||
|
}
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
t.adjustTime(5)
|
||||||
|
} else {
|
||||||
|
t.adjustDate(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time field adjustments (uppercase - 30 minutes) or date adjustments (week)
|
||||||
|
case "J":
|
||||||
|
// Set current time on first edit if empty
|
||||||
|
if t.isEmpty {
|
||||||
|
t.setCurrentTime()
|
||||||
|
}
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
t.adjustTime(-30)
|
||||||
|
} else {
|
||||||
|
t.adjustDate(-7)
|
||||||
|
}
|
||||||
|
case "K":
|
||||||
|
// Set current time on first edit if empty
|
||||||
|
if t.isEmpty {
|
||||||
|
t.setCurrentTime()
|
||||||
|
}
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
t.adjustTime(30)
|
||||||
|
} else {
|
||||||
|
t.adjustDate(7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove timestamp
|
||||||
|
case "d":
|
||||||
|
t.timestamp = time.Time{}
|
||||||
|
t.isEmpty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCurrentTime sets the timestamp to the current time and marks it as not empty
|
||||||
|
func (t *TimestampEditor) setCurrentTime() {
|
||||||
|
now := time.Now()
|
||||||
|
// Snap to nearest 5 minutes
|
||||||
|
minute := now.Minute()
|
||||||
|
remainder := minute % 5
|
||||||
|
if remainder != 0 {
|
||||||
|
if remainder < 3 {
|
||||||
|
// Round down
|
||||||
|
now = now.Add(-time.Duration(remainder) * time.Minute)
|
||||||
|
} else {
|
||||||
|
// Round up
|
||||||
|
now = now.Add(time.Duration(5-remainder) * time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Zero out seconds and nanoseconds
|
||||||
|
t.timestamp = time.Date(
|
||||||
|
now.Year(),
|
||||||
|
now.Month(),
|
||||||
|
now.Day(),
|
||||||
|
now.Hour(),
|
||||||
|
now.Minute(),
|
||||||
|
0, 0,
|
||||||
|
now.Location(),
|
||||||
|
)
|
||||||
|
t.isEmpty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjustTime adjusts the time by the given number of minutes and snaps to nearest 5 minutes
|
||||||
|
func (t *TimestampEditor) adjustTime(minutes int) {
|
||||||
|
// Add the minutes
|
||||||
|
t.timestamp = t.timestamp.Add(time.Duration(minutes) * time.Minute)
|
||||||
|
|
||||||
|
// Snap to nearest 5 minutes
|
||||||
|
minute := t.timestamp.Minute()
|
||||||
|
remainder := minute % 5
|
||||||
|
if remainder != 0 {
|
||||||
|
if remainder < 3 {
|
||||||
|
// Round down
|
||||||
|
t.timestamp = t.timestamp.Add(-time.Duration(remainder) * time.Minute)
|
||||||
|
} else {
|
||||||
|
// Round up
|
||||||
|
t.timestamp = t.timestamp.Add(time.Duration(5-remainder) * time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero out seconds and nanoseconds
|
||||||
|
t.timestamp = time.Date(
|
||||||
|
t.timestamp.Year(),
|
||||||
|
t.timestamp.Month(),
|
||||||
|
t.timestamp.Day(),
|
||||||
|
t.timestamp.Hour(),
|
||||||
|
t.timestamp.Minute(),
|
||||||
|
0, 0,
|
||||||
|
t.timestamp.Location(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjustDate adjusts the date by the given number of days
|
||||||
|
func (t *TimestampEditor) adjustDate(days int) {
|
||||||
|
t.timestamp = t.timestamp.AddDate(0, 0, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the timestamp editor
|
||||||
|
func (t *TimestampEditor) View() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
styles := t.getStyles()
|
||||||
|
|
||||||
|
// Render title if present
|
||||||
|
if t.title != "" {
|
||||||
|
sb.WriteString(styles.title.Render(t.title))
|
||||||
|
if t.err != nil {
|
||||||
|
sb.WriteString(styles.errorIndicator.String())
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render description if present
|
||||||
|
if t.description != "" {
|
||||||
|
sb.WriteString(styles.description.Render(t.description))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the time and date fields side by side
|
||||||
|
var timeStr, dateStr string
|
||||||
|
if t.isEmpty {
|
||||||
|
timeStr = "--:--"
|
||||||
|
dateStr = "--- ----------"
|
||||||
|
} else {
|
||||||
|
timeStr = t.timestamp.Format("15:04")
|
||||||
|
dateStr = t.timestamp.Format("Mon 2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeField, dateField string
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
timeField = styles.selectedField.Render(timeStr)
|
||||||
|
dateField = styles.unselectedField.Render(dateStr)
|
||||||
|
} else {
|
||||||
|
timeField = styles.unselectedField.Render(timeStr)
|
||||||
|
dateField = styles.selectedField.Render(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeField, " ", dateField)
|
||||||
|
sb.WriteString(fieldsRow)
|
||||||
|
|
||||||
|
return styles.base.Render(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHelpText returns the help text based on the current field
|
||||||
|
func (t *TimestampEditor) getHelpText() string {
|
||||||
|
if t.currentField == TimeField {
|
||||||
|
return "h/l: switch field • j/k: ±5min • J/K: ±30min • d: remove"
|
||||||
|
}
|
||||||
|
return "h/l: switch field • j/k: ±1day • J/K: ±1week • d: remove"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles for the timestamp editor
|
||||||
|
type timestampEditorStyles struct {
|
||||||
|
base lipgloss.Style
|
||||||
|
title lipgloss.Style
|
||||||
|
description lipgloss.Style
|
||||||
|
errorIndicator lipgloss.Style
|
||||||
|
selectedField lipgloss.Style
|
||||||
|
unselectedField lipgloss.Style
|
||||||
|
help lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStyles returns the styles for the timestamp editor
|
||||||
|
func (t *TimestampEditor) getStyles() timestampEditorStyles {
|
||||||
|
theme := t.common.Styles.Form
|
||||||
|
var styles timestampEditorStyles
|
||||||
|
|
||||||
|
if t.focused {
|
||||||
|
styles.base = lipgloss.NewStyle()
|
||||||
|
styles.title = theme.Focused.Title
|
||||||
|
styles.description = theme.Focused.Description
|
||||||
|
styles.errorIndicator = theme.Focused.ErrorIndicator
|
||||||
|
styles.selectedField = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Padding(0, 2).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("12"))
|
||||||
|
styles.unselectedField = lipgloss.NewStyle().
|
||||||
|
Padding(0, 2).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("8"))
|
||||||
|
styles.help = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("8")).
|
||||||
|
Italic(true)
|
||||||
|
} else {
|
||||||
|
styles.base = lipgloss.NewStyle()
|
||||||
|
styles.title = theme.Blurred.Title
|
||||||
|
styles.description = theme.Blurred.Description
|
||||||
|
styles.errorIndicator = theme.Blurred.ErrorIndicator
|
||||||
|
styles.selectedField = lipgloss.NewStyle().
|
||||||
|
Padding(0, 2).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("8"))
|
||||||
|
styles.unselectedField = lipgloss.NewStyle().
|
||||||
|
Padding(0, 2).
|
||||||
|
Border(lipgloss.HiddenBorder())
|
||||||
|
styles.help = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip returns whether the timestamp editor should be skipped
|
||||||
|
func (t *TimestampEditor) Skip() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom returns whether the timestamp editor should be zoomed
|
||||||
|
func (t *TimestampEditor) Zoom() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyBinds returns the key bindings for the timestamp editor
|
||||||
|
func (t *TimestampEditor) KeyBinds() []key.Binding {
|
||||||
|
return []key.Binding{
|
||||||
|
t.common.Keymap.Left,
|
||||||
|
t.common.Keymap.Right,
|
||||||
|
t.common.Keymap.Up,
|
||||||
|
t.common.Keymap.Down,
|
||||||
|
}
|
||||||
|
}
|
||||||
582
components/timetable/table.go
Normal file
582
components/timetable/table.go
Normal file
@ -0,0 +1,582 @@
|
|||||||
|
package timetable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"tasksquire/common"
|
||||||
|
"tasksquire/timewarrior"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model defines a state for the table widget.
|
||||||
|
type Model struct {
|
||||||
|
common *common.Common
|
||||||
|
KeyMap KeyMap
|
||||||
|
|
||||||
|
cols []Column
|
||||||
|
rows timewarrior.Intervals
|
||||||
|
rowStyles []lipgloss.Style
|
||||||
|
cursor int
|
||||||
|
focus bool
|
||||||
|
styles common.TableStyle
|
||||||
|
styleFunc StyleFunc
|
||||||
|
|
||||||
|
viewport viewport.Model
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row represents one line in the table.
|
||||||
|
type Row *timewarrior.Interval
|
||||||
|
|
||||||
|
// Column defines the table structure.
|
||||||
|
type Column struct {
|
||||||
|
Title string
|
||||||
|
Name string
|
||||||
|
Width int
|
||||||
|
MaxWidth int
|
||||||
|
ContentWidth int
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
|
||||||
|
// is used to render the menu.
|
||||||
|
type KeyMap struct {
|
||||||
|
LineUp key.Binding
|
||||||
|
LineDown key.Binding
|
||||||
|
PageUp key.Binding
|
||||||
|
PageDown key.Binding
|
||||||
|
HalfPageUp key.Binding
|
||||||
|
HalfPageDown key.Binding
|
||||||
|
GotoTop key.Binding
|
||||||
|
GotoBottom key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp implements the KeyMap interface.
|
||||||
|
func (km KeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{km.LineUp, km.LineDown}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp implements the KeyMap interface.
|
||||||
|
func (km KeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
|
||||||
|
{km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultKeyMap returns a default set of keybindings.
|
||||||
|
func DefaultKeyMap() KeyMap {
|
||||||
|
const spacebar = " "
|
||||||
|
return KeyMap{
|
||||||
|
LineUp: key.NewBinding(
|
||||||
|
key.WithKeys("up", "k"),
|
||||||
|
key.WithHelp("↑/k", "up"),
|
||||||
|
),
|
||||||
|
LineDown: key.NewBinding(
|
||||||
|
key.WithKeys("down", "j"),
|
||||||
|
key.WithHelp("↓/j", "down"),
|
||||||
|
),
|
||||||
|
PageUp: key.NewBinding(
|
||||||
|
key.WithKeys("b", "pgup"),
|
||||||
|
key.WithHelp("b/pgup", "page up"),
|
||||||
|
),
|
||||||
|
PageDown: key.NewBinding(
|
||||||
|
key.WithKeys("f", "pgdown", spacebar),
|
||||||
|
key.WithHelp("f/pgdn", "page down"),
|
||||||
|
),
|
||||||
|
HalfPageUp: key.NewBinding(
|
||||||
|
key.WithKeys("u", "ctrl+u"),
|
||||||
|
key.WithHelp("u", "½ page up"),
|
||||||
|
),
|
||||||
|
HalfPageDown: key.NewBinding(
|
||||||
|
key.WithKeys("d", "ctrl+d"),
|
||||||
|
key.WithHelp("d", "½ page down"),
|
||||||
|
),
|
||||||
|
GotoTop: key.NewBinding(
|
||||||
|
key.WithKeys("home", "g"),
|
||||||
|
key.WithHelp("g/home", "go to start"),
|
||||||
|
),
|
||||||
|
GotoBottom: key.NewBinding(
|
||||||
|
key.WithKeys("end", "G"),
|
||||||
|
key.WithHelp("G/end", "go to end"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStyles sets the table styles.
|
||||||
|
func (m *Model) SetStyles(s common.TableStyle) {
|
||||||
|
m.styles = s
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option is used to set options in New. For example:
|
||||||
|
//
|
||||||
|
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
|
||||||
|
type Option func(*Model)
|
||||||
|
|
||||||
|
// New creates a new model for the table widget.
|
||||||
|
func New(com *common.Common, opts ...Option) Model {
|
||||||
|
m := Model{
|
||||||
|
common: com,
|
||||||
|
cursor: 0,
|
||||||
|
viewport: viewport.New(0, 20),
|
||||||
|
|
||||||
|
KeyMap: DefaultKeyMap(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&m)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cols = m.parseColumns(m.cols)
|
||||||
|
m.rowStyles = m.parseRowStyles(m.rows)
|
||||||
|
|
||||||
|
m.UpdateViewport()
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) parseRowStyles(rows timewarrior.Intervals) []lipgloss.Style {
|
||||||
|
styles := make([]lipgloss.Style, len(rows))
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return styles
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
// Default style
|
||||||
|
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
|
||||||
|
// If active, maybe highlight?
|
||||||
|
if rows[i].IsActive() {
|
||||||
|
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return styles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) parseColumns(cols []Column) []Column {
|
||||||
|
if len(cols) == 0 {
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, col := range cols {
|
||||||
|
for _, interval := range m.rows {
|
||||||
|
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(interval.GetString(col.Name)))
|
||||||
|
}
|
||||||
|
cols[i] = col
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedSize := 0
|
||||||
|
nonZeroWidths := 0
|
||||||
|
tagIndex := -1
|
||||||
|
for i, col := range cols {
|
||||||
|
if col.ContentWidth > 0 {
|
||||||
|
col.Width = max(col.ContentWidth, lipgloss.Width(col.Title))
|
||||||
|
nonZeroWidths++
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(col.Name, "tags") {
|
||||||
|
combinedSize += col.Width
|
||||||
|
} else {
|
||||||
|
tagIndex = i
|
||||||
|
}
|
||||||
|
|
||||||
|
cols[i] = col
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagIndex >= 0 {
|
||||||
|
cols[tagIndex].Width = m.Width() - combinedSize - nonZeroWidths
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithColumns sets the table columns (headers).
|
||||||
|
func WithColumns(cols []Column) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.cols = cols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRows sets the table rows (data).
|
||||||
|
func WithIntervals(rows timewarrior.Intervals) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.rows = rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHeight sets the height of the table.
|
||||||
|
func WithHeight(h int) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.viewport.Height = h - lipgloss.Height(m.headersView())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWidth sets the width of the table.
|
||||||
|
func WithWidth(w int) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.viewport.Width = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFocused sets the focus state of the table.
|
||||||
|
func WithFocused(f bool) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.focus = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStyles sets the table styles.
|
||||||
|
func WithStyles(s common.TableStyle) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.styles = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state.
|
||||||
|
func WithStyleFunc(f StyleFunc) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.styleFunc = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeyMap sets the key map.
|
||||||
|
func WithKeyMap(km KeyMap) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.KeyMap = km
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update is the Bubble Tea update loop.
|
||||||
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
if !m.focus {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.KeyMap.LineUp):
|
||||||
|
m.MoveUp(1)
|
||||||
|
case key.Matches(msg, m.KeyMap.LineDown):
|
||||||
|
m.MoveDown(1)
|
||||||
|
case key.Matches(msg, m.KeyMap.PageUp):
|
||||||
|
m.MoveUp(m.viewport.Height)
|
||||||
|
case key.Matches(msg, m.KeyMap.PageDown):
|
||||||
|
m.MoveDown(m.viewport.Height)
|
||||||
|
case key.Matches(msg, m.KeyMap.HalfPageUp):
|
||||||
|
m.MoveUp(m.viewport.Height / 2)
|
||||||
|
case key.Matches(msg, m.KeyMap.HalfPageDown):
|
||||||
|
m.MoveDown(m.viewport.Height / 2)
|
||||||
|
case key.Matches(msg, m.KeyMap.LineDown):
|
||||||
|
m.MoveDown(1)
|
||||||
|
case key.Matches(msg, m.KeyMap.GotoTop):
|
||||||
|
m.GotoTop()
|
||||||
|
case key.Matches(msg, m.KeyMap.GotoBottom):
|
||||||
|
m.GotoBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focused returns the focus state of the table.
|
||||||
|
func (m Model) Focused() bool {
|
||||||
|
return m.focus
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus focuses the table, allowing the user to move around the rows and
|
||||||
|
// interact.
|
||||||
|
func (m *Model) Focus() {
|
||||||
|
m.focus = true
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur blurs the table, preventing selection or movement.
|
||||||
|
func (m *Model) Blur() {
|
||||||
|
m.focus = false
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the component.
|
||||||
|
func (m Model) View() string {
|
||||||
|
return m.headersView() + "\n" + m.viewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateViewport updates the list content based on the previously defined
|
||||||
|
// columns and rows.
|
||||||
|
func (m *Model) UpdateViewport() {
|
||||||
|
renderedRows := make([]string, 0, len(m.rows))
|
||||||
|
|
||||||
|
if m.cursor >= 0 {
|
||||||
|
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)
|
||||||
|
} else {
|
||||||
|
m.start = 0
|
||||||
|
}
|
||||||
|
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))
|
||||||
|
for i := m.start; i < m.end; i++ {
|
||||||
|
renderedRows = append(renderedRows, m.renderRow(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
m.viewport.SetContent(
|
||||||
|
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedRow returns the selected row.
|
||||||
|
// Returns nil if cursor is on a gap row or out of bounds.
|
||||||
|
func (m Model) SelectedRow() Row {
|
||||||
|
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't return gap rows as selected
|
||||||
|
if m.rows[m.cursor].IsGap {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.rows[m.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows returns the current rows.
|
||||||
|
func (m Model) Rows() timewarrior.Intervals {
|
||||||
|
return m.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns the current columns.
|
||||||
|
func (m Model) Columns() []Column {
|
||||||
|
return m.cols
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRows sets a new rows state.
|
||||||
|
func (m *Model) SetRows(r timewarrior.Intervals) {
|
||||||
|
m.rows = r
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetColumns sets a new columns state.
|
||||||
|
func (m *Model) SetColumns(c []Column) {
|
||||||
|
m.cols = c
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWidth sets the width of the viewport of the table.
|
||||||
|
func (m *Model) SetWidth(w int) {
|
||||||
|
m.viewport.Width = w
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHeight sets the height of the viewport of the table.
|
||||||
|
func (m *Model) SetHeight(h int) {
|
||||||
|
m.viewport.Height = h - lipgloss.Height(m.headersView())
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height returns the viewport height of the table.
|
||||||
|
func (m Model) Height() int {
|
||||||
|
return m.viewport.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width returns the viewport width of the table.
|
||||||
|
func (m Model) Width() int {
|
||||||
|
return m.viewport.Width
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor returns the index of the selected row.
|
||||||
|
func (m Model) Cursor() int {
|
||||||
|
return m.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCursor sets the cursor position in the table.
|
||||||
|
// Skips gap rows by moving to the nearest non-gap row.
|
||||||
|
func (m *Model) SetCursor(n int) {
|
||||||
|
m.cursor = clamp(n, 0, len(m.rows)-1)
|
||||||
|
|
||||||
|
// Skip gap rows - try moving down first, then up
|
||||||
|
if m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
|
||||||
|
// Try moving down to find non-gap
|
||||||
|
found := false
|
||||||
|
for i := m.cursor; i < len(m.rows); i++ {
|
||||||
|
if !m.rows[i].IsGap {
|
||||||
|
m.cursor = i
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not found down, try moving up
|
||||||
|
if !found {
|
||||||
|
for i := m.cursor; i >= 0; i-- {
|
||||||
|
if !m.rows[i].IsGap {
|
||||||
|
m.cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveUp moves the selection up by any number of rows.
|
||||||
|
// It can not go above the first row. Skips gap rows.
|
||||||
|
func (m *Model) MoveUp(n int) {
|
||||||
|
originalCursor := m.cursor
|
||||||
|
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
||||||
|
|
||||||
|
// Skip gap rows
|
||||||
|
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we went past the beginning, find the first non-gap row
|
||||||
|
if m.cursor < 0 {
|
||||||
|
for i := 0; i < len(m.rows); i++ {
|
||||||
|
if !m.rows[i].IsGap {
|
||||||
|
m.cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no non-gap row found, restore original cursor
|
||||||
|
if m.cursor < 0 || (m.cursor < len(m.rows) && m.rows[m.cursor].IsGap) {
|
||||||
|
m.cursor = originalCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case m.start == 0:
|
||||||
|
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
|
||||||
|
case m.start < m.viewport.Height:
|
||||||
|
m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height))
|
||||||
|
case m.viewport.YOffset >= 1:
|
||||||
|
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)
|
||||||
|
}
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDown moves the selection down by any number of rows.
|
||||||
|
// It can not go below the last row. Skips gap rows.
|
||||||
|
func (m *Model) MoveDown(n int) {
|
||||||
|
originalCursor := m.cursor
|
||||||
|
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
|
||||||
|
|
||||||
|
// Skip gap rows
|
||||||
|
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we went past the end, find the last non-gap row
|
||||||
|
if m.cursor >= len(m.rows) {
|
||||||
|
for i := len(m.rows) - 1; i >= 0; i-- {
|
||||||
|
if !m.rows[i].IsGap {
|
||||||
|
m.cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no non-gap row found, restore original cursor
|
||||||
|
if m.cursor >= len(m.rows) || (m.cursor >= 0 && m.rows[m.cursor].IsGap) {
|
||||||
|
m.cursor = originalCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
m.UpdateViewport()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case m.end == len(m.rows) && m.viewport.YOffset > 0:
|
||||||
|
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))
|
||||||
|
case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0:
|
||||||
|
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))
|
||||||
|
case m.viewport.YOffset > 1:
|
||||||
|
case m.cursor > m.viewport.YOffset+m.viewport.Height-1:
|
||||||
|
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GotoTop moves the selection to the first row.
|
||||||
|
func (m *Model) GotoTop() {
|
||||||
|
m.MoveUp(m.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GotoBottom moves the selection to the last row.
|
||||||
|
func (m *Model) GotoBottom() {
|
||||||
|
m.MoveDown(len(m.rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index.
|
||||||
|
type StyleFunc func(row, col int, value string) lipgloss.Style
|
||||||
|
|
||||||
|
func (m Model) headersView() string {
|
||||||
|
var s = make([]string, 0, len(m.cols))
|
||||||
|
for _, col := range m.cols {
|
||||||
|
if col.Width <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
|
||||||
|
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
|
||||||
|
s = append(s, m.styles.Header.Render(renderedCell))
|
||||||
|
}
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderRow(r int) string {
|
||||||
|
// Special rendering for gap rows
|
||||||
|
if m.rows[r].IsGap {
|
||||||
|
gapText := m.rows[r].GetString("gap_display")
|
||||||
|
gapStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("240")).
|
||||||
|
Align(lipgloss.Center).
|
||||||
|
Width(m.Width())
|
||||||
|
return gapStyle.Render(gapText)
|
||||||
|
}
|
||||||
|
|
||||||
|
var s = make([]string, 0, len(m.cols))
|
||||||
|
for i, col := range m.cols {
|
||||||
|
if m.cols[i].Width <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var cellStyle lipgloss.Style
|
||||||
|
cellStyle = m.rowStyles[r]
|
||||||
|
if r == m.cursor {
|
||||||
|
cellStyle = cellStyle.Inherit(m.styles.Selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
|
||||||
|
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…")))
|
||||||
|
s = append(s, renderedCell)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
|
||||||
|
|
||||||
|
if r == m.cursor {
|
||||||
|
return m.styles.Selected.Render(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v, low, high int) int {
|
||||||
|
return min(max(v, low), high)
|
||||||
|
}
|
||||||
23
go.mod
23
go.mod
@ -4,18 +4,24 @@ go 1.22.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbles v0.18.0
|
github.com/charmbracelet/bubbles v0.18.0
|
||||||
github.com/charmbracelet/bubbletea v0.26.1
|
github.com/charmbracelet/bubbletea v0.26.4
|
||||||
github.com/charmbracelet/huh v0.3.0
|
github.com/charmbracelet/huh v0.4.2
|
||||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb
|
github.com/charmbracelet/lipgloss v0.11.0
|
||||||
github.com/mattn/go-runewidth v0.0.15
|
github.com/mattn/go-runewidth v0.0.15
|
||||||
golang.org/x/term v0.20.0
|
golang.org/x/term v0.21.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/catppuccin/go v0.2.0 // indirect
|
github.com/catppuccin/go v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 // indirect
|
github.com/charmbracelet/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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@ -25,8 +31,9 @@ require (
|
|||||||
github.com/muesli/reflow v0.3.0 // indirect
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
github.com/muesli/termenv v0.15.2 // indirect
|
github.com/muesli/termenv v0.15.2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // 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/sync v0.7.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.21.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
golang.org/x/text v0.16.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
48
go.sum
48
go.sum
@ -6,14 +6,26 @@ github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
|||||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
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 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
||||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
||||||
github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0=
|
github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40=
|
||||||
github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo=
|
github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0=
|
||||||
github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE=
|
github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM=
|
||||||
github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA=
|
github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0=
|
||||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb h1:Hs3xzxHuruNT2Iuo87iS40c0PhLqpnUKBI6Xw6Ad3wQ=
|
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
|
||||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs=
|
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 h1:+0U9qX8Pv8KiYgRxfBvORRjgBzLgHMjtElP4O0PyKYA=
|
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c=
|
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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
@ -39,15 +51,19 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
|||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
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 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
|
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
|||||||
30
main.go
30
main.go
@ -10,14 +10,40 @@ import (
|
|||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
"tasksquire/pages"
|
"tasksquire/pages"
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
"tasksquire/timewarrior"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ts := taskwarrior.NewTaskSquire("./test/taskrc")
|
var taskrcPath string
|
||||||
|
if taskrcEnv := os.Getenv("TASKRC"); taskrcEnv != "" {
|
||||||
|
taskrcPath = taskrcEnv
|
||||||
|
} else if _, err := os.Stat(os.Getenv("HOME") + "/.taskrc"); err == nil {
|
||||||
|
taskrcPath = os.Getenv("HOME") + "/.taskrc"
|
||||||
|
} else if _, err := os.Stat(os.Getenv("HOME") + "/.config/task/taskrc"); err == nil {
|
||||||
|
taskrcPath = os.Getenv("HOME") + "/.config/task/taskrc"
|
||||||
|
} else {
|
||||||
|
log.Fatal("Unable to find taskrc file")
|
||||||
|
}
|
||||||
|
|
||||||
|
var timewConfigPath string
|
||||||
|
if timewConfigEnv := os.Getenv("TIMEWARRIORDB"); timewConfigEnv != "" {
|
||||||
|
timewConfigPath = timewConfigEnv
|
||||||
|
} else if _, err := os.Stat(os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg"); err == nil {
|
||||||
|
timewConfigPath = os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg"
|
||||||
|
} else {
|
||||||
|
// Default to empty string if not found, let TimeSquire handle defaults or errors if necessary
|
||||||
|
// But TimeSquire seems to only take config location.
|
||||||
|
// Let's assume standard location if not found or pass empty if it auto-detects.
|
||||||
|
// Checking TimeSquire implementation: it calls `timew show` which usually works if timew is in path.
|
||||||
|
timewConfigPath = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := taskwarrior.NewTaskSquire(taskrcPath)
|
||||||
|
tws := timewarrior.NewTimeSquire(timewConfigPath)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
common := common.NewCommon(ctx, ts)
|
common := common.NewCommon(ctx, ts, tws)
|
||||||
|
|
||||||
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -4,17 +4,19 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
|
"tasksquire/components/picker"
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextPickerPage struct {
|
type ContextPickerPage struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
contexts taskwarrior.Contexts
|
contexts taskwarrior.Contexts
|
||||||
form *huh.Form
|
picker *picker.Picker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
||||||
@ -24,8 +26,11 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selected := common.TW.GetActiveContext().Name
|
selected := common.TW.GetActiveContext().Name
|
||||||
|
|
||||||
|
itemProvider := func() []list.Item {
|
||||||
|
contexts := common.TW.GetContexts()
|
||||||
options := make([]string, 0)
|
options := make([]string, 0)
|
||||||
for _, c := range p.contexts {
|
for _, c := range contexts {
|
||||||
if c.Name != "none" {
|
if c.Name != "none" {
|
||||||
options = append(options, c.Name)
|
options = append(options, c.Name)
|
||||||
}
|
}
|
||||||
@ -33,38 +38,75 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
|||||||
slices.Sort(options)
|
slices.Sort(options)
|
||||||
options = append([]string{"(none)"}, options...)
|
options = append([]string{"(none)"}, options...)
|
||||||
|
|
||||||
p.form = huh.NewForm(
|
items := []list.Item{}
|
||||||
huh.NewGroup(
|
for _, opt := range options {
|
||||||
huh.NewSelect[string]().
|
items = append(items, picker.NewItem(opt))
|
||||||
Key("context").
|
}
|
||||||
Options(huh.NewOptions(options...)...).
|
return items
|
||||||
Title("Contexts").
|
}
|
||||||
Description("Choose a context").
|
|
||||||
Value(&selected),
|
onSelect := func(item list.Item) tea.Cmd {
|
||||||
),
|
return func() tea.Msg { return contextSelectedMsg{item: item} }
|
||||||
).
|
}
|
||||||
WithShowHelp(false).
|
|
||||||
WithShowErrors(true).
|
p.picker = picker.New(common, "Contexts", itemProvider, onSelect)
|
||||||
WithTheme(p.common.Styles.Form)
|
|
||||||
|
// Set active context
|
||||||
|
if selected == "" {
|
||||||
|
selected = "(none)"
|
||||||
|
}
|
||||||
|
p.picker.SelectItemByFilterValue(selected)
|
||||||
|
|
||||||
|
p.SetSize(common.Width(), common.Height())
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) SetSize(width, height int) {
|
func (p *ContextPickerPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
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 {
|
func (p *ContextPickerPage) Init() tea.Cmd {
|
||||||
return p.form.Init()
|
return p.picker.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextSelectedMsg struct {
|
||||||
|
item list.Item
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
p.SetSize(msg.Width, msg.Height)
|
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:
|
case tea.KeyMsg:
|
||||||
|
if !p.picker.IsFiltering() {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
case key.Matches(msg, p.common.Keymap.Back):
|
||||||
model, err := p.common.PopPage()
|
model, err := p.common.PopPage()
|
||||||
@ -75,36 +117,28 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return model, BackCmd
|
return model, BackCmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
f, cmd := p.form.Update(msg)
|
|
||||||
if f, ok := f.(*huh.Form); ok {
|
|
||||||
p.form = f
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.form.State == huh.StateCompleted {
|
_, cmd = p.picker.Update(msg)
|
||||||
cmds = append(cmds, p.updateContextCmd)
|
return p, cmd
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) View() string {
|
func (p *ContextPickerPage) View() string {
|
||||||
return p.common.Styles.Base.Render(p.form.View())
|
width := p.common.Width() - 4
|
||||||
|
if width > 40 {
|
||||||
|
width = 40
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) updateContextCmd() tea.Msg {
|
content := p.picker.View()
|
||||||
context := p.form.GetString("context")
|
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
||||||
if context == "(none)" {
|
|
||||||
context = ""
|
return lipgloss.Place(
|
||||||
}
|
p.common.Width(),
|
||||||
return UpdateContextMsg(p.common.TW.GetContext(context))
|
p.common.Height(),
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
p.common.Styles.Base.Render(styledContent),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateContextMsg *taskwarrior.Context
|
type UpdateContextMsg *taskwarrior.Context
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
|
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MainPage struct {
|
type MainPage struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
activePage common.Component
|
activePage common.Component
|
||||||
|
|
||||||
|
taskPage common.Component
|
||||||
|
timePage common.Component
|
||||||
|
currentTab int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMainPage(common *common.Common) *MainPage {
|
func NewMainPage(common *common.Common) *MainPage {
|
||||||
@ -16,15 +24,17 @@ func NewMainPage(common *common.Common) *MainPage {
|
|||||||
common: common,
|
common: common,
|
||||||
}
|
}
|
||||||
|
|
||||||
m.activePage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
|
m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
|
||||||
// m.activePage = NewTaskEditorPage(common, taskwarrior.Task{})
|
m.timePage = NewTimePage(common)
|
||||||
|
|
||||||
|
m.activePage = m.taskPage
|
||||||
|
m.currentTab = 0
|
||||||
|
|
||||||
return m
|
return m
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MainPage) Init() tea.Cmd {
|
func (m *MainPage) Init() tea.Cmd {
|
||||||
return m.activePage.Init()
|
return tea.Batch(m.taskPage.Init(), m.timePage.Init())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@ -32,7 +42,43 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
m.common.SetSize(msg.Width, 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)
|
activePage, cmd := m.activePage.Update(msg)
|
||||||
@ -41,6 +87,22 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MainPage) View() string {
|
func (m *MainPage) renderTabBar() string {
|
||||||
return m.activePage.View()
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import tea "github.com/charmbracelet/bubbletea"
|
import (
|
||||||
|
"tasksquire/taskwarrior"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
type UpdatedTasksMsg struct{}
|
type UpdatedTasksMsg struct{}
|
||||||
|
|
||||||
@ -52,9 +57,9 @@ func prevArea() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type changeAreaMsg area
|
type changeAreaMsg int
|
||||||
|
|
||||||
func changeArea(a area) tea.Cmd {
|
func changeArea(a int) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return changeAreaMsg(a)
|
return changeAreaMsg(a)
|
||||||
}
|
}
|
||||||
@ -67,3 +72,13 @@ func changeMode(mode mode) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -3,15 +3,17 @@ package pages
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
|
"tasksquire/components/picker"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectPickerPage struct {
|
type ProjectPickerPage struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
form *huh.Form
|
picker *picker.Picker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage {
|
func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage {
|
||||||
@ -19,48 +21,80 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
|
|||||||
common: common,
|
common: common,
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected string
|
itemProvider := func() []list.Item {
|
||||||
if activeProject == "" {
|
projects := common.TW.GetProjects()
|
||||||
selected = "(none)"
|
items := []list.Item{picker.NewItem("(none)")}
|
||||||
} else {
|
for _, proj := range projects {
|
||||||
selected = activeProject
|
items = append(items, picker.NewItem(proj))
|
||||||
|
}
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
projects := common.TW.GetProjects()
|
onSelect := func(item list.Item) tea.Cmd {
|
||||||
options := []string{"(none)"}
|
return func() tea.Msg { return projectSelectedMsg{item: item} }
|
||||||
options = append(options, projects...)
|
}
|
||||||
|
|
||||||
p.form = huh.NewForm(
|
// onCreate := func(name string) tea.Cmd {
|
||||||
huh.NewGroup(
|
// return func() tea.Msg { return projectSelectedMsg{item: picker.NewItem(name)} }
|
||||||
huh.NewSelect[string]().
|
// }
|
||||||
Key("project").
|
|
||||||
Options(huh.NewOptions(options...)...).
|
// p.picker = picker.New(common, "Projects", itemProvider, onSelect, picker.WithOnCreate(onCreate))
|
||||||
Title("Projects").
|
p.picker = picker.New(common, "Projects", itemProvider, onSelect)
|
||||||
Description("Choose a project").
|
|
||||||
Value(&selected),
|
// Set active project
|
||||||
),
|
if activeProject == "" {
|
||||||
).
|
activeProject = "(none)"
|
||||||
WithShowHelp(false).
|
}
|
||||||
WithShowErrors(false)
|
p.picker.SelectItemByFilterValue(activeProject)
|
||||||
|
|
||||||
|
p.SetSize(common.Width(), common.Height())
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) SetSize(width, height int) {
|
func (p *ProjectPickerPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
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 {
|
func (p *ProjectPickerPage) Init() tea.Cmd {
|
||||||
return p.form.Init()
|
return p.picker.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectSelectedMsg struct {
|
||||||
|
item list.Item
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
p.SetSize(msg.Width, msg.Height)
|
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:
|
case tea.KeyMsg:
|
||||||
|
if !p.picker.IsFiltering() {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
case key.Matches(msg, p.common.Keymap.Back):
|
||||||
model, err := p.common.PopPage()
|
model, err := p.common.PopPage()
|
||||||
@ -71,36 +105,32 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return model, BackCmd
|
return model, BackCmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
f, cmd := p.form.Update(msg)
|
|
||||||
if f, ok := f.(*huh.Form); ok {
|
|
||||||
p.form = f
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.form.State == huh.StateCompleted {
|
_, cmd = p.picker.Update(msg)
|
||||||
cmds = append(cmds, p.updateProjectCmd)
|
return p, cmd
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) View() string {
|
func (p *ProjectPickerPage) View() string {
|
||||||
return p.common.Styles.Base.Render(p.form.View())
|
width := p.common.Width() - 4
|
||||||
|
if width > 40 {
|
||||||
|
width = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
content := p.picker.View()
|
||||||
|
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
||||||
|
|
||||||
|
return lipgloss.Place(
|
||||||
|
p.common.Width(),
|
||||||
|
p.common.Height(),
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
p.common.Styles.Base.Render(styledContent),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
|
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
|
||||||
project := p.form.GetString("project")
|
return nil
|
||||||
if project == "(none)" {
|
|
||||||
project = ""
|
|
||||||
}
|
|
||||||
return UpdateProjectMsg(project)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProjectMsg string
|
type UpdateProjectMsg string
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
// "github.com/charmbracelet/bubbles/table"
|
// "github.com/charmbracelet/bubbles/table"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
@ -24,7 +25,7 @@ type ReportPage struct {
|
|||||||
|
|
||||||
taskTable table.Model
|
taskTable table.Model
|
||||||
|
|
||||||
subpage tea.Model
|
subpage common.Component
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
||||||
@ -44,22 +45,18 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
|||||||
taskTable: table.New(com),
|
taskTable: table.New(com),
|
||||||
}
|
}
|
||||||
|
|
||||||
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
|
|
||||||
p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPage) SetSize(width int, height int) {
|
func (p *ReportPage) SetSize(width int, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
p.taskTable.SetWidth(width - p.common.Styles.Base.GetVerticalFrameSize())
|
p.taskTable.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
|
||||||
p.taskTable.SetHeight(height - p.common.Styles.Base.GetHorizontalFrameSize())
|
p.taskTable.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPage) Init() tea.Cmd {
|
func (p *ReportPage) Init() tea.Cmd {
|
||||||
return p.getTasks()
|
return tea.Batch(p.getTasks(), doTick())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@ -68,7 +65,11 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
p.SetSize(msg.Width, msg.Height)
|
p.SetSize(msg.Width, msg.Height)
|
||||||
// case BackMsg:
|
// case BackMsg:
|
||||||
case TaskMsg:
|
case tickMsg:
|
||||||
|
cmds = append(cmds, p.getTasks())
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
case taskMsg:
|
||||||
p.tasks = taskwarrior.Tasks(msg)
|
p.tasks = taskwarrior.Tasks(msg)
|
||||||
p.populateTaskTable(p.tasks)
|
p.populateTaskTable(p.tasks)
|
||||||
case UpdateReportMsg:
|
case UpdateReportMsg:
|
||||||
@ -89,24 +90,24 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return p, tea.Quit
|
return p, tea.Quit
|
||||||
case key.Matches(msg, p.common.Keymap.SetReport):
|
case key.Matches(msg, p.common.Keymap.SetReport):
|
||||||
p.subpage = NewReportPickerPage(p.common, p.activeReport)
|
p.subpage = NewReportPickerPage(p.common, p.activeReport)
|
||||||
p.subpage.Init()
|
cmd := p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, nil
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.SetContext):
|
case key.Matches(msg, p.common.Keymap.SetContext):
|
||||||
p.subpage = NewContextPickerPage(p.common)
|
p.subpage = NewContextPickerPage(p.common)
|
||||||
p.subpage.Init()
|
cmd := p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, nil
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.Add):
|
case key.Matches(msg, p.common.Keymap.Add):
|
||||||
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
|
p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
|
||||||
p.subpage.Init()
|
cmd := p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, nil
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.Edit):
|
case key.Matches(msg, p.common.Keymap.Edit):
|
||||||
p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
|
p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
|
||||||
p.subpage.Init()
|
cmd := p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, nil
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
p.common.TW.SetTaskDone(p.selectedTask)
|
p.common.TW.SetTaskDone(p.selectedTask)
|
||||||
return p, p.getTasks()
|
return p, p.getTasks()
|
||||||
@ -115,9 +116,9 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return p, p.getTasks()
|
return p, p.getTasks()
|
||||||
case key.Matches(msg, p.common.Keymap.SetProject):
|
case key.Matches(msg, p.common.Keymap.SetProject):
|
||||||
p.subpage = NewProjectPickerPage(p.common, p.activeProject)
|
p.subpage = NewProjectPickerPage(p.common, p.activeProject)
|
||||||
p.subpage.Init()
|
cmd := p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, nil
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.Tag):
|
case key.Matches(msg, p.common.Keymap.Tag):
|
||||||
if p.selectedTask != nil {
|
if p.selectedTask != nil {
|
||||||
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
||||||
@ -211,8 +212,6 @@ func (p *ReportPage) getTasks() tea.Cmd {
|
|||||||
filters = append(filters, "project:"+p.activeProject)
|
filters = append(filters, "project:"+p.activeProject)
|
||||||
}
|
}
|
||||||
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
|
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
|
||||||
return TaskMsg(tasks)
|
return taskMsg(tasks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskMsg taskwarrior.Tasks
|
|
||||||
|
|||||||
@ -4,17 +4,19 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
|
"tasksquire/components/picker"
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReportPickerPage struct {
|
type ReportPickerPage struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
reports taskwarrior.Reports
|
reports taskwarrior.Reports
|
||||||
form *huh.Form
|
picker *picker.Picker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage {
|
func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage {
|
||||||
@ -23,45 +25,76 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
|
|||||||
reports: common.TW.GetReports(),
|
reports: common.TW.GetReports(),
|
||||||
}
|
}
|
||||||
|
|
||||||
selected := activeReport.Name
|
itemProvider := func() []list.Item {
|
||||||
|
|
||||||
options := make([]string, 0)
|
options := make([]string, 0)
|
||||||
for _, r := range p.reports {
|
for _, r := range p.reports {
|
||||||
options = append(options, r.Name)
|
options = append(options, r.Name)
|
||||||
}
|
}
|
||||||
slices.Sort(options)
|
slices.Sort(options)
|
||||||
|
|
||||||
p.form = huh.NewForm(
|
items := []list.Item{}
|
||||||
huh.NewGroup(
|
for _, opt := range options {
|
||||||
huh.NewSelect[string]().
|
items = append(items, picker.NewItem(opt))
|
||||||
Key("report").
|
}
|
||||||
Options(huh.NewOptions(options...)...).
|
return items
|
||||||
Title("Reports").
|
}
|
||||||
Description("Choose a report").
|
|
||||||
Value(&selected),
|
onSelect := func(item list.Item) tea.Cmd {
|
||||||
),
|
return func() tea.Msg { return reportSelectedMsg{item: item} }
|
||||||
).
|
}
|
||||||
WithShowHelp(false).
|
|
||||||
WithShowErrors(false)
|
p.picker = picker.New(common, "Reports", itemProvider, onSelect)
|
||||||
|
|
||||||
|
if activeReport != nil {
|
||||||
|
p.picker.SelectItemByFilterValue(activeReport.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.SetSize(common.Width(), common.Height())
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) SetSize(width, height int) {
|
func (p *ReportPickerPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
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 {
|
func (p *ReportPickerPage) Init() tea.Cmd {
|
||||||
return p.form.Init()
|
return p.picker.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
type reportSelectedMsg struct {
|
||||||
|
item list.Item
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
p.SetSize(msg.Width, msg.Height)
|
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:
|
case tea.KeyMsg:
|
||||||
|
if !p.picker.IsFiltering() {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
case key.Matches(msg, p.common.Keymap.Back):
|
||||||
model, err := p.common.PopPage()
|
model, err := p.common.PopPage()
|
||||||
@ -72,32 +105,28 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return model, BackCmd
|
return model, BackCmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
f, cmd := p.form.Update(msg)
|
|
||||||
if f, ok := f.(*huh.Form); ok {
|
|
||||||
p.form = f
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.form.State == huh.StateCompleted {
|
_, cmd = p.picker.Update(msg)
|
||||||
cmds = append(cmds, []tea.Cmd{BackCmd, p.updateReportCmd}...)
|
return p, cmd
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) View() string {
|
func (p *ReportPickerPage) View() string {
|
||||||
return p.common.Styles.Base.Render(p.form.View())
|
width := p.common.Width() - 4
|
||||||
|
if width > 40 {
|
||||||
|
width = 40
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) updateReportCmd() tea.Msg {
|
content := p.picker.View()
|
||||||
return UpdateReportMsg(p.common.TW.GetReport(p.form.GetString("report")))
|
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
|
type UpdateReportMsg *taskwarrior.Report
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
300
pages/timeEditor.go
Normal file
300
pages/timeEditor.go
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
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, " ")
|
||||||
|
}
|
||||||
421
pages/timePage.go
Normal file
421
pages/timePage.go
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
pages/timePage_test.go
Normal file
146
pages/timePage_test.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tasksquire/timewarrior"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInsertGaps(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
intervals timewarrior.Intervals
|
||||||
|
expectedCount int
|
||||||
|
expectedGaps int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty intervals",
|
||||||
|
intervals: timewarrior.Intervals{},
|
||||||
|
expectedCount: 0,
|
||||||
|
expectedGaps: 0,
|
||||||
|
description: "Should return empty list for empty input",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single interval",
|
||||||
|
intervals: timewarrior.Intervals{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedCount: 1,
|
||||||
|
expectedGaps: 0,
|
||||||
|
description: "Should return single interval without gaps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two intervals with gap (reverse chronological)",
|
||||||
|
intervals: timewarrior.Intervals{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedCount: 3,
|
||||||
|
expectedGaps: 1,
|
||||||
|
description: "Should insert one gap between two intervals (newest first order)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three intervals with two gaps (reverse chronological)",
|
||||||
|
intervals: timewarrior.Intervals{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Start: time.Now().Add(-5 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().Add(-4 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedCount: 5,
|
||||||
|
expectedGaps: 2,
|
||||||
|
description: "Should insert two gaps between three intervals (newest first order)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consecutive intervals with no gap (reverse chronological)",
|
||||||
|
intervals: timewarrior.Intervals{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Start: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedCount: 2,
|
||||||
|
expectedGaps: 0,
|
||||||
|
description: "Should not insert gap when intervals are consecutive (newest first order)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := insertGaps(tt.intervals)
|
||||||
|
|
||||||
|
if len(result) != tt.expectedCount {
|
||||||
|
t.Errorf("insertGaps() returned %d intervals, expected %d. %s",
|
||||||
|
len(result), tt.expectedCount, tt.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
gapCount := 0
|
||||||
|
for _, interval := range result {
|
||||||
|
if interval.IsGap {
|
||||||
|
gapCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gapCount != tt.expectedGaps {
|
||||||
|
t.Errorf("insertGaps() created %d gaps, expected %d. %s",
|
||||||
|
gapCount, tt.expectedGaps, tt.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify gaps are properly interleaved with intervals
|
||||||
|
for i := 0; i < len(result)-1; i++ {
|
||||||
|
if result[i].IsGap && result[i+1].IsGap {
|
||||||
|
t.Errorf("insertGaps() created consecutive gap rows at indices %d and %d", i, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify first and last items are never gaps
|
||||||
|
if len(result) > 0 {
|
||||||
|
if result[0].IsGap {
|
||||||
|
t.Errorf("insertGaps() created gap as first item")
|
||||||
|
}
|
||||||
|
if result[len(result)-1].IsGap {
|
||||||
|
t.Errorf("insertGaps() created gap as last item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
128
pages/timespanPicker.go
Normal file
128
pages/timespanPicker.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"tasksquire/common"
|
||||||
|
"tasksquire/components/picker"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimespanPickerPage struct {
|
||||||
|
common *common.Common
|
||||||
|
picker *picker.Picker
|
||||||
|
selectedTimespan string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTimespanPickerPage(common *common.Common, currentTimespan string) *TimespanPickerPage {
|
||||||
|
p := &TimespanPickerPage{
|
||||||
|
common: common,
|
||||||
|
selectedTimespan: currentTimespan,
|
||||||
|
}
|
||||||
|
|
||||||
|
timespanOptions := []list.Item{
|
||||||
|
picker.NewItem(":day"),
|
||||||
|
picker.NewItem(":yesterday"),
|
||||||
|
picker.NewItem(":week"),
|
||||||
|
picker.NewItem(":lastweek"),
|
||||||
|
picker.NewItem(":month"),
|
||||||
|
picker.NewItem(":lastmonth"),
|
||||||
|
picker.NewItem(":year"),
|
||||||
|
}
|
||||||
|
|
||||||
|
itemProvider := func() []list.Item {
|
||||||
|
return timespanOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect := func(item list.Item) tea.Cmd {
|
||||||
|
return func() tea.Msg { return timespanSelectedMsg{item: item} }
|
||||||
|
}
|
||||||
|
|
||||||
|
p.picker = picker.New(common, "Select Timespan", itemProvider, onSelect)
|
||||||
|
|
||||||
|
// Select the current timespan in the picker
|
||||||
|
p.picker.SelectItemByFilterValue(currentTimespan)
|
||||||
|
|
||||||
|
p.SetSize(common.Width(), common.Height())
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimespanPickerPage) SetSize(width, height int) {
|
||||||
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
|
// Set list size with some padding/limits to look like a picker
|
||||||
|
listWidth := width - 4
|
||||||
|
if listWidth > 40 {
|
||||||
|
listWidth = 40
|
||||||
|
}
|
||||||
|
listHeight := height - 6
|
||||||
|
if listHeight > 20 {
|
||||||
|
listHeight = 20
|
||||||
|
}
|
||||||
|
p.picker.SetSize(listWidth, listHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimespanPickerPage) Init() tea.Cmd {
|
||||||
|
return p.picker.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
type timespanSelectedMsg struct {
|
||||||
|
item list.Item
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimespanPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
p.SetSize(msg.Width, msg.Height)
|
||||||
|
case timespanSelectedMsg:
|
||||||
|
timespan := msg.item.FilterValue()
|
||||||
|
|
||||||
|
model, err := p.common.PopPage()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("page stack empty")
|
||||||
|
return nil, tea.Quit
|
||||||
|
}
|
||||||
|
return model, func() tea.Msg { return UpdateTimespanMsg(timespan) }
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if !p.picker.IsFiltering() {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, p.common.Keymap.Back):
|
||||||
|
model, err := p.common.PopPage()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("page stack empty")
|
||||||
|
return nil, tea.Quit
|
||||||
|
}
|
||||||
|
return model, BackCmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, cmd = p.picker.Update(msg)
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimespanPickerPage) View() string {
|
||||||
|
width := p.common.Width() - 4
|
||||||
|
if width > 40 {
|
||||||
|
width = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
content := p.picker.View()
|
||||||
|
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
||||||
|
|
||||||
|
return lipgloss.Place(
|
||||||
|
p.common.Width(),
|
||||||
|
p.common.Height(),
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
p.common.Styles.Base.Render(styledContent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateTimespanMsg string
|
||||||
@ -14,7 +14,8 @@ var (
|
|||||||
defaultConfig = map[string]string{
|
defaultConfig = map[string]string{
|
||||||
"uda.tasksquire.report.default": "next",
|
"uda.tasksquire.report.default": "next",
|
||||||
"uda.tasksquire.tag.default": "next",
|
"uda.tasksquire.tag.default": "next",
|
||||||
"uda.tasksquire.tags.default": "low_energy,customer,delegate,code,communication,research",
|
"uda.tasksquire.tags.default": "mngmnt,ops,low_energy,cust,delegate,code,comm,research",
|
||||||
|
"uda.tasksquire.picker.filter_by_default": "yes",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package taskwarrior
|
package taskwarrior
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
@ -13,6 +14,23 @@ const (
|
|||||||
dtformat = "20060102T150405Z"
|
dtformat = "20060102T150405Z"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type UdaType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UdaTypeString UdaType = "string"
|
||||||
|
UdaTypeDate UdaType = "date"
|
||||||
|
UdaTypeNumeric UdaType = "numeric"
|
||||||
|
UdaTypeDuration UdaType = "duration"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Uda struct {
|
||||||
|
Name string
|
||||||
|
Type UdaType
|
||||||
|
Label string
|
||||||
|
Values []string
|
||||||
|
Default string
|
||||||
|
}
|
||||||
|
|
||||||
type Annotation struct {
|
type Annotation struct {
|
||||||
Entry string `json:"entry,omitempty"`
|
Entry string `json:"entry,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
@ -22,12 +40,14 @@ func (a Annotation) String() string {
|
|||||||
return fmt.Sprintf("%s %s", a.Entry, a.Description)
|
return fmt.Sprintf("%s %s", a.Entry, a.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tasks []*Task
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
Id int64 `json:"id,omitempty"`
|
Id int64 `json:"id,omitempty"`
|
||||||
Uuid string `json:"uuid,omitempty"`
|
Uuid string `json:"uuid,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Project string `json:"project"`
|
Project string `json:"project"`
|
||||||
Priority string `json:"priority"`
|
// Priority string `json:"priority"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
VirtualTags []string `json:"-"`
|
VirtualTags []string `json:"-"`
|
||||||
@ -45,6 +65,16 @@ type Task struct {
|
|||||||
Modified string `json:"modified,omitempty"`
|
Modified string `json:"modified,omitempty"`
|
||||||
Recur string `json:"recur,omitempty"`
|
Recur string `json:"recur,omitempty"`
|
||||||
Annotations []Annotation `json:"annotations,omitempty"`
|
Annotations []Annotation `json:"annotations,omitempty"`
|
||||||
|
Udas map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: fix pointer receiver
|
||||||
|
func NewTask() Task {
|
||||||
|
return Task{
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
Depends: make([]string, 0),
|
||||||
|
Udas: make(map[string]any),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) GetString(fieldWFormat string) string {
|
func (t *Task) GetString(fieldWFormat string) string {
|
||||||
@ -90,19 +120,25 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.Annotations) == 0 {
|
if t.Udas["details"] != nil && t.Udas["details"] != "" {
|
||||||
return t.Description
|
return fmt.Sprintf("%s [D]", t.Description)
|
||||||
} else {
|
} else {
|
||||||
// var annotations []string
|
return t.Description
|
||||||
// for _, a := range t.Annotations {
|
|
||||||
// annotations = append(annotations, a.String())
|
|
||||||
// }
|
|
||||||
// return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
|
|
||||||
|
|
||||||
// TODO enable support for multiline in table
|
|
||||||
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if len(t.Annotations) == 0 {
|
||||||
|
// return t.Description
|
||||||
|
// } else {
|
||||||
|
// // var annotations []string
|
||||||
|
// // for _, a := range t.Annotations {
|
||||||
|
// // annotations = append(annotations, a.String())
|
||||||
|
// // }
|
||||||
|
// // return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
|
||||||
|
|
||||||
|
// // TODO enable support for multiline in table
|
||||||
|
// return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||||
|
// }
|
||||||
|
|
||||||
case "project":
|
case "project":
|
||||||
switch format {
|
switch format {
|
||||||
case "parent":
|
case "parent":
|
||||||
@ -113,8 +149,8 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
return t.Project
|
return t.Project
|
||||||
|
|
||||||
case "priority":
|
// case "priority":
|
||||||
return t.Priority
|
// return t.Priority
|
||||||
|
|
||||||
case "status":
|
case "status":
|
||||||
return t.Status
|
return t.Status
|
||||||
@ -185,10 +221,17 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
return t.Recur
|
return t.Recur
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
// TODO: format according to UDA type
|
||||||
|
if val, ok := t.Udas[field]; ok {
|
||||||
|
if strVal, ok := val.(string); ok {
|
||||||
|
return strVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Task) GetDate(dateString string) time.Time {
|
func (t *Task) GetDate(dateString string) time.Time {
|
||||||
dt, err := time.Parse(dtformat, dateString)
|
dt, err := time.Parse(dtformat, dateString)
|
||||||
@ -223,7 +266,69 @@ func (t *Task) RemoveTag(tag string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tasks []*Task
|
func (t *Task) UnmarshalJSON(data []byte) error {
|
||||||
|
type Alias Task
|
||||||
|
task := Alias{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*t = Task(task)
|
||||||
|
|
||||||
|
m := make(map[string]any)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(m, "id")
|
||||||
|
delete(m, "uuid")
|
||||||
|
delete(m, "description")
|
||||||
|
delete(m, "project")
|
||||||
|
// delete(m, "priority")
|
||||||
|
delete(m, "status")
|
||||||
|
delete(m, "tags")
|
||||||
|
delete(m, "depends")
|
||||||
|
delete(m, "urgency")
|
||||||
|
delete(m, "parent")
|
||||||
|
delete(m, "due")
|
||||||
|
delete(m, "wait")
|
||||||
|
delete(m, "scheduled")
|
||||||
|
delete(m, "until")
|
||||||
|
delete(m, "start")
|
||||||
|
delete(m, "end")
|
||||||
|
delete(m, "entry")
|
||||||
|
delete(m, "modified")
|
||||||
|
delete(m, "recur")
|
||||||
|
delete(m, "annotations")
|
||||||
|
t.Udas = m
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) MarshalJSON() ([]byte, error) {
|
||||||
|
type Alias Task
|
||||||
|
task := Alias(*t)
|
||||||
|
|
||||||
|
knownFields, err := json.Marshal(task)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownMap map[string]any
|
||||||
|
if err := json.Unmarshal(knownFields, &knownMap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range t.Udas {
|
||||||
|
if value != nil && value != "" {
|
||||||
|
knownMap[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(knownMap)
|
||||||
|
}
|
||||||
|
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Name string
|
Name string
|
||||||
@ -411,3 +516,15 @@ func ValidateDate(s string) error {
|
|||||||
|
|
||||||
return fmt.Errorf("invalid date")
|
return fmt.Errorf("invalid date")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateNumeric(s string) error {
|
||||||
|
if _, err := strconv.ParseFloat(s, 64); err != nil {
|
||||||
|
return fmt.Errorf("invalid number")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDuration(s string) error {
|
||||||
|
// TODO: implement duration validation
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -89,13 +89,17 @@ type TaskWarrior interface {
|
|||||||
GetReport(report string) *Report
|
GetReport(report string) *Report
|
||||||
GetReports() Reports
|
GetReports() Reports
|
||||||
|
|
||||||
|
GetUdas() []Uda
|
||||||
|
|
||||||
GetTasks(report *Report, filter ...string) Tasks
|
GetTasks(report *Report, filter ...string) Tasks
|
||||||
AddTask(task *Task) error
|
// AddTask(task *Task) error
|
||||||
ImportTask(task *Task)
|
ImportTask(task *Task)
|
||||||
SetTaskDone(task *Task)
|
SetTaskDone(task *Task)
|
||||||
DeleteTask(task *Task)
|
DeleteTask(task *Task)
|
||||||
StartTask(task *Task)
|
StartTask(task *Task)
|
||||||
StopTask(task *Task)
|
StopTask(task *Task)
|
||||||
|
GetInformation(task *Task) string
|
||||||
|
AddTaskAnnotation(uuid string, annotation string)
|
||||||
|
|
||||||
Undo()
|
Undo()
|
||||||
}
|
}
|
||||||
@ -281,10 +285,18 @@ func (ts *TaskSquire) GetTags() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tags := make([]string, 0)
|
tags := make([]string, 0)
|
||||||
|
tagSet := make(map[string]struct{})
|
||||||
|
|
||||||
for _, tag := range strings.Split(string(output), "\n") {
|
for _, tag := range strings.Split(string(output), "\n") {
|
||||||
if _, ok := virtualTags[tag]; !ok && tag != "" {
|
if _, ok := virtualTags[tag]; !ok && tag != "" {
|
||||||
tags = append(tags, tag)
|
tags = append(tags, tag)
|
||||||
|
tagSet[tag] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range strings.Split(ts.config.Get("uda.tasksquire.tags.default"), ",") {
|
||||||
|
if _, ok := tagSet[tag]; !ok {
|
||||||
|
tags = append(tags, tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,6 +319,45 @@ func (ts *TaskSquire) GetReports() Reports {
|
|||||||
return ts.reports
|
return ts.reports
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ts *TaskSquire) GetUdas() []Uda {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, "_udas")...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting config:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
udas := make([]Uda, 0)
|
||||||
|
for _, uda := range strings.Split(string(output), "\n") {
|
||||||
|
if uda != "" {
|
||||||
|
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
|
||||||
|
if udatype == "" {
|
||||||
|
slog.Error(fmt.Sprintf("UDA type not found: %s", uda))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
label := ts.config.Get(fmt.Sprintf("uda.%s.label", uda))
|
||||||
|
values := strings.Split(ts.config.Get(fmt.Sprintf("uda.%s.values", uda)), ",")
|
||||||
|
def := ts.config.Get(fmt.Sprintf("uda.%s.default", uda))
|
||||||
|
|
||||||
|
uda := Uda{
|
||||||
|
Name: uda,
|
||||||
|
Label: label,
|
||||||
|
Type: udatype,
|
||||||
|
Values: values,
|
||||||
|
Default: def,
|
||||||
|
}
|
||||||
|
|
||||||
|
udas = append(udas, uda)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return udas
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) SetContext(context *Context) error {
|
func (ts *TaskSquire) SetContext(context *Context) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
@ -328,42 +379,42 @@ func (ts *TaskSquire) SetContext(context *Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) AddTask(task *Task) error {
|
// func (ts *TaskSquire) AddTask(task *Task) error {
|
||||||
ts.mutex.Lock()
|
// ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
// defer ts.mutex.Unlock()
|
||||||
|
|
||||||
addArgs := []string{"add"}
|
// addArgs := []string{"add"}
|
||||||
|
|
||||||
if task.Description == "" {
|
// if task.Description == "" {
|
||||||
slog.Error("Task description is required")
|
// slog.Error("Task description is required")
|
||||||
return nil
|
// return nil
|
||||||
} else {
|
// } else {
|
||||||
addArgs = append(addArgs, task.Description)
|
// addArgs = append(addArgs, task.Description)
|
||||||
}
|
// }
|
||||||
if task.Priority != "" && task.Priority != "(none)" {
|
// if task.Priority != "" && task.Priority != "(none)" {
|
||||||
addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority))
|
// addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority))
|
||||||
}
|
// }
|
||||||
if task.Project != "" && task.Project != "(none)" {
|
// if task.Project != "" && task.Project != "(none)" {
|
||||||
addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project))
|
// addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project))
|
||||||
}
|
// }
|
||||||
if task.Tags != nil {
|
// if task.Tags != nil {
|
||||||
for _, tag := range task.Tags {
|
// for _, tag := range task.Tags {
|
||||||
addArgs = append(addArgs, fmt.Sprintf("+%s", tag))
|
// addArgs = append(addArgs, fmt.Sprintf("+%s", tag))
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
if task.Due != "" {
|
// if task.Due != "" {
|
||||||
addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due))
|
// addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due))
|
||||||
}
|
// }
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...)
|
// cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...)
|
||||||
err := cmd.Run()
|
// err := cmd.Run()
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
slog.Error("Failed adding task:", err)
|
// slog.Error("Failed adding task:", err)
|
||||||
}
|
// }
|
||||||
|
|
||||||
// TODO remove error?
|
// // TODO remove error?
|
||||||
return nil
|
// return nil
|
||||||
}
|
// }
|
||||||
|
|
||||||
// TODO error handling
|
// TODO error handling
|
||||||
func (ts *TaskSquire) ImportTask(task *Task) {
|
func (ts *TaskSquire) ImportTask(task *Task) {
|
||||||
@ -371,7 +422,6 @@ func (ts *TaskSquire) ImportTask(task *Task) {
|
|||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
tasks, err := json.Marshal(Tasks{task})
|
tasks, err := json.Marshal(Tasks{task})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed marshalling task:", err)
|
slog.Error("Failed marshalling task:", err)
|
||||||
}
|
}
|
||||||
@ -440,6 +490,31 @@ func (ts *TaskSquire) StopTask(task *Task) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ts *TaskSquire) GetInformation(task *Task) string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting task information:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed adding annotation:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) extractConfig() *TWConfig {
|
func (ts *TaskSquire) extractConfig() *TWConfig {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|||||||
Binary file not shown.
24
test/taskrc
24
test/taskrc
@ -1,6 +1,30 @@
|
|||||||
include light-256.theme
|
include light-256.theme
|
||||||
|
|
||||||
|
uda.priority.values=H,M,,L
|
||||||
|
|
||||||
context.test.read=+test
|
context.test.read=+test
|
||||||
context.test.write=+test
|
context.test.write=+test
|
||||||
context.home.read=+home
|
context.home.read=+home
|
||||||
context.home.write=+home
|
context.home.write=+home
|
||||||
|
|
||||||
|
uda.testuda.type=string
|
||||||
|
uda.testuda.label=Testuda
|
||||||
|
uda.testuda.values=eins,zwei,drei
|
||||||
|
uda.testuda.default=eins
|
||||||
|
|
||||||
|
uda.testuda2.type=numeric
|
||||||
|
uda.testuda2.label=TESTUDA2
|
||||||
|
|
||||||
|
uda.testuda3.type=date
|
||||||
|
uda.testuda3.label=Ttttuda
|
||||||
|
|
||||||
|
uda.testuda4.type=duration
|
||||||
|
uda.testuda4.label=TtttudaDURUD
|
||||||
|
|
||||||
|
report.next.columns=id,testuda,start.age,entry.age,depends,priority,project,tags,recur,scheduled.countdown,due.relative,until.remaining,description,urgency
|
||||||
|
report.next.context=1
|
||||||
|
report.next.description=Most urgent tasks
|
||||||
|
report.next.filter=status:pending -WAITING
|
||||||
|
report.next.labels=ID,UDA,Active,Age,Deps,P,Project,Tag,Recur,S,Due,Until,Description,Urg
|
||||||
|
report.next.sort=urgency-
|
||||||
|
uda.tasksquire.use_details=true
|
||||||
|
|||||||
78
timewarrior/config.go
Normal file
78
timewarrior/config.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package timewarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TWConfig struct {
|
||||||
|
config map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultConfig = map[string]string{
|
||||||
|
"uda.timesquire.default.tag": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewConfig(config []string) *TWConfig {
|
||||||
|
cfg := parseConfig(config)
|
||||||
|
|
||||||
|
for key, value := range defaultConfig {
|
||||||
|
if _, ok := cfg[key]; !ok {
|
||||||
|
cfg[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TWConfig{
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TWConfig) GetConfig() map[string]string {
|
||||||
|
return tc.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TWConfig) Get(key string) string {
|
||||||
|
if _, ok := tc.config[key]; !ok {
|
||||||
|
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return tc.config[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfig(config []string) map[string]string {
|
||||||
|
configMap := make(map[string]string)
|
||||||
|
|
||||||
|
for _, line := range config {
|
||||||
|
// Skip empty lines and comments
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timewarrior config format: key = value or key: value
|
||||||
|
var key, value string
|
||||||
|
if strings.Contains(line, "=") {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key = strings.TrimSpace(parts[0])
|
||||||
|
value = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
} else if strings.Contains(line, ":") {
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key = strings.TrimSpace(parts[0])
|
||||||
|
value = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
configMap[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configMap
|
||||||
|
}
|
||||||
314
timewarrior/models.go
Normal file
314
timewarrior/models.go
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
package timewarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dtformat = "20060102T150405Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Intervals []*Interval
|
||||||
|
|
||||||
|
type Interval struct {
|
||||||
|
ID int `json:"-"`
|
||||||
|
Start string `json:"start,omitempty"`
|
||||||
|
End string `json:"end,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
IsGap bool `json:"-"` // True if this represents an untracked time gap
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInterval() *Interval {
|
||||||
|
return &Interval{
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGapInterval creates a new gap interval representing untracked time.
|
||||||
|
// start and end are the times between which the gap occurred.
|
||||||
|
func NewGapInterval(start, end time.Time) *Interval {
|
||||||
|
return &Interval{
|
||||||
|
ID: -1, // Gap intervals have no real ID
|
||||||
|
Start: start.UTC().Format(dtformat),
|
||||||
|
End: end.UTC().Format(dtformat),
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
IsGap: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetString(field string) string {
|
||||||
|
// Special handling for gap intervals
|
||||||
|
if i.IsGap {
|
||||||
|
switch field {
|
||||||
|
case "duration":
|
||||||
|
return i.GetDuration()
|
||||||
|
case "gap_display":
|
||||||
|
return fmt.Sprintf("--- Untracked: %s ---", i.GetDuration())
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field {
|
||||||
|
case "id":
|
||||||
|
return strconv.Itoa(i.ID)
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
return formatDate(i.Start, "formatted")
|
||||||
|
|
||||||
|
case "start_time":
|
||||||
|
return formatDate(i.Start, "time")
|
||||||
|
|
||||||
|
case "end":
|
||||||
|
if i.End == "" {
|
||||||
|
return "now"
|
||||||
|
}
|
||||||
|
return formatDate(i.End, "formatted")
|
||||||
|
|
||||||
|
case "end_time":
|
||||||
|
if i.End == "" {
|
||||||
|
return "now"
|
||||||
|
}
|
||||||
|
return formatDate(i.End, "time")
|
||||||
|
|
||||||
|
case "weekday":
|
||||||
|
return formatDate(i.Start, "weekday")
|
||||||
|
|
||||||
|
case "tags":
|
||||||
|
if len(i.Tags) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(i.Tags, " ")
|
||||||
|
|
||||||
|
case "duration":
|
||||||
|
return i.GetDuration()
|
||||||
|
|
||||||
|
case "active":
|
||||||
|
if i.End == "" {
|
||||||
|
return "●"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
|
||||||
|
default:
|
||||||
|
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetDuration() string {
|
||||||
|
start, err := time.Parse(dtformat, i.Start)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse start time:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var end time.Time
|
||||||
|
if i.End == "" {
|
||||||
|
end = time.Now()
|
||||||
|
} else {
|
||||||
|
end, err = time.Parse(dtformat, i.End)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse end time:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := end.Sub(start)
|
||||||
|
return formatDuration(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetStartTime() time.Time {
|
||||||
|
dt, err := time.Parse(dtformat, i.Start)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse time:", err)
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return dt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetEndTime() time.Time {
|
||||||
|
if i.End == "" {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
dt, err := time.Parse(dtformat, i.End)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse time:", err)
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return dt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) HasTag(tag string) bool {
|
||||||
|
for _, t := range i.Tags {
|
||||||
|
if t == tag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) AddTag(tag string) {
|
||||||
|
if !i.HasTag(tag) {
|
||||||
|
i.Tags = append(i.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) RemoveTag(tag string) {
|
||||||
|
for idx, t := range i.Tags {
|
||||||
|
if t == tag {
|
||||||
|
i.Tags = append(i.Tags[:idx], i.Tags[idx+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) IsActive() bool {
|
||||||
|
return i.End == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDate(date string, format string) string {
|
||||||
|
if date == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
dt, err := time.Parse(dtformat, date)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse time:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
dt = dt.Local()
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "formatted", "":
|
||||||
|
return dt.Format("2006-01-02 15:04")
|
||||||
|
case "time":
|
||||||
|
return dt.Format("15:04")
|
||||||
|
case "date":
|
||||||
|
return dt.Format("2006-01-02")
|
||||||
|
case "weekday":
|
||||||
|
return dt.Format("Mon")
|
||||||
|
case "iso":
|
||||||
|
return dt.Format("2006-01-02T150405Z")
|
||||||
|
case "epoch":
|
||||||
|
return strconv.FormatInt(dt.Unix(), 10)
|
||||||
|
case "age":
|
||||||
|
return parseDurationVague(time.Since(dt))
|
||||||
|
case "relative":
|
||||||
|
return parseDurationVague(time.Until(dt))
|
||||||
|
default:
|
||||||
|
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
hours := int(d.Hours())
|
||||||
|
minutes := int(d.Minutes()) % 60
|
||||||
|
seconds := int(d.Seconds()) % 60
|
||||||
|
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDurationVague(d time.Duration) string {
|
||||||
|
dur := d.Round(time.Second).Abs()
|
||||||
|
days := dur.Hours() / 24
|
||||||
|
|
||||||
|
var formatted string
|
||||||
|
if dur >= time.Hour*24*365 {
|
||||||
|
formatted = fmt.Sprintf("%.1fy", days/365)
|
||||||
|
} else if dur >= time.Hour*24*90 {
|
||||||
|
formatted = strconv.Itoa(int(math.Round(days/30))) + "mo"
|
||||||
|
} else if dur >= time.Hour*24*7 {
|
||||||
|
formatted = strconv.Itoa(int(math.Round(days/7))) + "w"
|
||||||
|
} else if dur >= time.Hour*24 {
|
||||||
|
formatted = strconv.Itoa(int(days)) + "d"
|
||||||
|
} else if dur >= time.Hour {
|
||||||
|
formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h"
|
||||||
|
} else if dur >= time.Minute {
|
||||||
|
formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min"
|
||||||
|
} else if dur >= time.Second {
|
||||||
|
formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
if d < 0 {
|
||||||
|
formatted = "-" + formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
dateFormats = []string{
|
||||||
|
"2006-01-02",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
"20060102T150405Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
specialDateFormats = []string{
|
||||||
|
"",
|
||||||
|
"now",
|
||||||
|
"today",
|
||||||
|
"yesterday",
|
||||||
|
"tomorrow",
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
"sunday",
|
||||||
|
"mon",
|
||||||
|
"tue",
|
||||||
|
"wed",
|
||||||
|
"thu",
|
||||||
|
"fri",
|
||||||
|
"sat",
|
||||||
|
"sun",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateDate(s string) error {
|
||||||
|
for _, f := range dateFormats {
|
||||||
|
if _, err := time.Parse(f, s); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range specialDateFormats {
|
||||||
|
if s == f {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("invalid date")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDuration(s string) error {
|
||||||
|
// TODO: implement proper duration validation
|
||||||
|
// Should accept formats like: 1h, 30m, 1h30m, etc.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary represents time tracking summary data
|
||||||
|
type Summary struct {
|
||||||
|
Range string
|
||||||
|
TotalTime time.Duration
|
||||||
|
ByTag map[string]time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Summary) GetTotalString() string {
|
||||||
|
return formatDuration(s.TotalTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Summary) GetTagTime(tag string) string {
|
||||||
|
if duration, ok := s.ByTag[tag]; ok {
|
||||||
|
return formatDuration(duration)
|
||||||
|
}
|
||||||
|
return "0:00"
|
||||||
|
}
|
||||||
398
timewarrior/timewarrior.go
Normal file
398
timewarrior/timewarrior.go
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
// TODO: error handling
|
||||||
|
// TODO: split combinedOutput and handle stderr differently
|
||||||
|
package timewarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
twBinary = "timew"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeWarrior interface {
|
||||||
|
GetConfig() *TWConfig
|
||||||
|
|
||||||
|
GetTags() []string
|
||||||
|
GetTagCombinations() []string
|
||||||
|
|
||||||
|
GetIntervals(filter ...string) Intervals
|
||||||
|
StartTracking(tags []string) error
|
||||||
|
StopTracking() error
|
||||||
|
ContinueTracking() error
|
||||||
|
ContinueInterval(id int) error
|
||||||
|
CancelTracking() error
|
||||||
|
DeleteInterval(id int) error
|
||||||
|
FillInterval(id int) error
|
||||||
|
JoinInterval(id int) error
|
||||||
|
ModifyInterval(interval *Interval, adjust bool) error
|
||||||
|
GetSummary(filter ...string) string
|
||||||
|
GetActive() *Interval
|
||||||
|
|
||||||
|
Undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeSquire struct {
|
||||||
|
configLocation string
|
||||||
|
defaultArgs []string
|
||||||
|
config *TWConfig
|
||||||
|
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTimeSquire(configLocation string) *TimeSquire {
|
||||||
|
if _, err := exec.LookPath(twBinary); err != nil {
|
||||||
|
slog.Error("Timewarrior not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := &TimeSquire{
|
||||||
|
configLocation: configLocation,
|
||||||
|
defaultArgs: []string{},
|
||||||
|
mutex: sync.Mutex{},
|
||||||
|
}
|
||||||
|
ts.config = ts.extractConfig()
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) GetConfig() *TWConfig {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
return ts.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) GetTags() []string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting tags:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := make([]string, 0)
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
|
||||||
|
// Skip header lines and parse tag names
|
||||||
|
for i, line := range lines {
|
||||||
|
if i < 3 || line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Tags are space-separated, first column is the tag name
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) > 0 {
|
||||||
|
tags = append(tags, fields[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(tags)
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTagCombinations returns unique tag combinations from intervals,
|
||||||
|
// ordered newest first (most recent intervals' tags appear first).
|
||||||
|
// Returns formatted strings like "dev client-work meeting".
|
||||||
|
func (ts *TimeSquire) GetTagCombinations() []string {
|
||||||
|
intervals := ts.GetIntervals() // Already sorted newest first
|
||||||
|
|
||||||
|
// Track unique combinations while preserving order
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var combinations []string
|
||||||
|
|
||||||
|
for _, interval := range intervals {
|
||||||
|
if len(interval.Tags) == 0 {
|
||||||
|
continue // Skip intervals with no tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format tags (handles spaces with quotes)
|
||||||
|
combo := formatTagsForCombination(interval.Tags)
|
||||||
|
|
||||||
|
if !seen[combo] {
|
||||||
|
seen[combo] = true
|
||||||
|
combinations = append(combinations, combo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinations
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTagsForCombination formats tags consistently for display
|
||||||
|
func formatTagsForCombination(tags []string) string {
|
||||||
|
var formatted []string
|
||||||
|
for _, t := range tags {
|
||||||
|
if strings.Contains(t, " ") {
|
||||||
|
formatted = append(formatted, "\""+t+"\"")
|
||||||
|
} else {
|
||||||
|
formatted = append(formatted, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(formatted, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
args := append(ts.defaultArgs, "export")
|
||||||
|
if filter != nil {
|
||||||
|
args = append(args, filter...)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting intervals:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals := make(Intervals, 0)
|
||||||
|
err = json.Unmarshal(output, &intervals)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed unmarshalling intervals:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the intervals to show newest first
|
||||||
|
slices.Reverse(intervals)
|
||||||
|
|
||||||
|
// Assign IDs based on new order (newest is @1)
|
||||||
|
for i := range intervals {
|
||||||
|
intervals[i].ID = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) StartTracking(tags []string) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return fmt.Errorf("at least one tag is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := append(ts.defaultArgs, "start")
|
||||||
|
args = append(args, tags...)
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, args...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed starting tracking:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) StopTracking() error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed stopping tracking:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) ContinueTracking() error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed continuing tracking:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) ContinueInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed continuing interval:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) CancelTracking() error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed canceling tracking:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) DeleteInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed deleting interval:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) FillInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed filling interval:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) JoinInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
// Join the current interval with the previous one
|
||||||
|
// The previous interval has id+1 (since intervals are ordered newest first)
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed joining interval:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
// Export the modified interval
|
||||||
|
intervals, err := json.Marshal(Intervals{interval})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed marshalling interval:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build import command with optional :adjust hint
|
||||||
|
args := append(ts.defaultArgs, "import")
|
||||||
|
if adjust {
|
||||||
|
args = append(args, ":adjust")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the modified interval
|
||||||
|
cmd := exec.Command(twBinary, args...)
|
||||||
|
cmd.Stdin = bytes.NewBuffer(intervals)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed modifying interval:", err, string(out))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) GetSummary(filter ...string) string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
args := append(ts.defaultArgs, "summary")
|
||||||
|
if filter != nil {
|
||||||
|
args = append(args, filter...)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting summary:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) GetActive() *Interval {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil || string(output) == "0\n" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the active interval
|
||||||
|
intervals := ts.GetIntervals()
|
||||||
|
for _, interval := range intervals {
|
||||||
|
if interval.End == "" {
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) Undo() {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed undoing:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) extractConfig() *TWConfig {
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting config:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewConfig(strings.Split(string(output), "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTags(config string) []string {
|
||||||
|
re := regexp.MustCompile(`tag\.([^.]+)\.[^.]+`)
|
||||||
|
matches := re.FindAllStringSubmatch(config, -1)
|
||||||
|
uniques := make(map[string]struct{})
|
||||||
|
for _, match := range matches {
|
||||||
|
uniques[match[1]] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
for tag := range uniques {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(tags)
|
||||||
|
return tags
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user