Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a3e0a568 | ||
|
|
9eda92503e | ||
|
|
418bcd96a8 | ||
|
|
6b1418fc71 | ||
|
|
b46aced2c7 | ||
|
|
3ab26f658d | ||
|
|
1a9fd9b4b0 | ||
|
|
6e60698526 | ||
|
|
e3effe8b25 | ||
|
|
980c8eb309 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
app.log
|
|
||||||
test/taskchampion.sqlite3
|
test/taskchampion.sqlite3
|
||||||
tasksquire
|
tasksquire
|
||||||
test/*.sqlite3*
|
test/*.sqlite3*
|
||||||
|
result
|
||||||
|
main
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ ts.StopTask(&task)
|
|||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
- **Logging**: Application logs to `app.log` in current directory
|
- **Logging**: Application logs to `/tmp/tasksquire.log`
|
||||||
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
|
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
|
||||||
- **Color Parsing**: Custom color parsing from Taskwarrior config format
|
- **Color Parsing**: Custom color parsing from Taskwarrior config format
|
||||||
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000
|
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ The `Task` struct uses custom `MarshalJSON()` and `UnmarshalJSON()` to handle:
|
|||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
- Uses `log/slog` for structured logging
|
- Uses `log/slog` for structured logging
|
||||||
- Logs written to `app.log` in current directory
|
- Logs written to `/tmp/tasksquire.log`
|
||||||
- Errors logged but execution typically continues (graceful degradation)
|
- Errors logged but execution typically continues (graceful degradation)
|
||||||
- Log pattern: `slog.Error("message", "key", value)`
|
- Log pattern: `slog.Error("message", "key", value)`
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It look
|
|||||||
2. `$HOME/.taskrc`
|
2. `$HOME/.taskrc`
|
||||||
3. `$HOME/.config/task/taskrc`
|
3. `$HOME/.config/task/taskrc`
|
||||||
|
|
||||||
Logging is written to `app.log` in the current working directory.
|
Logging is written to `/tmp/tasksquire.log`.
|
||||||
|
|
||||||
## Development Conventions
|
## Development Conventions
|
||||||
|
|
||||||
|
|||||||
194
common/styles.go
194
common/styles.go
@@ -1,194 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
// "github.com/charmbracelet/bubbles/table"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TableStyle struct {
|
|
||||||
Header lipgloss.Style
|
|
||||||
Cell lipgloss.Style
|
|
||||||
Selected lipgloss.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
type Styles struct {
|
|
||||||
Colors map[string]*lipgloss.Style
|
|
||||||
|
|
||||||
Base lipgloss.Style
|
|
||||||
|
|
||||||
Form *huh.Theme
|
|
||||||
TableStyle TableStyle
|
|
||||||
|
|
||||||
Tab lipgloss.Style
|
|
||||||
ActiveTab lipgloss.Style
|
|
||||||
TabBar lipgloss.Style
|
|
||||||
|
|
||||||
ColumnFocused lipgloss.Style
|
|
||||||
ColumnBlurred lipgloss.Style
|
|
||||||
ColumnInsert lipgloss.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|
||||||
styles := Styles{}
|
|
||||||
|
|
||||||
colors := make(map[string]*lipgloss.Style)
|
|
||||||
|
|
||||||
for key, value := range config.GetConfig() {
|
|
||||||
if strings.HasPrefix(key, "color.") {
|
|
||||||
_, color, _ := strings.Cut(key, ".")
|
|
||||||
colors[color] = parseColorString(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
styles.Colors = colors
|
|
||||||
|
|
||||||
styles.Base = lipgloss.NewStyle()
|
|
||||||
|
|
||||||
styles.TableStyle = TableStyle{
|
|
||||||
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
|
|
||||||
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
|
|
||||||
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true),
|
|
||||||
Selected: lipgloss.NewStyle().Bold(true).Reverse(true),
|
|
||||||
}
|
|
||||||
|
|
||||||
formTheme := huh.ThemeBase()
|
|
||||||
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
|
|
||||||
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ")
|
|
||||||
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
|
|
||||||
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ")
|
|
||||||
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ")
|
|
||||||
formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
|
|
||||||
formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true)
|
|
||||||
formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
|
||||||
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
|
||||||
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
|
||||||
formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ")
|
|
||||||
formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ")
|
|
||||||
|
|
||||||
styles.Form = formTheme
|
|
||||||
|
|
||||||
styles.Tab = lipgloss.NewStyle().
|
|
||||||
Padding(0, 1).
|
|
||||||
Foreground(lipgloss.Color("240"))
|
|
||||||
|
|
||||||
styles.ActiveTab = styles.Tab.
|
|
||||||
Foreground(lipgloss.Color("252")).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
styles.TabBar = lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
|
||||||
BorderForeground(lipgloss.Color("240")).
|
|
||||||
MarginBottom(1)
|
|
||||||
|
|
||||||
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
|
||||||
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
|
||||||
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
|
||||||
if styles.Colors["active"] != nil {
|
|
||||||
styles.ColumnInsert = styles.ColumnInsert.BorderForeground(styles.Colors["active"].GetForeground())
|
|
||||||
}
|
|
||||||
|
|
||||||
return &styles
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseColorString(color string) *lipgloss.Style {
|
|
||||||
if color == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
style := lipgloss.NewStyle()
|
|
||||||
|
|
||||||
if strings.Contains(color, "on") {
|
|
||||||
fgbg := strings.Split(color, "on")
|
|
||||||
fg := strings.TrimSpace(fgbg[0])
|
|
||||||
bg := strings.TrimSpace(fgbg[1])
|
|
||||||
if fg != "" {
|
|
||||||
style = style.Foreground(parseColor(fg))
|
|
||||||
}
|
|
||||||
if bg != "" {
|
|
||||||
style = style.Background(parseColor(bg))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
style = style.Foreground(parseColor(strings.TrimSpace(color)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &style
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseColor(color string) lipgloss.Color {
|
|
||||||
if strings.HasPrefix(color, "rgb") {
|
|
||||||
return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb")))
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(color, "color") {
|
|
||||||
return lipgloss.Color(strings.TrimPrefix(color, "color"))
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(color, "gray") {
|
|
||||||
gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray"))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Invalid gray color format")
|
|
||||||
return lipgloss.Color("0")
|
|
||||||
}
|
|
||||||
return lipgloss.Color(strconv.Itoa(gray + 232))
|
|
||||||
}
|
|
||||||
if ansi, okcolor := colorStrings[color]; okcolor {
|
|
||||||
return lipgloss.Color(strconv.Itoa(ansi))
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Error("Invalid color format")
|
|
||||||
return lipgloss.Color("0")
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertRgbToAnsi(rgbString string) string {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if len(rgbString) != 3 {
|
|
||||||
slog.Error("Invalid RGB color format")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
r, err := strconv.Atoi(string(rgbString[0]))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Invalid value for R")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := strconv.Atoi(string(rgbString[1]))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Invalid value for G")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := strconv.Atoi(string(rgbString[2]))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Invalid value for B")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return strconv.Itoa(16 + (36 * r) + (6 * g) + b)
|
|
||||||
}
|
|
||||||
|
|
||||||
var colorStrings = map[string]int{
|
|
||||||
"black": 0,
|
|
||||||
"red": 1,
|
|
||||||
"green": 2,
|
|
||||||
"yellow": 3,
|
|
||||||
"blue": 4,
|
|
||||||
"magenta": 5,
|
|
||||||
"cyan": 6,
|
|
||||||
"white": 7,
|
|
||||||
"bright black": 8,
|
|
||||||
"bright red": 9,
|
|
||||||
"bright green": 10,
|
|
||||||
"bright yellow": 11,
|
|
||||||
"bright blue": 12,
|
|
||||||
"bright magenta": 13,
|
|
||||||
"bright cyan": 14,
|
|
||||||
"bright white": 15,
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
package autocomplete
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/sahilm/fuzzy"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Autocomplete struct {
|
|
||||||
input textinput.Model
|
|
||||||
allSuggestions []string // All available suggestions (newest first)
|
|
||||||
filteredSuggestions []string // Currently matching suggestions
|
|
||||||
matchedIndexes [][]int // Matched character positions for each suggestion
|
|
||||||
selectedIndex int // -1 = input focused, 0+ = suggestion selected
|
|
||||||
showSuggestions bool // Whether to display suggestion box
|
|
||||||
maxVisible int // Max suggestions to show
|
|
||||||
minChars int // Min chars before showing suggestions
|
|
||||||
focused bool
|
|
||||||
width int
|
|
||||||
placeholder string
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new autocomplete component
|
|
||||||
func New(suggestions []string, minChars int, maxVisible int) *Autocomplete {
|
|
||||||
ti := textinput.New()
|
|
||||||
ti.Width = 50
|
|
||||||
|
|
||||||
return &Autocomplete{
|
|
||||||
input: ti,
|
|
||||||
allSuggestions: suggestions,
|
|
||||||
selectedIndex: -1,
|
|
||||||
maxVisible: maxVisible,
|
|
||||||
minChars: minChars,
|
|
||||||
width: 50,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetValue sets the input value
|
|
||||||
func (a *Autocomplete) SetValue(value string) {
|
|
||||||
a.input.SetValue(value)
|
|
||||||
a.updateFilteredSuggestions()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValue returns the current input value
|
|
||||||
func (a *Autocomplete) GetValue() string {
|
|
||||||
return a.input.Value()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus focuses the autocomplete input
|
|
||||||
func (a *Autocomplete) Focus() {
|
|
||||||
a.focused = true
|
|
||||||
a.input.Focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur blurs the autocomplete input
|
|
||||||
func (a *Autocomplete) Blur() {
|
|
||||||
a.focused = false
|
|
||||||
a.input.Blur()
|
|
||||||
a.showSuggestions = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPlaceholder sets the placeholder text
|
|
||||||
func (a *Autocomplete) SetPlaceholder(placeholder string) {
|
|
||||||
a.placeholder = placeholder
|
|
||||||
a.input.Placeholder = placeholder
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetWidth sets the width of the autocomplete
|
|
||||||
func (a *Autocomplete) SetWidth(width int) {
|
|
||||||
a.width = width
|
|
||||||
a.input.Width = width
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMaxVisible sets the maximum number of visible suggestions
|
|
||||||
func (a *Autocomplete) SetMaxVisible(max int) {
|
|
||||||
a.maxVisible = max
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMinChars sets the minimum characters required before showing suggestions
|
|
||||||
func (a *Autocomplete) SetMinChars(min int) {
|
|
||||||
a.minChars = min
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSuggestions updates the available suggestions
|
|
||||||
func (a *Autocomplete) SetSuggestions(suggestions []string) {
|
|
||||||
a.allSuggestions = suggestions
|
|
||||||
a.updateFilteredSuggestions()
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasSuggestions returns true if the autocomplete is currently showing suggestions
|
|
||||||
func (a *Autocomplete) HasSuggestions() bool {
|
|
||||||
return a.showSuggestions && len(a.filteredSuggestions) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes the autocomplete
|
|
||||||
func (a *Autocomplete) Init() tea.Cmd {
|
|
||||||
return textinput.Blink
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update handles messages for the autocomplete
|
|
||||||
func (a *Autocomplete) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if !a.focused {
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
|
|
||||||
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
|
||||||
a.selectedIndex++
|
|
||||||
if a.selectedIndex >= len(a.filteredSuggestions) {
|
|
||||||
a.selectedIndex = 0
|
|
||||||
}
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
|
|
||||||
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
|
||||||
a.selectedIndex--
|
|
||||||
if a.selectedIndex < 0 {
|
|
||||||
a.selectedIndex = len(a.filteredSuggestions) - 1
|
|
||||||
}
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
|
||||||
if a.showSuggestions && a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
|
|
||||||
// Accept selected suggestion
|
|
||||||
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
|
|
||||||
a.showSuggestions = false
|
|
||||||
a.selectedIndex = -1
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
|
||||||
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
|
||||||
// Accept first or selected suggestion
|
|
||||||
if a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
|
|
||||||
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
|
|
||||||
} else {
|
|
||||||
a.input.SetValue(a.filteredSuggestions[0])
|
|
||||||
}
|
|
||||||
a.showSuggestions = false
|
|
||||||
a.selectedIndex = -1
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
|
||||||
if a.showSuggestions {
|
|
||||||
a.showSuggestions = false
|
|
||||||
a.selectedIndex = -1
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Handle regular text input
|
|
||||||
prevValue := a.input.Value()
|
|
||||||
a.input, cmd = a.input.Update(msg)
|
|
||||||
|
|
||||||
// Update suggestions if value changed
|
|
||||||
if a.input.Value() != prevValue {
|
|
||||||
a.updateFilteredSuggestions()
|
|
||||||
}
|
|
||||||
|
|
||||||
return a, cmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.input, cmd = a.input.Update(msg)
|
|
||||||
return a, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// View renders the autocomplete
|
|
||||||
func (a *Autocomplete) View() string {
|
|
||||||
// Input field
|
|
||||||
inputView := a.input.View()
|
|
||||||
|
|
||||||
if !a.showSuggestions || len(a.filteredSuggestions) == 0 {
|
|
||||||
return inputView
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suggestion box
|
|
||||||
var suggestionViews []string
|
|
||||||
for i, suggestion := range a.filteredSuggestions {
|
|
||||||
if i >= a.maxVisible {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := " "
|
|
||||||
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
|
||||||
|
|
||||||
if i == a.selectedIndex {
|
|
||||||
// Highlight selected suggestion
|
|
||||||
baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12"))
|
|
||||||
prefix = "→ "
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build suggestion with highlighted matched characters
|
|
||||||
var rendered string
|
|
||||||
if i < len(a.matchedIndexes) {
|
|
||||||
rendered = a.renderWithHighlights(suggestion, a.matchedIndexes[i], i == a.selectedIndex)
|
|
||||||
} else {
|
|
||||||
rendered = suggestion
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestionViews = append(suggestionViews, baseStyle.Render(prefix+rendered))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Box style
|
|
||||||
boxStyle := lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("8")).
|
|
||||||
Width(a.width)
|
|
||||||
|
|
||||||
suggestionsBox := boxStyle.Render(
|
|
||||||
lipgloss.JoinVertical(lipgloss.Left, suggestionViews...),
|
|
||||||
)
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, inputView, suggestionsBox)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderWithHighlights renders a suggestion with matched characters highlighted
|
|
||||||
func (a *Autocomplete) renderWithHighlights(str string, matchedIndexes []int, isSelected bool) string {
|
|
||||||
if len(matchedIndexes) == 0 {
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a map for quick lookup
|
|
||||||
matchedMap := make(map[int]bool)
|
|
||||||
for _, idx := range matchedIndexes {
|
|
||||||
matchedMap[idx] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Choose highlight style based on selection state
|
|
||||||
var highlightStyle lipgloss.Style
|
|
||||||
if isSelected {
|
|
||||||
// When selected, use underline to distinguish from selection bold
|
|
||||||
highlightStyle = lipgloss.NewStyle().Underline(true)
|
|
||||||
} else {
|
|
||||||
// When not selected, use bold and accent color
|
|
||||||
highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the string with highlights
|
|
||||||
var result string
|
|
||||||
runes := []rune(str)
|
|
||||||
for i, r := range runes {
|
|
||||||
if matchedMap[i] {
|
|
||||||
result += highlightStyle.Render(string(r))
|
|
||||||
} else {
|
|
||||||
result += string(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateFilteredSuggestions filters suggestions based on current input
|
|
||||||
func (a *Autocomplete) updateFilteredSuggestions() {
|
|
||||||
value := a.input.Value()
|
|
||||||
|
|
||||||
// Only show if >= minChars
|
|
||||||
if len(value) < a.minChars {
|
|
||||||
a.showSuggestions = false
|
|
||||||
a.filteredSuggestions = nil
|
|
||||||
a.matchedIndexes = nil
|
|
||||||
a.selectedIndex = -1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fuzzy match using sahilm/fuzzy
|
|
||||||
matches := fuzzy.Find(value, a.allSuggestions)
|
|
||||||
|
|
||||||
var filtered []string
|
|
||||||
var indexes [][]int
|
|
||||||
for _, match := range matches {
|
|
||||||
filtered = append(filtered, match.Str)
|
|
||||||
indexes = append(indexes, match.MatchedIndexes)
|
|
||||||
if len(filtered) >= a.maxVisible {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.filteredSuggestions = filtered
|
|
||||||
a.matchedIndexes = indexes
|
|
||||||
a.showSuggestions = len(filtered) > 0 && a.focused
|
|
||||||
a.selectedIndex = -1 // Reset to input
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package detailsviewer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/glamour"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DetailsViewer is a reusable component for displaying task details
|
|
||||||
type DetailsViewer struct {
|
|
||||||
common *common.Common
|
|
||||||
viewport viewport.Model
|
|
||||||
task *taskwarrior.Task
|
|
||||||
focused bool
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new DetailsViewer component
|
|
||||||
func New(com *common.Common) *DetailsViewer {
|
|
||||||
return &DetailsViewer{
|
|
||||||
common: com,
|
|
||||||
viewport: viewport.New(0, 0),
|
|
||||||
focused: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTask updates the task to display
|
|
||||||
func (d *DetailsViewer) SetTask(task *taskwarrior.Task) {
|
|
||||||
d.task = task
|
|
||||||
d.updateContent()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus sets the component to focused state (for future interactivity)
|
|
||||||
func (d *DetailsViewer) Focus() {
|
|
||||||
d.focused = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur sets the component to blurred state
|
|
||||||
func (d *DetailsViewer) Blur() {
|
|
||||||
d.focused = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsFocused returns whether the component is focused
|
|
||||||
func (d *DetailsViewer) IsFocused() bool {
|
|
||||||
return d.focused
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSize implements common.Component
|
|
||||||
func (d *DetailsViewer) SetSize(width, height int) {
|
|
||||||
d.width = width
|
|
||||||
d.height = height
|
|
||||||
|
|
||||||
// Account for border and padding (4 chars horizontal, 4 lines vertical)
|
|
||||||
d.viewport.Width = max(width-4, 0)
|
|
||||||
d.viewport.Height = max(height-4, 0)
|
|
||||||
|
|
||||||
// Refresh content with new width
|
|
||||||
d.updateContent()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init implements tea.Model
|
|
||||||
func (d *DetailsViewer) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update implements tea.Model
|
|
||||||
func (d *DetailsViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
d.viewport, cmd = d.viewport.Update(msg)
|
|
||||||
return d, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// View implements tea.Model
|
|
||||||
func (d *DetailsViewer) View() string {
|
|
||||||
// Title bar
|
|
||||||
titleStyle := lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("252"))
|
|
||||||
|
|
||||||
helpStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("240"))
|
|
||||||
|
|
||||||
header := lipgloss.JoinHorizontal(
|
|
||||||
lipgloss.Left,
|
|
||||||
titleStyle.Render("Details"),
|
|
||||||
" ",
|
|
||||||
helpStyle.Render("(↑/↓ scroll, v/ESC close)"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Container style
|
|
||||||
containerStyle := lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("240")).
|
|
||||||
Padding(0, 1).
|
|
||||||
Width(d.width).
|
|
||||||
Height(d.height)
|
|
||||||
|
|
||||||
// Optional: highlight border when focused (for future interactivity)
|
|
||||||
if d.focused {
|
|
||||||
containerStyle = containerStyle.
|
|
||||||
BorderForeground(lipgloss.Color("86"))
|
|
||||||
}
|
|
||||||
|
|
||||||
content := lipgloss.JoinVertical(
|
|
||||||
lipgloss.Left,
|
|
||||||
header,
|
|
||||||
d.viewport.View(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return containerStyle.Render(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateContent refreshes the viewport content based on current task
|
|
||||||
func (d *DetailsViewer) updateContent() {
|
|
||||||
if d.task == nil {
|
|
||||||
d.viewport.SetContent("(No task selected)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
detailsValue := ""
|
|
||||||
if details, ok := d.task.Udas["details"]; ok && details != nil {
|
|
||||||
detailsValue = details.(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
if detailsValue == "" {
|
|
||||||
d.viewport.SetContent("(No details for this task)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render markdown with glamour
|
|
||||||
renderer, err := glamour.NewTermRenderer(
|
|
||||||
glamour.WithAutoStyle(),
|
|
||||||
glamour.WithWordWrap(d.viewport.Width),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create markdown renderer", "error", err)
|
|
||||||
// Fallback to plain text
|
|
||||||
wrapped := lipgloss.NewStyle().
|
|
||||||
Width(d.viewport.Width).
|
|
||||||
Render(detailsValue)
|
|
||||||
d.viewport.SetContent(wrapped)
|
|
||||||
d.viewport.GotoTop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered, err := renderer.Render(detailsValue)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to render markdown", "error", err)
|
|
||||||
// Fallback to plain text
|
|
||||||
wrapped := lipgloss.NewStyle().
|
|
||||||
Width(d.viewport.Width).
|
|
||||||
Render(detailsValue)
|
|
||||||
d.viewport.SetContent(wrapped)
|
|
||||||
d.viewport.GotoTop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
d.viewport.SetContent(rendered)
|
|
||||||
d.viewport.GotoTop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function
|
|
||||||
func max(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
@@ -1,672 +0,0 @@
|
|||||||
package input
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"tasksquire/common"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/huh/accessibility"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MultiSelect is a form multi-select field.
|
|
||||||
type MultiSelect struct {
|
|
||||||
common *common.Common
|
|
||||||
|
|
||||||
value *[]string
|
|
||||||
key string
|
|
||||||
|
|
||||||
// customization
|
|
||||||
title string
|
|
||||||
description string
|
|
||||||
options []Option[string]
|
|
||||||
filterable bool
|
|
||||||
filteredOptions []Option[string]
|
|
||||||
limit int
|
|
||||||
height int
|
|
||||||
|
|
||||||
// error handling
|
|
||||||
validate func([]string) error
|
|
||||||
err error
|
|
||||||
|
|
||||||
// state
|
|
||||||
cursor int
|
|
||||||
focused bool
|
|
||||||
filtering bool
|
|
||||||
filter textinput.Model
|
|
||||||
viewport viewport.Model
|
|
||||||
|
|
||||||
// options
|
|
||||||
width int
|
|
||||||
accessible bool
|
|
||||||
theme *huh.Theme
|
|
||||||
keymap huh.MultiSelectKeyMap
|
|
||||||
|
|
||||||
// new
|
|
||||||
hasNewOption bool
|
|
||||||
newInput textinput.Model
|
|
||||||
newInputActive bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMultiSelect returns a new multi-select field.
|
|
||||||
func NewMultiSelect(common *common.Common) *MultiSelect {
|
|
||||||
filter := textinput.New()
|
|
||||||
filter.Prompt = "/"
|
|
||||||
|
|
||||||
newInput := textinput.New()
|
|
||||||
newInput.Prompt = "New: "
|
|
||||||
|
|
||||||
return &MultiSelect{
|
|
||||||
common: common,
|
|
||||||
options: []Option[string]{},
|
|
||||||
value: new([]string),
|
|
||||||
validate: func([]string) error { return nil },
|
|
||||||
filtering: false,
|
|
||||||
filter: filter,
|
|
||||||
newInput: newInput,
|
|
||||||
newInputActive: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Value sets the value of the multi-select field.
|
|
||||||
func (m *MultiSelect) Value(value *[]string) *MultiSelect {
|
|
||||||
m.value = value
|
|
||||||
for i, o := range m.options {
|
|
||||||
for _, v := range *value {
|
|
||||||
if o.Value == v {
|
|
||||||
m.options[i].selected = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key sets the key of the select field which can be used to retrieve the value
|
|
||||||
// after submission.
|
|
||||||
func (m *MultiSelect) Key(key string) *MultiSelect {
|
|
||||||
m.key = key
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title sets the title of the multi-select field.
|
|
||||||
func (m *MultiSelect) Title(title string) *MultiSelect {
|
|
||||||
m.title = title
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description sets the description of the multi-select field.
|
|
||||||
func (m *MultiSelect) Description(description string) *MultiSelect {
|
|
||||||
m.description = description
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options sets the options of the multi-select field.
|
|
||||||
func (m *MultiSelect) Options(hasNewOption bool, options ...Option[string]) *MultiSelect {
|
|
||||||
m.hasNewOption = hasNewOption
|
|
||||||
|
|
||||||
if m.hasNewOption {
|
|
||||||
newOption := []Option[string]{
|
|
||||||
{Key: "(new)", Value: ""},
|
|
||||||
}
|
|
||||||
options = append(newOption, options...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(options) <= 0 {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, o := range options {
|
|
||||||
for _, v := range *m.value {
|
|
||||||
if o.Value == v {
|
|
||||||
options[i].selected = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.options = options
|
|
||||||
m.filteredOptions = options
|
|
||||||
m.updateViewportHeight()
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filterable sets the multi-select field as filterable.
|
|
||||||
func (m *MultiSelect) Filterable(filterable bool) *MultiSelect {
|
|
||||||
m.filterable = filterable
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit sets the limit of the multi-select field.
|
|
||||||
func (m *MultiSelect) Limit(limit int) *MultiSelect {
|
|
||||||
m.limit = limit
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Height sets the height of the multi-select field.
|
|
||||||
func (m *MultiSelect) Height(height int) *MultiSelect {
|
|
||||||
// What we really want to do is set the height of the viewport, but we
|
|
||||||
// need a theme applied before we can calcualate its height.
|
|
||||||
m.height = height
|
|
||||||
m.updateViewportHeight()
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate sets the validation function of the multi-select field.
|
|
||||||
func (m *MultiSelect) Validate(validate func([]string) error) *MultiSelect {
|
|
||||||
m.validate = validate
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error returns the error of the multi-select field.
|
|
||||||
func (m *MultiSelect) Error() error {
|
|
||||||
return m.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip returns whether the multiselect should be skipped or should be blocking.
|
|
||||||
func (*MultiSelect) Skip() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zoom returns whether the multiselect should be zoomed.
|
|
||||||
func (*MultiSelect) Zoom() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus focuses the multi-select field.
|
|
||||||
func (m *MultiSelect) Focus() tea.Cmd {
|
|
||||||
m.focused = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur blurs the multi-select field.
|
|
||||||
func (m *MultiSelect) Blur() tea.Cmd {
|
|
||||||
m.focused = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeyBinds returns the help message for the multi-select field.
|
|
||||||
func (m *MultiSelect) KeyBinds() []key.Binding {
|
|
||||||
return []key.Binding{
|
|
||||||
m.keymap.Toggle,
|
|
||||||
m.keymap.Up,
|
|
||||||
m.keymap.Down,
|
|
||||||
m.keymap.Filter,
|
|
||||||
m.keymap.SetFilter,
|
|
||||||
m.keymap.ClearFilter,
|
|
||||||
m.keymap.Prev,
|
|
||||||
m.keymap.Submit,
|
|
||||||
m.keymap.Next,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes the multi-select field.
|
|
||||||
func (m *MultiSelect) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates the multi-select field.
|
|
||||||
func (m *MultiSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
// Enforce height on the viewport during update as we need themes to
|
|
||||||
// be applied before we can calculate the height.
|
|
||||||
m.updateViewportHeight()
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
if m.filtering {
|
|
||||||
m.filter, cmd = m.filter.Update(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.newInputActive {
|
|
||||||
m.newInput, cmd = m.newInput.Update(msg)
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, m.common.Keymap.Ok):
|
|
||||||
newOptions := []Option[string]{}
|
|
||||||
for _, item := range strings.Split(m.newInput.Value(), " ") {
|
|
||||||
newOptions = append(newOptions, Option[string]{
|
|
||||||
Key: item,
|
|
||||||
Value: item,
|
|
||||||
selected: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
m.options = append(m.options, newOptions...)
|
|
||||||
filteredNewOptions := []Option[string]{}
|
|
||||||
for _, item := range newOptions {
|
|
||||||
if m.filterFunc(item.Key) {
|
|
||||||
filteredNewOptions = append(filteredNewOptions, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.filteredOptions = append(m.filteredOptions, filteredNewOptions...)
|
|
||||||
m.newInputActive = false
|
|
||||||
m.newInput.SetValue("")
|
|
||||||
m.newInput.Blur()
|
|
||||||
case key.Matches(msg, m.common.Keymap.Back):
|
|
||||||
m.newInputActive = false
|
|
||||||
m.newInput.Blur()
|
|
||||||
return m, SuppressBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
|
|
||||||
m.err = nil
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, m.keymap.Filter):
|
|
||||||
m.setFilter(true)
|
|
||||||
return m, m.filter.Focus()
|
|
||||||
case key.Matches(msg, m.keymap.SetFilter) && m.filtering:
|
|
||||||
if len(m.filteredOptions) <= 0 {
|
|
||||||
m.filter.SetValue("")
|
|
||||||
m.filteredOptions = m.options
|
|
||||||
}
|
|
||||||
m.setFilter(false)
|
|
||||||
case key.Matches(msg, m.common.Keymap.Back) && m.filtering:
|
|
||||||
m.filter.SetValue("")
|
|
||||||
m.filteredOptions = m.options
|
|
||||||
m.setFilter(false)
|
|
||||||
case key.Matches(msg, m.keymap.ClearFilter):
|
|
||||||
m.filter.SetValue("")
|
|
||||||
m.filteredOptions = m.options
|
|
||||||
m.setFilter(false)
|
|
||||||
case key.Matches(msg, m.keymap.Up):
|
|
||||||
if m.filtering && msg.String() == "k" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
m.cursor = max(m.cursor-1, 0)
|
|
||||||
if m.cursor < m.viewport.YOffset {
|
|
||||||
m.viewport.SetYOffset(m.cursor)
|
|
||||||
}
|
|
||||||
case key.Matches(msg, m.keymap.Down):
|
|
||||||
if m.filtering && msg.String() == "j" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
m.cursor = min(m.cursor+1, len(m.filteredOptions)-1)
|
|
||||||
if m.cursor >= m.viewport.YOffset+m.viewport.Height {
|
|
||||||
m.viewport.LineDown(1)
|
|
||||||
}
|
|
||||||
case key.Matches(msg, m.keymap.GotoTop):
|
|
||||||
if m.filtering {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
m.cursor = 0
|
|
||||||
m.viewport.GotoTop()
|
|
||||||
case key.Matches(msg, m.keymap.GotoBottom):
|
|
||||||
if m.filtering {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
m.cursor = len(m.filteredOptions) - 1
|
|
||||||
m.viewport.GotoBottom()
|
|
||||||
case key.Matches(msg, m.keymap.HalfPageUp):
|
|
||||||
m.cursor = max(m.cursor-m.viewport.Height/2, 0)
|
|
||||||
m.viewport.HalfViewUp()
|
|
||||||
case key.Matches(msg, m.keymap.HalfPageDown):
|
|
||||||
m.cursor = min(m.cursor+m.viewport.Height/2, len(m.filteredOptions)-1)
|
|
||||||
m.viewport.HalfViewDown()
|
|
||||||
case key.Matches(msg, m.keymap.Toggle) && !m.filtering:
|
|
||||||
if m.hasNewOption && m.cursor == 0 {
|
|
||||||
m.newInputActive = true
|
|
||||||
m.newInput.Focus()
|
|
||||||
} else {
|
|
||||||
for i, option := range m.options {
|
|
||||||
if option.Key == m.filteredOptions[m.cursor].Key {
|
|
||||||
if !m.options[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
selected := m.options[i].selected
|
|
||||||
m.options[i].selected = !selected
|
|
||||||
m.filteredOptions[m.cursor].selected = !selected
|
|
||||||
m.finalize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case key.Matches(msg, m.keymap.Prev):
|
|
||||||
m.finalize()
|
|
||||||
if m.err != nil {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, huh.PrevField
|
|
||||||
case key.Matches(msg, m.keymap.Next, m.keymap.Submit):
|
|
||||||
m.finalize()
|
|
||||||
if m.err != nil {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, huh.NextField
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.filtering {
|
|
||||||
m.filteredOptions = m.options
|
|
||||||
if m.filter.Value() != "" {
|
|
||||||
m.filteredOptions = nil
|
|
||||||
for _, option := range m.options {
|
|
||||||
if m.filterFunc(option.Key) {
|
|
||||||
m.filteredOptions = append(m.filteredOptions, option)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(m.filteredOptions) > 0 {
|
|
||||||
m.cursor = min(m.cursor, len(m.filteredOptions)-1)
|
|
||||||
m.viewport.SetYOffset(clamp(m.cursor, 0, len(m.filteredOptions)-m.viewport.Height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateViewportHeight updates the viewport size according to the Height setting
|
|
||||||
// on this multi-select field.
|
|
||||||
func (m *MultiSelect) updateViewportHeight() {
|
|
||||||
// If no height is set size the viewport to the number of options.
|
|
||||||
if m.height <= 0 {
|
|
||||||
m.viewport.Height = len(m.options)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const minHeight = 1
|
|
||||||
m.viewport.Height = max(minHeight, m.height-
|
|
||||||
lipgloss.Height(m.titleView())-
|
|
||||||
lipgloss.Height(m.descriptionView()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MultiSelect) numSelected() int {
|
|
||||||
var count int
|
|
||||||
for _, o := range m.options {
|
|
||||||
if o.selected {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MultiSelect) finalize() {
|
|
||||||
*m.value = make([]string, 0)
|
|
||||||
for _, option := range m.options {
|
|
||||||
if option.selected {
|
|
||||||
*m.value = append(*m.value, option.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.err = m.validate(*m.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MultiSelect) activeStyles() *huh.FieldStyles {
|
|
||||||
theme := m.theme
|
|
||||||
if theme == nil {
|
|
||||||
theme = huh.ThemeCharm()
|
|
||||||
}
|
|
||||||
if m.focused {
|
|
||||||
return &theme.Focused
|
|
||||||
}
|
|
||||||
return &theme.Blurred
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MultiSelect) titleView() string {
|
|
||||||
if m.title == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var (
|
|
||||||
styles = m.activeStyles()
|
|
||||||
sb = strings.Builder{}
|
|
||||||
)
|
|
||||||
if m.filtering {
|
|
||||||
sb.WriteString(m.filter.View())
|
|
||||||
} else if m.filter.Value() != "" {
|
|
||||||
sb.WriteString(styles.Title.Render(m.title) + styles.Description.Render("/"+m.filter.Value()))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(styles.Title.Render(m.title))
|
|
||||||
}
|
|
||||||
if m.err != nil {
|
|
||||||
sb.WriteString(styles.ErrorIndicator.String())
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MultiSelect) descriptionView() string {
|
|
||||||
return m.activeStyles().Description.Render(m.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MultiSelect) choicesView() string {
|
|
||||||
var (
|
|
||||||
styles = m.activeStyles()
|
|
||||||
c = styles.MultiSelectSelector.String()
|
|
||||||
sb strings.Builder
|
|
||||||
)
|
|
||||||
for i, option := range m.filteredOptions {
|
|
||||||
if m.newInputActive && i == 0 {
|
|
||||||
sb.WriteString(c)
|
|
||||||
sb.WriteString(m.newInput.View())
|
|
||||||
sb.WriteString("\n")
|
|
||||||
continue
|
|
||||||
} else if m.cursor == i {
|
|
||||||
sb.WriteString(c)
|
|
||||||
} else {
|
|
||||||
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.filteredOptions[i].selected {
|
|
||||||
sb.WriteString(styles.SelectedPrefix.String())
|
|
||||||
sb.WriteString(styles.SelectedOption.Render(option.Key))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(styles.UnselectedPrefix.String())
|
|
||||||
sb.WriteString(styles.UnselectedOption.Render(option.Key))
|
|
||||||
}
|
|
||||||
if i < len(m.options)-1 {
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := len(m.filteredOptions); i < len(m.options)-1; i++ {
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// View renders the multi-select field.
|
|
||||||
func (m *MultiSelect) View() string {
|
|
||||||
styles := m.activeStyles()
|
|
||||||
m.viewport.SetContent(m.choicesView())
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
if m.title != "" {
|
|
||||||
sb.WriteString(m.titleView())
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
if m.description != "" {
|
|
||||||
sb.WriteString(m.descriptionView() + "\n")
|
|
||||||
}
|
|
||||||
sb.WriteString(m.viewport.View())
|
|
||||||
return styles.Base.Render(sb.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MultiSelect) printOptions() {
|
|
||||||
styles := m.activeStyles()
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
sb.WriteString(styles.Title.Render(m.title))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
|
|
||||||
for i, option := range m.options {
|
|
||||||
if option.selected {
|
|
||||||
sb.WriteString(styles.SelectedOption.Render(fmt.Sprintf("%d. %s %s", i+1, "✓", option.Key)))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(fmt.Sprintf("%d. %s %s", i+1, " ", option.Key))
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(sb.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// setFilter sets the filter of the select field.
|
|
||||||
func (m *MultiSelect) setFilter(filter bool) {
|
|
||||||
m.filtering = filter
|
|
||||||
m.keymap.SetFilter.SetEnabled(filter)
|
|
||||||
m.keymap.Filter.SetEnabled(!filter)
|
|
||||||
m.keymap.Next.SetEnabled(!filter)
|
|
||||||
m.keymap.Submit.SetEnabled(!filter)
|
|
||||||
m.keymap.Prev.SetEnabled(!filter)
|
|
||||||
m.keymap.ClearFilter.SetEnabled(!filter && m.filter.Value() != "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterFunc returns true if the option matches the filter.
|
|
||||||
func (m *MultiSelect) filterFunc(option string) bool {
|
|
||||||
// XXX: remove diacritics or allow customization of filter function.
|
|
||||||
return strings.Contains(strings.ToLower(option), strings.ToLower(m.filter.Value()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run runs the multi-select field.
|
|
||||||
func (m *MultiSelect) Run() error {
|
|
||||||
if m.accessible {
|
|
||||||
return m.runAccessible()
|
|
||||||
}
|
|
||||||
return huh.Run(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runAccessible() runs the multi-select field in accessible mode.
|
|
||||||
func (m *MultiSelect) runAccessible() error {
|
|
||||||
m.printOptions()
|
|
||||||
styles := m.activeStyles()
|
|
||||||
|
|
||||||
var choice int
|
|
||||||
for {
|
|
||||||
fmt.Printf("Select up to %d options. 0 to continue.\n", m.limit)
|
|
||||||
|
|
||||||
choice = accessibility.PromptInt("Select: ", 0, len(m.options))
|
|
||||||
if choice == 0 {
|
|
||||||
m.finalize()
|
|
||||||
err := m.validate(*m.value)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if !m.options[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit {
|
|
||||||
fmt.Printf("You can't select more than %d options.\n", m.limit)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m.options[choice-1].selected = !m.options[choice-1].selected
|
|
||||||
if m.options[choice-1].selected {
|
|
||||||
fmt.Printf("Selected: %s\n\n", m.options[choice-1].Key)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Deselected: %s\n\n", m.options[choice-1].Key)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.printOptions()
|
|
||||||
}
|
|
||||||
|
|
||||||
var values []string
|
|
||||||
|
|
||||||
for _, option := range m.options {
|
|
||||||
if option.selected {
|
|
||||||
*m.value = append(*m.value, option.Value)
|
|
||||||
values = append(values, option.Key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(styles.SelectedOption.Render("Selected:", strings.Join(values, ", ")+"\n"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTheme sets the theme of the multi-select field.
|
|
||||||
func (m *MultiSelect) WithTheme(theme *huh.Theme) huh.Field {
|
|
||||||
if m.theme != nil {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
m.theme = theme
|
|
||||||
m.filter.Cursor.Style = m.theme.Focused.TextInput.Cursor
|
|
||||||
m.filter.PromptStyle = m.theme.Focused.TextInput.Prompt
|
|
||||||
m.updateViewportHeight()
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithKeyMap sets the keymap of the multi-select field.
|
|
||||||
func (m *MultiSelect) WithKeyMap(k *huh.KeyMap) huh.Field {
|
|
||||||
m.keymap = k.MultiSelect
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithAccessible sets the accessible mode of the multi-select field.
|
|
||||||
func (m *MultiSelect) WithAccessible(accessible bool) huh.Field {
|
|
||||||
m.accessible = accessible
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithWidth sets the width of the multi-select field.
|
|
||||||
func (m *MultiSelect) WithWidth(width int) huh.Field {
|
|
||||||
m.width = width
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithHeight sets the height of the multi-select field.
|
|
||||||
func (m *MultiSelect) WithHeight(height int) huh.Field {
|
|
||||||
m.height = height
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithPosition sets the position of the multi-select field.
|
|
||||||
func (m *MultiSelect) WithPosition(p huh.FieldPosition) huh.Field {
|
|
||||||
if m.filtering {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
m.keymap.Prev.SetEnabled(!p.IsFirst())
|
|
||||||
m.keymap.Next.SetEnabled(!p.IsLast())
|
|
||||||
m.keymap.Submit.SetEnabled(p.IsLast())
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetKey returns the multi-select's key.
|
|
||||||
func (m *MultiSelect) GetKey() string {
|
|
||||||
return m.key
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValue returns the multi-select's value.
|
|
||||||
func (m *MultiSelect) GetValue() any {
|
|
||||||
return *m.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsFiltering returns true if the multi-select is currently filtering.
|
|
||||||
func (m *MultiSelect) IsFiltering() bool {
|
|
||||||
return m.filtering
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func max(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func clamp(n, low, high int) int {
|
|
||||||
if low > high {
|
|
||||||
low, high = high, low
|
|
||||||
}
|
|
||||||
return min(high, max(low, n))
|
|
||||||
}
|
|
||||||
|
|
||||||
type SuppressBackMsg struct{}
|
|
||||||
|
|
||||||
func SuppressBack() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return SuppressBackMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package input
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// Option is an option for select fields.
|
|
||||||
type Option[T comparable] struct {
|
|
||||||
Key string
|
|
||||||
Value T
|
|
||||||
selected bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOptions returns new options from a list of values.
|
|
||||||
func NewOptions[T comparable](values ...T) []Option[T] {
|
|
||||||
options := make([]Option[T], len(values))
|
|
||||||
for i, o := range values {
|
|
||||||
options[i] = Option[T]{
|
|
||||||
Key: fmt.Sprint(o),
|
|
||||||
Value: o,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOption returns a new select option.
|
|
||||||
func NewOption[T comparable](key string, value T) Option[T] {
|
|
||||||
return Option[T]{Key: key, Value: value}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selected sets whether the option is currently selected.
|
|
||||||
func (o Option[T]) Selected(selected bool) Option[T] {
|
|
||||||
o.selected = selected
|
|
||||||
return o
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the key of the option.
|
|
||||||
func (o Option[T]) String() string {
|
|
||||||
return o.Key
|
|
||||||
}
|
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
package input
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"tasksquire/common"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/huh/accessibility"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Select is a form select field.
|
|
||||||
type Select struct {
|
|
||||||
common *common.Common
|
|
||||||
|
|
||||||
value *string
|
|
||||||
key string
|
|
||||||
viewport viewport.Model
|
|
||||||
|
|
||||||
// customization
|
|
||||||
title string
|
|
||||||
description string
|
|
||||||
options []Option[string]
|
|
||||||
filteredOptions []Option[string]
|
|
||||||
height int
|
|
||||||
|
|
||||||
// error handling
|
|
||||||
validate func(string) error
|
|
||||||
err error
|
|
||||||
|
|
||||||
// state
|
|
||||||
selected int
|
|
||||||
focused bool
|
|
||||||
filtering bool
|
|
||||||
filter textinput.Model
|
|
||||||
|
|
||||||
// options
|
|
||||||
inline bool
|
|
||||||
width int
|
|
||||||
accessible bool
|
|
||||||
theme *huh.Theme
|
|
||||||
keymap huh.SelectKeyMap
|
|
||||||
|
|
||||||
// new
|
|
||||||
hasNewOption bool
|
|
||||||
newInput textinput.Model
|
|
||||||
newInputActive bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSelect returns a new select field.
|
|
||||||
func NewSelect(com *common.Common) *Select {
|
|
||||||
filter := textinput.New()
|
|
||||||
filter.Prompt = "/"
|
|
||||||
|
|
||||||
newInput := textinput.New()
|
|
||||||
newInput.Prompt = "New: "
|
|
||||||
|
|
||||||
return &Select{
|
|
||||||
common: com,
|
|
||||||
options: []Option[string]{},
|
|
||||||
value: new(string),
|
|
||||||
validate: func(string) error { return nil },
|
|
||||||
filtering: false,
|
|
||||||
filter: filter,
|
|
||||||
newInput: newInput,
|
|
||||||
newInputActive: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Value sets the value of the select field.
|
|
||||||
func (s *Select) Value(value *string) *Select {
|
|
||||||
s.value = value
|
|
||||||
s.selectValue(*value)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Select) selectValue(value string) {
|
|
||||||
for i, o := range s.options {
|
|
||||||
if o.Value == value {
|
|
||||||
s.selected = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key sets the key of the select field which can be used to retrieve the value
|
|
||||||
// after submission.
|
|
||||||
func (s *Select) Key(key string) *Select {
|
|
||||||
s.key = key
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title sets the title of the select field.
|
|
||||||
func (s *Select) Title(title string) *Select {
|
|
||||||
s.title = title
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description sets the description of the select field.
|
|
||||||
func (s *Select) Description(description string) *Select {
|
|
||||||
s.description = description
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options sets the options of the select field.
|
|
||||||
func (s *Select) Options(hasNewOption bool, options ...Option[string]) *Select {
|
|
||||||
s.hasNewOption = hasNewOption
|
|
||||||
|
|
||||||
if s.hasNewOption {
|
|
||||||
newOption := []Option[string]{
|
|
||||||
{Key: "(new)", Value: ""},
|
|
||||||
}
|
|
||||||
options = append(newOption, options...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(options) <= 0 {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
s.options = options
|
|
||||||
s.filteredOptions = options
|
|
||||||
|
|
||||||
// Set the cursor to the existing value or the last selected option.
|
|
||||||
for i, option := range options {
|
|
||||||
if option.Value == *s.value {
|
|
||||||
s.selected = i
|
|
||||||
break
|
|
||||||
} else if option.selected {
|
|
||||||
s.selected = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.updateViewportHeight()
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline sets whether the select input should be inline.
|
|
||||||
func (s *Select) Inline(v bool) *Select {
|
|
||||||
s.inline = v
|
|
||||||
if v {
|
|
||||||
s.Height(1)
|
|
||||||
}
|
|
||||||
s.keymap.Left.SetEnabled(v)
|
|
||||||
s.keymap.Right.SetEnabled(v)
|
|
||||||
s.keymap.Up.SetEnabled(!v)
|
|
||||||
s.keymap.Down.SetEnabled(!v)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Height sets the height of the select field. If the number of options
|
|
||||||
// exceeds the height, the select field will become scrollable.
|
|
||||||
func (s *Select) Height(height int) *Select {
|
|
||||||
s.height = height
|
|
||||||
s.updateViewportHeight()
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate sets the validation function of the select field.
|
|
||||||
func (s *Select) Validate(validate func(string) error) *Select {
|
|
||||||
s.validate = validate
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error returns the error of the select field.
|
|
||||||
func (s *Select) Error() error {
|
|
||||||
return s.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip returns whether the select should be skipped or should be blocking.
|
|
||||||
func (*Select) Skip() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zoom returns whether the input should be zoomed.
|
|
||||||
func (*Select) Zoom() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus focuses the select field.
|
|
||||||
func (s *Select) Focus() tea.Cmd {
|
|
||||||
s.focused = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur blurs the select field.
|
|
||||||
func (s *Select) Blur() tea.Cmd {
|
|
||||||
value := *s.value
|
|
||||||
if s.inline {
|
|
||||||
s.clearFilter()
|
|
||||||
s.selectValue(value)
|
|
||||||
}
|
|
||||||
s.focused = false
|
|
||||||
s.err = s.validate(value)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeyBinds returns the help keybindings for the select field.
|
|
||||||
func (s *Select) KeyBinds() []key.Binding {
|
|
||||||
return []key.Binding{
|
|
||||||
s.keymap.Up,
|
|
||||||
s.keymap.Down,
|
|
||||||
s.keymap.Left,
|
|
||||||
s.keymap.Right,
|
|
||||||
s.keymap.Filter,
|
|
||||||
s.keymap.SetFilter,
|
|
||||||
s.keymap.ClearFilter,
|
|
||||||
s.keymap.Prev,
|
|
||||||
s.keymap.Next,
|
|
||||||
s.keymap.Submit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes the select field.
|
|
||||||
func (s *Select) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates the select field.
|
|
||||||
func (s *Select) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
s.updateViewportHeight()
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
if s.filtering {
|
|
||||||
s.filter, cmd = s.filter.Update(msg)
|
|
||||||
|
|
||||||
// Keep the selected item in view.
|
|
||||||
if s.selected < s.viewport.YOffset || s.selected >= s.viewport.YOffset+s.viewport.Height {
|
|
||||||
s.viewport.SetYOffset(s.selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.newInputActive {
|
|
||||||
s.newInput, cmd = s.newInput.Update(msg)
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, s.common.Keymap.Ok):
|
|
||||||
if s.newInput.Value() != "" {
|
|
||||||
newOption := Option[string]{
|
|
||||||
Key: s.newInput.Value(),
|
|
||||||
Value: s.newInput.Value(),
|
|
||||||
selected: true,
|
|
||||||
}
|
|
||||||
s.options = append(s.options, newOption)
|
|
||||||
if s.filterFunc(newOption.Key) {
|
|
||||||
s.filteredOptions = append(s.filteredOptions, newOption)
|
|
||||||
}
|
|
||||||
s.selected = len(s.options) - 1
|
|
||||||
|
|
||||||
value := newOption.Value
|
|
||||||
s.setFiltering(false)
|
|
||||||
s.err = s.validate(value)
|
|
||||||
if s.err != nil {
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
*s.value = value
|
|
||||||
}
|
|
||||||
s.newInputActive = false
|
|
||||||
s.newInput.SetValue("")
|
|
||||||
s.newInput.Blur()
|
|
||||||
return s, nil
|
|
||||||
case key.Matches(msg, s.common.Keymap.Back):
|
|
||||||
s.newInputActive = false
|
|
||||||
s.newInput.Blur()
|
|
||||||
return s, SuppressBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
s.err = nil
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, s.keymap.Filter):
|
|
||||||
s.setFiltering(true)
|
|
||||||
return s, s.filter.Focus()
|
|
||||||
case key.Matches(msg, s.keymap.SetFilter):
|
|
||||||
if len(s.filteredOptions) <= 0 {
|
|
||||||
s.filter.SetValue("")
|
|
||||||
s.filteredOptions = s.options
|
|
||||||
}
|
|
||||||
s.setFiltering(false)
|
|
||||||
case key.Matches(msg, s.keymap.ClearFilter):
|
|
||||||
s.clearFilter()
|
|
||||||
case key.Matches(msg, s.keymap.Up, s.keymap.Left):
|
|
||||||
// When filtering we should ignore j/k keybindings
|
|
||||||
//
|
|
||||||
// XXX: Currently, the below check doesn't account for keymap
|
|
||||||
// changes. When making this fix it's worth considering ignoring
|
|
||||||
// whether to ignore all up/down keybindings as ignoring a-zA-Z0-9
|
|
||||||
// may not be enough when international keyboards are considered.
|
|
||||||
if s.filtering && (msg.String() == "k" || msg.String() == "h") {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
s.selected = max(s.selected-1, 0)
|
|
||||||
if s.selected < s.viewport.YOffset {
|
|
||||||
s.viewport.SetYOffset(s.selected)
|
|
||||||
}
|
|
||||||
case key.Matches(msg, s.keymap.GotoTop):
|
|
||||||
if s.filtering {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
s.selected = 0
|
|
||||||
s.viewport.GotoTop()
|
|
||||||
case key.Matches(msg, s.keymap.GotoBottom):
|
|
||||||
if s.filtering {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
s.selected = len(s.filteredOptions) - 1
|
|
||||||
s.viewport.GotoBottom()
|
|
||||||
case key.Matches(msg, s.keymap.HalfPageUp):
|
|
||||||
s.selected = max(s.selected-s.viewport.Height/2, 0)
|
|
||||||
s.viewport.HalfViewUp()
|
|
||||||
case key.Matches(msg, s.keymap.HalfPageDown):
|
|
||||||
s.selected = min(s.selected+s.viewport.Height/2, len(s.filteredOptions)-1)
|
|
||||||
s.viewport.HalfViewDown()
|
|
||||||
case key.Matches(msg, s.keymap.Down, s.keymap.Right):
|
|
||||||
// When filtering we should ignore j/k keybindings
|
|
||||||
//
|
|
||||||
// XXX: See note in the previous case match.
|
|
||||||
if s.filtering && (msg.String() == "j" || msg.String() == "l") {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
s.selected = min(s.selected+1, len(s.filteredOptions)-1)
|
|
||||||
if s.selected >= s.viewport.YOffset+s.viewport.Height {
|
|
||||||
s.viewport.LineDown(1)
|
|
||||||
}
|
|
||||||
case key.Matches(msg, s.keymap.Prev):
|
|
||||||
if s.selected >= len(s.filteredOptions) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
value := s.filteredOptions[s.selected].Value
|
|
||||||
s.err = s.validate(value)
|
|
||||||
if s.err != nil {
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
*s.value = value
|
|
||||||
return s, huh.PrevField
|
|
||||||
case key.Matches(msg, s.common.Keymap.Select):
|
|
||||||
if s.hasNewOption && s.selected == 0 {
|
|
||||||
s.newInputActive = true
|
|
||||||
s.newInput.Focus()
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
case key.Matches(msg, s.keymap.Next, s.keymap.Submit):
|
|
||||||
if s.selected >= len(s.filteredOptions) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
value := s.filteredOptions[s.selected].Value
|
|
||||||
s.setFiltering(false)
|
|
||||||
s.err = s.validate(value)
|
|
||||||
if s.err != nil {
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
*s.value = value
|
|
||||||
return s, huh.NextField
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.filtering {
|
|
||||||
s.filteredOptions = s.options
|
|
||||||
if s.filter.Value() != "" {
|
|
||||||
s.filteredOptions = nil
|
|
||||||
for _, option := range s.options {
|
|
||||||
if s.filterFunc(option.Key) {
|
|
||||||
s.filteredOptions = append(s.filteredOptions, option)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(s.filteredOptions) > 0 {
|
|
||||||
s.selected = min(s.selected, len(s.filteredOptions)-1)
|
|
||||||
s.viewport.SetYOffset(clamp(s.selected, 0, len(s.filteredOptions)-s.viewport.Height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateViewportHeight updates the viewport size according to the Height setting
|
|
||||||
// on this select field.
|
|
||||||
func (s *Select) updateViewportHeight() {
|
|
||||||
// If no height is set size the viewport to the number of options.
|
|
||||||
if s.height <= 0 {
|
|
||||||
s.viewport.Height = len(s.options)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const minHeight = 1
|
|
||||||
s.viewport.Height = max(minHeight, s.height-
|
|
||||||
lipgloss.Height(s.titleView())-
|
|
||||||
lipgloss.Height(s.descriptionView()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Select) activeStyles() *huh.FieldStyles {
|
|
||||||
theme := s.theme
|
|
||||||
if theme == nil {
|
|
||||||
theme = huh.ThemeCharm()
|
|
||||||
}
|
|
||||||
if s.focused {
|
|
||||||
return &theme.Focused
|
|
||||||
}
|
|
||||||
return &theme.Blurred
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Select) titleView() string {
|
|
||||||
if s.title == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var (
|
|
||||||
styles = s.activeStyles()
|
|
||||||
sb = strings.Builder{}
|
|
||||||
)
|
|
||||||
if s.filtering {
|
|
||||||
sb.WriteString(styles.Title.Render(s.filter.View()))
|
|
||||||
} else if s.filter.Value() != "" && !s.inline {
|
|
||||||
sb.WriteString(styles.Title.Render(s.title) + styles.Description.Render("/"+s.filter.Value()))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(styles.Title.Render(s.title))
|
|
||||||
}
|
|
||||||
if s.err != nil {
|
|
||||||
sb.WriteString(styles.ErrorIndicator.String())
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Select) descriptionView() string {
|
|
||||||
return s.activeStyles().Description.Render(s.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Select) choicesView() string {
|
|
||||||
var (
|
|
||||||
styles = s.activeStyles()
|
|
||||||
c = styles.SelectSelector.String()
|
|
||||||
sb strings.Builder
|
|
||||||
)
|
|
||||||
|
|
||||||
if s.inline {
|
|
||||||
sb.WriteString(styles.PrevIndicator.Faint(s.selected <= 0).String())
|
|
||||||
if len(s.filteredOptions) > 0 {
|
|
||||||
sb.WriteString(styles.SelectedOption.Render(s.filteredOptions[s.selected].Key))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(styles.TextInput.Placeholder.Render("No matches"))
|
|
||||||
}
|
|
||||||
sb.WriteString(styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String())
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, option := range s.filteredOptions {
|
|
||||||
if s.newInputActive && i == 0 {
|
|
||||||
sb.WriteString(c)
|
|
||||||
sb.WriteString(s.newInput.View())
|
|
||||||
sb.WriteString("\n")
|
|
||||||
continue
|
|
||||||
} else if s.selected == i {
|
|
||||||
sb.WriteString(c + styles.SelectedOption.Render(option.Key))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.Option.Render(option.Key))
|
|
||||||
}
|
|
||||||
if i < len(s.options)-1 {
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := len(s.filteredOptions); i < len(s.options)-1; i++ {
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// View renders the select field.
|
|
||||||
func (s *Select) View() string {
|
|
||||||
styles := s.activeStyles()
|
|
||||||
s.viewport.SetContent(s.choicesView())
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
if s.title != "" {
|
|
||||||
sb.WriteString(s.titleView())
|
|
||||||
if !s.inline {
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.description != "" {
|
|
||||||
sb.WriteString(s.descriptionView())
|
|
||||||
if !s.inline {
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString(s.viewport.View())
|
|
||||||
return styles.Base.Render(sb.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearFilter clears the value of the filter.
|
|
||||||
func (s *Select) clearFilter() {
|
|
||||||
s.filter.SetValue("")
|
|
||||||
s.filteredOptions = s.options
|
|
||||||
s.setFiltering(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setFiltering sets the filter of the select field.
|
|
||||||
func (s *Select) setFiltering(filtering bool) {
|
|
||||||
if s.inline && filtering {
|
|
||||||
s.filter.Width = lipgloss.Width(s.titleView()) - 1 - 1
|
|
||||||
}
|
|
||||||
s.filtering = filtering
|
|
||||||
s.keymap.SetFilter.SetEnabled(filtering)
|
|
||||||
s.keymap.Filter.SetEnabled(!filtering)
|
|
||||||
s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterFunc returns true if the option matches the filter.
|
|
||||||
func (s *Select) filterFunc(option string) bool {
|
|
||||||
// XXX: remove diacritics or allow customization of filter function.
|
|
||||||
return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run runs the select field.
|
|
||||||
func (s *Select) Run() error {
|
|
||||||
if s.accessible {
|
|
||||||
return s.runAccessible()
|
|
||||||
}
|
|
||||||
return huh.Run(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runAccessible runs an accessible select field.
|
|
||||||
func (s *Select) runAccessible() error {
|
|
||||||
var sb strings.Builder
|
|
||||||
styles := s.activeStyles()
|
|
||||||
|
|
||||||
sb.WriteString(styles.Title.Render(s.title) + "\n")
|
|
||||||
|
|
||||||
for i, option := range s.options {
|
|
||||||
sb.WriteString(fmt.Sprintf("%d. %s", i+1, option.Key))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(sb.String())
|
|
||||||
|
|
||||||
for {
|
|
||||||
choice := accessibility.PromptInt("Choose: ", 1, len(s.options))
|
|
||||||
option := s.options[choice-1]
|
|
||||||
if err := s.validate(option.Value); err != nil {
|
|
||||||
fmt.Println(err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Println(styles.SelectedOption.Render("Chose: " + option.Key + "\n"))
|
|
||||||
*s.value = option.Value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTheme sets the theme of the select field.
|
|
||||||
func (s *Select) WithTheme(theme *huh.Theme) huh.Field {
|
|
||||||
if s.theme != nil {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
s.theme = theme
|
|
||||||
s.filter.Cursor.Style = s.theme.Focused.TextInput.Cursor
|
|
||||||
s.filter.PromptStyle = s.theme.Focused.TextInput.Prompt
|
|
||||||
s.updateViewportHeight()
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithKeyMap sets the keymap on a select field.
|
|
||||||
func (s *Select) WithKeyMap(k *huh.KeyMap) huh.Field {
|
|
||||||
s.keymap = k.Select
|
|
||||||
s.keymap.Left.SetEnabled(s.inline)
|
|
||||||
s.keymap.Right.SetEnabled(s.inline)
|
|
||||||
s.keymap.Up.SetEnabled(!s.inline)
|
|
||||||
s.keymap.Down.SetEnabled(!s.inline)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithAccessible sets the accessible mode of the select field.
|
|
||||||
func (s *Select) WithAccessible(accessible bool) huh.Field {
|
|
||||||
s.accessible = accessible
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithWidth sets the width of the select field.
|
|
||||||
func (s *Select) WithWidth(width int) huh.Field {
|
|
||||||
s.width = width
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithHeight sets the height of the select field.
|
|
||||||
func (s *Select) WithHeight(height int) huh.Field {
|
|
||||||
return s.Height(height)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithPosition sets the position of the select field.
|
|
||||||
func (s *Select) WithPosition(p huh.FieldPosition) huh.Field {
|
|
||||||
if s.filtering {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
s.keymap.Prev.SetEnabled(!p.IsFirst())
|
|
||||||
s.keymap.Next.SetEnabled(!p.IsLast())
|
|
||||||
s.keymap.Submit.SetEnabled(p.IsLast())
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetKey returns the key of the field.
|
|
||||||
func (s *Select) GetKey() string {
|
|
||||||
return s.key
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValue returns the value of the field.
|
|
||||||
func (s *Select) GetValue() any {
|
|
||||||
return *s.value
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
package picker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"tasksquire/common"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Item struct {
|
|
||||||
text string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewItem(text string) Item { return Item{text: text} }
|
|
||||||
func (i Item) Title() string { return i.text }
|
|
||||||
func (i Item) Description() string { return "" }
|
|
||||||
func (i Item) FilterValue() string { return i.text }
|
|
||||||
|
|
||||||
// creationItem is a special item for creating new entries
|
|
||||||
type creationItem struct {
|
|
||||||
text string
|
|
||||||
filter string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i creationItem) Title() string { return i.text }
|
|
||||||
func (i creationItem) Description() string { return "" }
|
|
||||||
func (i creationItem) FilterValue() string { return i.filter }
|
|
||||||
|
|
||||||
type Picker struct {
|
|
||||||
common *common.Common
|
|
||||||
list list.Model
|
|
||||||
itemProvider func() []list.Item
|
|
||||||
onSelect func(list.Item) tea.Cmd
|
|
||||||
onCreate func(string) tea.Cmd
|
|
||||||
title string
|
|
||||||
filterByDefault bool
|
|
||||||
defaultValue string
|
|
||||||
baseItems []list.Item
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type PickerOption func(*Picker)
|
|
||||||
|
|
||||||
func WithFilterByDefault(enabled bool) PickerOption {
|
|
||||||
return func(p *Picker) {
|
|
||||||
p.filterByDefault = enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption {
|
|
||||||
return func(p *Picker) {
|
|
||||||
p.onCreate = onCreate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithDefaultValue(value string) PickerOption {
|
|
||||||
return func(p *Picker) {
|
|
||||||
p.defaultValue = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) Focus() tea.Cmd {
|
|
||||||
p.focused = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) Blur() tea.Cmd {
|
|
||||||
p.focused = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) GetValue() string {
|
|
||||||
item := p.list.SelectedItem()
|
|
||||||
if item == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return item.FilterValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(
|
|
||||||
c *common.Common,
|
|
||||||
title string,
|
|
||||||
itemProvider func() []list.Item,
|
|
||||||
onSelect func(list.Item) tea.Cmd,
|
|
||||||
opts ...PickerOption,
|
|
||||||
) *Picker {
|
|
||||||
delegate := list.NewDefaultDelegate()
|
|
||||||
delegate.ShowDescription = false
|
|
||||||
delegate.SetSpacing(0)
|
|
||||||
|
|
||||||
l := list.New([]list.Item{}, delegate, 0, 0)
|
|
||||||
l.SetShowTitle(false)
|
|
||||||
l.SetShowHelp(false)
|
|
||||||
l.SetShowStatusBar(false)
|
|
||||||
l.SetFilteringEnabled(true)
|
|
||||||
l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality
|
|
||||||
|
|
||||||
// Custom key for filtering (insert mode)
|
|
||||||
l.KeyMap.Filter = key.NewBinding(
|
|
||||||
key.WithKeys("i"),
|
|
||||||
key.WithHelp("i", "filter"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Disable the quit key binding - we don't want Esc to quit the list
|
|
||||||
// Esc should only cancel filtering mode
|
|
||||||
l.KeyMap.Quit = key.NewBinding(
|
|
||||||
key.WithKeys(), // No keys bound
|
|
||||||
key.WithHelp("", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Also disable force quit
|
|
||||||
l.KeyMap.ForceQuit = key.NewBinding(
|
|
||||||
key.WithKeys(), // No keys bound
|
|
||||||
key.WithHelp("", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
p := &Picker{
|
|
||||||
common: c,
|
|
||||||
list: l,
|
|
||||||
itemProvider: itemProvider,
|
|
||||||
onSelect: onSelect,
|
|
||||||
title: title,
|
|
||||||
focused: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
|
|
||||||
p.filterByDefault = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a default value is provided, don't start in filter mode
|
|
||||||
if p.defaultValue != "" {
|
|
||||||
p.filterByDefault = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.filterByDefault {
|
|
||||||
// Manually trigger filter mode on the list so it doesn't require a global key press
|
|
||||||
p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh items after entering filter mode to ensure they're visible
|
|
||||||
p.Refresh()
|
|
||||||
|
|
||||||
// If a default value is provided, select the corresponding item
|
|
||||||
if p.defaultValue != "" {
|
|
||||||
p.SelectItemByFilterValue(p.defaultValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) Refresh() tea.Cmd {
|
|
||||||
p.baseItems = p.itemProvider()
|
|
||||||
return p.updateListItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) updateListItems() tea.Cmd {
|
|
||||||
return p.updateListItemsWithFilter(p.list.FilterValue())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd {
|
|
||||||
items := make([]list.Item, 0, len(p.baseItems)+1)
|
|
||||||
|
|
||||||
// First add all base items
|
|
||||||
items = append(items, p.baseItems...)
|
|
||||||
|
|
||||||
if p.onCreate != nil && filterVal != "" {
|
|
||||||
// Add the creation item at the end (bottom of the list)
|
|
||||||
newItem := creationItem{
|
|
||||||
text: "(new) " + filterVal,
|
|
||||||
filter: filterVal,
|
|
||||||
}
|
|
||||||
items = append(items, newItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.list.SetItems(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) SetSize(width, height int) {
|
|
||||||
// We do NOT set common.SetSize here, as we are a sub-component.
|
|
||||||
|
|
||||||
// Set list size. The parent is responsible for providing a reasonable size.
|
|
||||||
// If this component is intended to fill a page, width/height will be large.
|
|
||||||
// If it's a small embedded box, they will be small.
|
|
||||||
// We apply a small margin for the title if needed, but for now we just pass through
|
|
||||||
// minus a header gap if we render a title.
|
|
||||||
|
|
||||||
headerHeight := 2 // Title + gap
|
|
||||||
p.list.SetSize(width, height-headerHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) Init() tea.Cmd {
|
|
||||||
// Trigger list item update to ensure items are properly displayed,
|
|
||||||
// especially when in filter mode with an empty filter
|
|
||||||
return p.updateListItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if !p.focused {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
// If filtering, update items with predicted filter before list processes the key
|
|
||||||
if p.list.FilterState() == list.Filtering {
|
|
||||||
currentFilter := p.list.FilterValue()
|
|
||||||
predictedFilter := currentFilter
|
|
||||||
|
|
||||||
// Predict what the filter will be after this key
|
|
||||||
switch msg.Type {
|
|
||||||
case tea.KeyRunes:
|
|
||||||
predictedFilter = currentFilter + string(msg.Runes)
|
|
||||||
case tea.KeyBackspace:
|
|
||||||
if len(currentFilter) > 0 {
|
|
||||||
predictedFilter = currentFilter[:len(currentFilter)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update items with predicted filter before list processes the message
|
|
||||||
if predictedFilter != currentFilter {
|
|
||||||
preCmd := p.updateListItemsWithFilter(predictedFilter)
|
|
||||||
cmds = append(cmds, preCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
break // Pass to list.Update
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
|
||||||
selectedItem := p.list.SelectedItem()
|
|
||||||
if selectedItem == nil {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
return p, p.handleSelect(selectedItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.list, cmd = p.list.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
|
|
||||||
if cItem, ok := item.(creationItem); ok {
|
|
||||||
if p.onCreate != nil {
|
|
||||||
return p.onCreate(cItem.filter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p.onSelect(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) View() string {
|
|
||||||
var title string
|
|
||||||
if p.focused {
|
|
||||||
title = p.common.Styles.Form.Focused.Title.Render(p.title)
|
|
||||||
} else {
|
|
||||||
title = p.common.Styles.Form.Blurred.Title.Render(p.title)
|
|
||||||
}
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Picker) IsFiltering() bool {
|
|
||||||
return p.list.FilterState() == list.Filtering
|
|
||||||
}
|
|
||||||
|
|
||||||
// SelectItemByFilterValue selects the item with the given filter value
|
|
||||||
func (p *Picker) SelectItemByFilterValue(filterValue string) {
|
|
||||||
items := p.list.Items()
|
|
||||||
for i, item := range items {
|
|
||||||
if item.FilterValue() == filterValue {
|
|
||||||
p.list.Select(i)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,696 +0,0 @@
|
|||||||
package table
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/mattn/go-runewidth"
|
|
||||||
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Model defines a state for the table widget.
|
|
||||||
type Model struct {
|
|
||||||
common *common.Common
|
|
||||||
KeyMap KeyMap
|
|
||||||
|
|
||||||
cols []Column
|
|
||||||
rows taskwarrior.Tasks
|
|
||||||
rowStyles []lipgloss.Style
|
|
||||||
cursor int
|
|
||||||
focus bool
|
|
||||||
styles common.TableStyle
|
|
||||||
styleFunc StyleFunc
|
|
||||||
taskTree *taskwarrior.TaskTree
|
|
||||||
|
|
||||||
viewport viewport.Model
|
|
||||||
start int
|
|
||||||
end int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row represents one line in the table.
|
|
||||||
type Row *taskwarrior.Task
|
|
||||||
|
|
||||||
// Column defines the table structure.
|
|
||||||
type Column struct {
|
|
||||||
Title string
|
|
||||||
Name string
|
|
||||||
Width int
|
|
||||||
MaxWidth int
|
|
||||||
ContentWidth int
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
|
|
||||||
// is used to render the menu.
|
|
||||||
type KeyMap struct {
|
|
||||||
LineUp key.Binding
|
|
||||||
LineDown key.Binding
|
|
||||||
PageUp key.Binding
|
|
||||||
PageDown key.Binding
|
|
||||||
HalfPageUp key.Binding
|
|
||||||
HalfPageDown key.Binding
|
|
||||||
GotoTop key.Binding
|
|
||||||
GotoBottom key.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShortHelp implements the KeyMap interface.
|
|
||||||
func (km KeyMap) ShortHelp() []key.Binding {
|
|
||||||
return []key.Binding{km.LineUp, km.LineDown}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FullHelp implements the KeyMap interface.
|
|
||||||
func (km KeyMap) FullHelp() [][]key.Binding {
|
|
||||||
return [][]key.Binding{
|
|
||||||
{km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
|
|
||||||
{km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultKeyMap returns a default set of keybindings.
|
|
||||||
func DefaultKeyMap() KeyMap {
|
|
||||||
const spacebar = " "
|
|
||||||
return KeyMap{
|
|
||||||
LineUp: key.NewBinding(
|
|
||||||
key.WithKeys("up", "k"),
|
|
||||||
key.WithHelp("↑/k", "up"),
|
|
||||||
),
|
|
||||||
LineDown: key.NewBinding(
|
|
||||||
key.WithKeys("down", "j"),
|
|
||||||
key.WithHelp("↓/j", "down"),
|
|
||||||
),
|
|
||||||
PageUp: key.NewBinding(
|
|
||||||
key.WithKeys("b", "pgup"),
|
|
||||||
key.WithHelp("b/pgup", "page up"),
|
|
||||||
),
|
|
||||||
PageDown: key.NewBinding(
|
|
||||||
key.WithKeys("f", "pgdown", spacebar),
|
|
||||||
key.WithHelp("f/pgdn", "page down"),
|
|
||||||
),
|
|
||||||
HalfPageUp: key.NewBinding(
|
|
||||||
key.WithKeys("u", "ctrl+u"),
|
|
||||||
key.WithHelp("u", "½ page up"),
|
|
||||||
),
|
|
||||||
HalfPageDown: key.NewBinding(
|
|
||||||
key.WithKeys("d", "ctrl+d"),
|
|
||||||
key.WithHelp("d", "½ page down"),
|
|
||||||
),
|
|
||||||
GotoTop: key.NewBinding(
|
|
||||||
key.WithKeys("home", "g"),
|
|
||||||
key.WithHelp("g/home", "go to start"),
|
|
||||||
),
|
|
||||||
GotoBottom: key.NewBinding(
|
|
||||||
key.WithKeys("end", "G"),
|
|
||||||
key.WithHelp("G/end", "go to end"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetStyles sets the table styles.
|
|
||||||
func (m *Model) SetStyles(s common.TableStyle) {
|
|
||||||
m.styles = s
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option is used to set options in New. For example:
|
|
||||||
//
|
|
||||||
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
|
|
||||||
type Option func(*Model)
|
|
||||||
|
|
||||||
// New creates a new model for the table widget.
|
|
||||||
func New(com *common.Common, opts ...Option) Model {
|
|
||||||
m := Model{
|
|
||||||
common: com,
|
|
||||||
cursor: 0,
|
|
||||||
viewport: viewport.New(0, 20),
|
|
||||||
|
|
||||||
KeyMap: DefaultKeyMap(),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&m)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.cols = m.parseColumns(m.cols)
|
|
||||||
m.rowStyles = m.parseRowStyles(m.rows)
|
|
||||||
|
|
||||||
m.UpdateViewport()
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: dynamically read rule.precedence.color
|
|
||||||
func (m *Model) parseRowStyles(rows taskwarrior.Tasks) []lipgloss.Style {
|
|
||||||
styles := make([]lipgloss.Style, len(rows))
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return styles
|
|
||||||
}
|
|
||||||
taskstyle:
|
|
||||||
for i, task := range rows {
|
|
||||||
if task.Status == "deleted" {
|
|
||||||
if c, ok := m.common.Styles.Colors["deleted"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if task.Status == "completed" {
|
|
||||||
if c, ok := m.common.Styles.Colors["completed"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if task.Status == "pending" && task.Start != "" {
|
|
||||||
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: implement keyword
|
|
||||||
// TODO: implement tag
|
|
||||||
if task.HasTag("next") {
|
|
||||||
if c, ok := m.common.Styles.Colors["tag.next"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: implement project
|
|
||||||
if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) {
|
|
||||||
if c, ok := m.common.Styles.Colors["overdue"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if task.Scheduled != "" {
|
|
||||||
if c, ok := m.common.Styles.Colors["scheduled"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) {
|
|
||||||
if c, ok := m.common.Styles.Colors["due.today"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if task.Due != "" {
|
|
||||||
if c, ok := m.common.Styles.Colors["due"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(task.Depends) > 0 {
|
|
||||||
if c, ok := m.common.Styles.Colors["blocked"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO implement blocking
|
|
||||||
if task.Recur != "" {
|
|
||||||
if c, ok := m.common.Styles.Colors["recurring"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: make styles optional and discard if empty
|
|
||||||
if len(task.Tags) > 0 {
|
|
||||||
if c, ok := m.common.Styles.Colors["tagged"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(m.common.Udas) > 0 {
|
|
||||||
for _, uda := range m.common.Udas {
|
|
||||||
if u, ok := task.Udas[uda.Name]; ok {
|
|
||||||
if c, ok := m.common.Styles.Colors[fmt.Sprintf("uda.%s.%s", uda.Name, u)]; ok {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue taskstyle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
}
|
|
||||||
return styles
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) parseColumns(cols []Column) []Column {
|
|
||||||
if len(cols) == 0 {
|
|
||||||
return cols
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate max tree depth for indentation
|
|
||||||
maxTreeWidth := 0
|
|
||||||
if m.taskTree != nil {
|
|
||||||
for _, node := range m.taskTree.FlatList {
|
|
||||||
// Calculate indentation: depth * 2 spaces + tree characters (3 chars for "└─ ")
|
|
||||||
treeWidth := 0
|
|
||||||
if node.Depth > 0 {
|
|
||||||
treeWidth = node.Depth*2 + 3
|
|
||||||
}
|
|
||||||
// Add progress indicator width for parent tasks (e.g., " (3/5)" = 6 chars max)
|
|
||||||
if node.HasChildren() {
|
|
||||||
treeWidth += 7
|
|
||||||
}
|
|
||||||
maxTreeWidth = max(maxTreeWidth, treeWidth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, col := range cols {
|
|
||||||
for _, task := range m.rows {
|
|
||||||
contentWidth := lipgloss.Width(task.GetString(col.Name))
|
|
||||||
// Add tree width to description column
|
|
||||||
if strings.Contains(col.Name, "description") {
|
|
||||||
contentWidth += maxTreeWidth
|
|
||||||
}
|
|
||||||
col.ContentWidth = max(col.ContentWidth, contentWidth)
|
|
||||||
}
|
|
||||||
cols[i] = col
|
|
||||||
}
|
|
||||||
|
|
||||||
combinedSize := 0
|
|
||||||
nonZeroWidths := 0
|
|
||||||
descIndex := -1
|
|
||||||
for i, col := range cols {
|
|
||||||
if col.ContentWidth > 0 {
|
|
||||||
col.Width = max(col.ContentWidth, lipgloss.Width(col.Title))
|
|
||||||
nonZeroWidths++
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(col.Name, "description") {
|
|
||||||
combinedSize += col.Width
|
|
||||||
} else {
|
|
||||||
descIndex = i
|
|
||||||
}
|
|
||||||
|
|
||||||
cols[i] = col
|
|
||||||
}
|
|
||||||
|
|
||||||
if descIndex >= 0 {
|
|
||||||
cols[descIndex].Width = m.Width() - combinedSize - nonZeroWidths
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithColumns sets the table columns (headers).
|
|
||||||
func WithColumns(cols []Column) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.cols = cols
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithReport(report *taskwarrior.Report) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
columns := make([]Column, len(report.Columns))
|
|
||||||
for i, col := range report.Columns {
|
|
||||||
columns[i] = Column{
|
|
||||||
Title: report.Labels[i],
|
|
||||||
Name: col,
|
|
||||||
Width: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.cols = columns
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithRows sets the table rows (data).
|
|
||||||
func WithRows(rows taskwarrior.Tasks) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.rows = rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithRows sets the table rows (data).
|
|
||||||
func WithTasks(rows taskwarrior.Tasks) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.rows = rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithHeight sets the height of the table.
|
|
||||||
func WithHeight(h int) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.viewport.Height = h - lipgloss.Height(m.headersView())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithWidth sets the width of the table.
|
|
||||||
func WithWidth(w int) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.viewport.Width = w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithFocused sets the focus state of the table.
|
|
||||||
func WithFocused(f bool) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.focus = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithStyles sets the table styles.
|
|
||||||
func WithStyles(s common.TableStyle) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.styles = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state.
|
|
||||||
func WithStyleFunc(f StyleFunc) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.styleFunc = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithKeyMap sets the key map.
|
|
||||||
func WithKeyMap(km KeyMap) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.KeyMap = km
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTaskTree sets the task tree for hierarchical display.
|
|
||||||
func WithTaskTree(tree *taskwarrior.TaskTree) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.taskTree = tree
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update is the Bubble Tea update loop.
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|
||||||
if !m.focus {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, m.KeyMap.LineUp):
|
|
||||||
m.MoveUp(1)
|
|
||||||
case key.Matches(msg, m.KeyMap.LineDown):
|
|
||||||
m.MoveDown(1)
|
|
||||||
case key.Matches(msg, m.KeyMap.PageUp):
|
|
||||||
m.MoveUp(m.viewport.Height)
|
|
||||||
case key.Matches(msg, m.KeyMap.PageDown):
|
|
||||||
m.MoveDown(m.viewport.Height)
|
|
||||||
case key.Matches(msg, m.KeyMap.HalfPageUp):
|
|
||||||
m.MoveUp(m.viewport.Height / 2)
|
|
||||||
case key.Matches(msg, m.KeyMap.HalfPageDown):
|
|
||||||
m.MoveDown(m.viewport.Height / 2)
|
|
||||||
case key.Matches(msg, m.KeyMap.LineDown):
|
|
||||||
m.MoveDown(1)
|
|
||||||
case key.Matches(msg, m.KeyMap.GotoTop):
|
|
||||||
m.GotoTop()
|
|
||||||
case key.Matches(msg, m.KeyMap.GotoBottom):
|
|
||||||
m.GotoBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focused returns the focus state of the table.
|
|
||||||
func (m Model) Focused() bool {
|
|
||||||
return m.focus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus focuses the table, allowing the user to move around the rows and
|
|
||||||
// interact.
|
|
||||||
func (m *Model) Focus() {
|
|
||||||
m.focus = true
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur blurs the table, preventing selection or movement.
|
|
||||||
func (m *Model) Blur() {
|
|
||||||
m.focus = false
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// View renders the component.
|
|
||||||
func (m Model) View() string {
|
|
||||||
return m.headersView() + "\n" + m.viewport.View()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateViewport updates the list content based on the previously defined
|
|
||||||
// columns and rows.
|
|
||||||
func (m *Model) UpdateViewport() {
|
|
||||||
renderedRows := make([]string, 0, len(m.rows))
|
|
||||||
|
|
||||||
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
|
|
||||||
// Constant runtime, independent of number of rows in a table.
|
|
||||||
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
|
|
||||||
if m.cursor >= 0 {
|
|
||||||
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)
|
|
||||||
} else {
|
|
||||||
m.start = 0
|
|
||||||
}
|
|
||||||
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))
|
|
||||||
for i := m.start; i < m.end; i++ {
|
|
||||||
renderedRows = append(renderedRows, m.renderRow(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
m.viewport.SetContent(
|
|
||||||
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SelectedRow returns the selected row.
|
|
||||||
// You can cast it to your own implementation.
|
|
||||||
func (m Model) SelectedRow() Row {
|
|
||||||
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.rows[m.cursor]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rows returns the current rows.
|
|
||||||
func (m Model) Rows() taskwarrior.Tasks {
|
|
||||||
return m.rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// Columns returns the current columns.
|
|
||||||
func (m Model) Columns() []Column {
|
|
||||||
return m.cols
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRows sets a new rows state.
|
|
||||||
func (m *Model) SetRows(r taskwarrior.Tasks) {
|
|
||||||
m.rows = r
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetColumns sets a new columns state.
|
|
||||||
func (m *Model) SetColumns(c []Column) {
|
|
||||||
m.cols = c
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetWidth sets the width of the viewport of the table.
|
|
||||||
func (m *Model) SetWidth(w int) {
|
|
||||||
m.viewport.Width = w
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetHeight sets the height of the viewport of the table.
|
|
||||||
func (m *Model) SetHeight(h int) {
|
|
||||||
m.viewport.Height = h - lipgloss.Height(m.headersView())
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Height returns the viewport height of the table.
|
|
||||||
func (m Model) Height() int {
|
|
||||||
return m.viewport.Height
|
|
||||||
}
|
|
||||||
|
|
||||||
// Width returns the viewport width of the table.
|
|
||||||
func (m Model) Width() int {
|
|
||||||
return m.viewport.Width
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor returns the index of the selected row.
|
|
||||||
func (m Model) Cursor() int {
|
|
||||||
return m.cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCursor sets the cursor position in the table.
|
|
||||||
func (m *Model) SetCursor(n int) {
|
|
||||||
m.cursor = clamp(n, 0, len(m.rows)-1)
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveUp moves the selection up by any number of rows.
|
|
||||||
// It can not go above the first row.
|
|
||||||
func (m *Model) MoveUp(n int) {
|
|
||||||
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
|
||||||
switch {
|
|
||||||
case m.start == 0:
|
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
|
|
||||||
case m.start < m.viewport.Height:
|
|
||||||
m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height))
|
|
||||||
case m.viewport.YOffset >= 1:
|
|
||||||
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)
|
|
||||||
}
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveDown moves the selection down by any number of rows.
|
|
||||||
// It can not go below the last row.
|
|
||||||
func (m *Model) MoveDown(n int) {
|
|
||||||
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
|
|
||||||
m.UpdateViewport()
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case m.end == len(m.rows) && m.viewport.YOffset > 0:
|
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))
|
|
||||||
case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0:
|
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))
|
|
||||||
case m.viewport.YOffset > 1:
|
|
||||||
case m.cursor > m.viewport.YOffset+m.viewport.Height-1:
|
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GotoTop moves the selection to the first row.
|
|
||||||
func (m *Model) GotoTop() {
|
|
||||||
m.MoveUp(m.cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GotoBottom moves the selection to the last row.
|
|
||||||
func (m *Model) GotoBottom() {
|
|
||||||
m.MoveDown(len(m.rows))
|
|
||||||
}
|
|
||||||
|
|
||||||
// FromValues create the table rows from a simple string. It uses `\n` by
|
|
||||||
// default for getting all the rows and the given separator for the fields on
|
|
||||||
// each row.
|
|
||||||
// func (m *Model) FromValues(value, separator string) {
|
|
||||||
// rows := []Row{}
|
|
||||||
// for _, line := range strings.Split(value, "\n") {
|
|
||||||
// r := Row{}
|
|
||||||
// for _, field := range strings.Split(line, separator) {
|
|
||||||
// r = append(r, field)
|
|
||||||
// }
|
|
||||||
// rows = append(rows, r)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// m.SetRows(rows)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index.
|
|
||||||
type StyleFunc func(row, col int, value string) lipgloss.Style
|
|
||||||
|
|
||||||
func (m Model) headersView() string {
|
|
||||||
var s = make([]string, 0, len(m.cols))
|
|
||||||
for _, col := range m.cols {
|
|
||||||
if col.Width <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
|
|
||||||
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
|
|
||||||
s = append(s, m.styles.Header.Render(renderedCell))
|
|
||||||
}
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) renderRow(r int) string {
|
|
||||||
var s = make([]string, 0, len(m.cols))
|
|
||||||
|
|
||||||
// Extract tree metadata for this row
|
|
||||||
var depth int
|
|
||||||
var hasChildren bool
|
|
||||||
var progress string
|
|
||||||
if m.taskTree != nil && r < len(m.taskTree.FlatList) {
|
|
||||||
node := m.taskTree.FlatList[r]
|
|
||||||
depth = node.Depth
|
|
||||||
hasChildren = node.HasChildren()
|
|
||||||
if hasChildren {
|
|
||||||
completed, total := node.GetChildrenStatus()
|
|
||||||
progress = fmt.Sprintf(" (%d/%d)", completed, total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, col := range m.cols {
|
|
||||||
// for i, task := range m.rows[r] {
|
|
||||||
if m.cols[i].Width <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var cellStyle lipgloss.Style
|
|
||||||
// if m.styleFunc != nil {
|
|
||||||
// cellStyle = m.styleFunc(r, i, m.rows[r].GetString(col.Name))
|
|
||||||
// if r == m.cursor {
|
|
||||||
// cellStyle.Inherit(m.styles.Selected)
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
cellStyle = m.rowStyles[r]
|
|
||||||
// }
|
|
||||||
if r == m.cursor {
|
|
||||||
cellStyle = cellStyle.Inherit(m.styles.Selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render cell content with tree formatting for description column
|
|
||||||
var cellContent string
|
|
||||||
if strings.Contains(col.Name, "description") && m.taskTree != nil {
|
|
||||||
cellContent = m.renderTreeDescription(r, depth, hasChildren, progress)
|
|
||||||
} else {
|
|
||||||
cellContent = m.rows[r].GetString(col.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
|
|
||||||
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(cellContent, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderTreeDescription renders the description column with tree indentation and progress
|
|
||||||
func (m *Model) renderTreeDescription(rowIdx int, depth int, hasChildren bool, progress string) string {
|
|
||||||
task := m.rows[rowIdx]
|
|
||||||
desc := task.Description
|
|
||||||
|
|
||||||
// Build indentation and tree characters
|
|
||||||
prefix := ""
|
|
||||||
if depth > 0 {
|
|
||||||
prefix = strings.Repeat(" ", depth) + "└─ "
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add progress indicator for parent tasks
|
|
||||||
if hasChildren {
|
|
||||||
desc = desc + progress
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefix + desc
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
package timestampeditor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"tasksquire/common"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
timeFormat = "20060102T150405Z" // Timewarrior format
|
|
||||||
)
|
|
||||||
|
|
||||||
// Field represents which field is currently focused
|
|
||||||
type Field int
|
|
||||||
|
|
||||||
const (
|
|
||||||
TimeField Field = iota
|
|
||||||
DateField
|
|
||||||
)
|
|
||||||
|
|
||||||
// TimestampEditor is a component for editing timestamps with separate time and date fields
|
|
||||||
type TimestampEditor struct {
|
|
||||||
common *common.Common
|
|
||||||
|
|
||||||
// Current timestamp value
|
|
||||||
timestamp time.Time
|
|
||||||
isEmpty bool // Track if timestamp is unset
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
focused bool
|
|
||||||
currentField Field
|
|
||||||
|
|
||||||
// Dimensions
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
|
|
||||||
// Title and description
|
|
||||||
title string
|
|
||||||
description string
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
validate func(time.Time) error
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new TimestampEditor with no initial timestamp
|
|
||||||
func New(com *common.Common) *TimestampEditor {
|
|
||||||
return &TimestampEditor{
|
|
||||||
common: com,
|
|
||||||
timestamp: time.Time{}, // Zero time
|
|
||||||
isEmpty: true, // Start empty
|
|
||||||
focused: false,
|
|
||||||
currentField: TimeField,
|
|
||||||
validate: func(time.Time) error { return nil },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title sets the title of the timestamp editor
|
|
||||||
func (t *TimestampEditor) Title(title string) *TimestampEditor {
|
|
||||||
t.title = title
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description sets the description of the timestamp editor
|
|
||||||
func (t *TimestampEditor) Description(description string) *TimestampEditor {
|
|
||||||
t.description = description
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Value sets the initial timestamp value
|
|
||||||
func (t *TimestampEditor) Value(timestamp time.Time) *TimestampEditor {
|
|
||||||
t.timestamp = timestamp
|
|
||||||
t.isEmpty = timestamp.IsZero()
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValueFromString sets the initial timestamp from a timewarrior format string
|
|
||||||
func (t *TimestampEditor) ValueFromString(s string) *TimestampEditor {
|
|
||||||
if s == "" {
|
|
||||||
t.timestamp = time.Time{}
|
|
||||||
t.isEmpty = true
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := time.Parse(timeFormat, s)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to parse timestamp", "error", err)
|
|
||||||
t.timestamp = time.Time{}
|
|
||||||
t.isEmpty = true
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
t.timestamp = parsed.Local()
|
|
||||||
t.isEmpty = false
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValue returns the current timestamp
|
|
||||||
func (t *TimestampEditor) GetValue() time.Time {
|
|
||||||
return t.timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValueString returns the timestamp in timewarrior format, or empty string if unset
|
|
||||||
func (t *TimestampEditor) GetValueString() string {
|
|
||||||
if t.isEmpty {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return t.timestamp.UTC().Format(timeFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate sets the validation function
|
|
||||||
func (t *TimestampEditor) Validate(validate func(time.Time) error) *TimestampEditor {
|
|
||||||
t.validate = validate
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error returns the validation error
|
|
||||||
func (t *TimestampEditor) Error() error {
|
|
||||||
return t.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus focuses the timestamp editor
|
|
||||||
func (t *TimestampEditor) Focus() tea.Cmd {
|
|
||||||
t.focused = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur blurs the timestamp editor
|
|
||||||
func (t *TimestampEditor) Blur() tea.Cmd {
|
|
||||||
t.focused = false
|
|
||||||
t.err = t.validate(t.timestamp)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSize sets the size of the timestamp editor
|
|
||||||
func (t *TimestampEditor) SetSize(width, height int) {
|
|
||||||
t.width = width
|
|
||||||
t.height = height
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes the timestamp editor
|
|
||||||
func (t *TimestampEditor) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update handles messages for the timestamp editor
|
|
||||||
func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if !t.focused {
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
t.err = nil
|
|
||||||
|
|
||||||
switch msg.String() {
|
|
||||||
// Navigation between fields
|
|
||||||
case "h", "left":
|
|
||||||
t.currentField = TimeField
|
|
||||||
case "l", "right":
|
|
||||||
t.currentField = DateField
|
|
||||||
|
|
||||||
// Time field adjustments (lowercase - 5 minutes)
|
|
||||||
case "j":
|
|
||||||
// Set current time on first edit if empty
|
|
||||||
if t.isEmpty {
|
|
||||||
t.setCurrentTime()
|
|
||||||
}
|
|
||||||
if t.currentField == TimeField {
|
|
||||||
t.adjustTime(-5)
|
|
||||||
} else {
|
|
||||||
t.adjustDate(-1)
|
|
||||||
}
|
|
||||||
case "k":
|
|
||||||
// Set current time on first edit if empty
|
|
||||||
if t.isEmpty {
|
|
||||||
t.setCurrentTime()
|
|
||||||
}
|
|
||||||
if t.currentField == TimeField {
|
|
||||||
t.adjustTime(5)
|
|
||||||
} else {
|
|
||||||
t.adjustDate(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time field adjustments (uppercase - 60 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(-60)
|
|
||||||
} 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(60)
|
|
||||||
} else {
|
|
||||||
t.adjustDate(7)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove timestamp
|
|
||||||
case "d":
|
|
||||||
t.timestamp = time.Time{}
|
|
||||||
t.isEmpty = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setCurrentTime sets the timestamp to the current time and marks it as not empty
|
|
||||||
func (t *TimestampEditor) setCurrentTime() {
|
|
||||||
now := time.Now()
|
|
||||||
// Snap to nearest 5 minutes
|
|
||||||
minute := now.Minute()
|
|
||||||
remainder := minute % 5
|
|
||||||
if remainder != 0 {
|
|
||||||
if remainder < 3 {
|
|
||||||
// Round down
|
|
||||||
now = now.Add(-time.Duration(remainder) * time.Minute)
|
|
||||||
} else {
|
|
||||||
// Round up
|
|
||||||
now = now.Add(time.Duration(5-remainder) * time.Minute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Zero out seconds and nanoseconds
|
|
||||||
t.timestamp = time.Date(
|
|
||||||
now.Year(),
|
|
||||||
now.Month(),
|
|
||||||
now.Day(),
|
|
||||||
now.Hour(),
|
|
||||||
now.Minute(),
|
|
||||||
0, 0,
|
|
||||||
now.Location(),
|
|
||||||
)
|
|
||||||
t.isEmpty = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// adjustTime adjusts the time by the given number of minutes and snaps to nearest 5 minutes
|
|
||||||
func (t *TimestampEditor) adjustTime(minutes int) {
|
|
||||||
// Add the minutes
|
|
||||||
t.timestamp = t.timestamp.Add(time.Duration(minutes) * time.Minute)
|
|
||||||
|
|
||||||
// Snap to nearest 5 minutes
|
|
||||||
minute := t.timestamp.Minute()
|
|
||||||
remainder := minute % 5
|
|
||||||
if remainder != 0 {
|
|
||||||
if remainder < 3 {
|
|
||||||
// Round down
|
|
||||||
t.timestamp = t.timestamp.Add(-time.Duration(remainder) * time.Minute)
|
|
||||||
} else {
|
|
||||||
// Round up
|
|
||||||
t.timestamp = t.timestamp.Add(time.Duration(5-remainder) * time.Minute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zero out seconds and nanoseconds
|
|
||||||
t.timestamp = time.Date(
|
|
||||||
t.timestamp.Year(),
|
|
||||||
t.timestamp.Month(),
|
|
||||||
t.timestamp.Day(),
|
|
||||||
t.timestamp.Hour(),
|
|
||||||
t.timestamp.Minute(),
|
|
||||||
0, 0,
|
|
||||||
t.timestamp.Location(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// adjustDate adjusts the date by the given number of days
|
|
||||||
func (t *TimestampEditor) adjustDate(days int) {
|
|
||||||
t.timestamp = t.timestamp.AddDate(0, 0, days)
|
|
||||||
}
|
|
||||||
|
|
||||||
// View renders the timestamp editor
|
|
||||||
func (t *TimestampEditor) View() string {
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
styles := t.getStyles()
|
|
||||||
|
|
||||||
// Render title if present
|
|
||||||
if t.title != "" {
|
|
||||||
sb.WriteString(styles.title.Render(t.title))
|
|
||||||
if t.err != nil {
|
|
||||||
sb.WriteString(styles.errorIndicator.String())
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render description if present
|
|
||||||
if t.description != "" {
|
|
||||||
sb.WriteString(styles.description.Render(t.description))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the time and date fields side by side
|
|
||||||
var timeStr, dateStr string
|
|
||||||
if t.isEmpty {
|
|
||||||
timeStr = "--:--"
|
|
||||||
dateStr = "--- ----------"
|
|
||||||
} else {
|
|
||||||
timeStr = t.timestamp.Format("15:04")
|
|
||||||
dateStr = t.timestamp.Format("Mon 2006-01-02")
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeField, dateField string
|
|
||||||
if t.currentField == TimeField {
|
|
||||||
timeField = styles.selectedField.Render(timeStr)
|
|
||||||
dateField = styles.unselectedField.Render(dateStr)
|
|
||||||
} else {
|
|
||||||
timeField = styles.unselectedField.Render(timeStr)
|
|
||||||
dateField = styles.selectedField.Render(dateStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeField, " ", dateField)
|
|
||||||
sb.WriteString(fieldsRow)
|
|
||||||
|
|
||||||
return styles.base.Render(sb.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// getHelpText returns the help text based on the current field
|
|
||||||
func (t *TimestampEditor) getHelpText() string {
|
|
||||||
if t.currentField == TimeField {
|
|
||||||
return "h/l: switch field • j/k: ±5min • J/K: ±30min • d: remove"
|
|
||||||
}
|
|
||||||
return "h/l: switch field • j/k: ±1day • J/K: ±1week • d: remove"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Styles for the timestamp editor
|
|
||||||
type timestampEditorStyles struct {
|
|
||||||
base lipgloss.Style
|
|
||||||
title lipgloss.Style
|
|
||||||
description lipgloss.Style
|
|
||||||
errorIndicator lipgloss.Style
|
|
||||||
selectedField lipgloss.Style
|
|
||||||
unselectedField lipgloss.Style
|
|
||||||
help lipgloss.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStyles returns the styles for the timestamp editor
|
|
||||||
func (t *TimestampEditor) getStyles() timestampEditorStyles {
|
|
||||||
theme := t.common.Styles.Form
|
|
||||||
var styles timestampEditorStyles
|
|
||||||
|
|
||||||
if t.focused {
|
|
||||||
styles.base = lipgloss.NewStyle()
|
|
||||||
styles.title = theme.Focused.Title
|
|
||||||
styles.description = theme.Focused.Description
|
|
||||||
styles.errorIndicator = theme.Focused.ErrorIndicator
|
|
||||||
styles.selectedField = lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Padding(0, 2).
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("12"))
|
|
||||||
styles.unselectedField = lipgloss.NewStyle().
|
|
||||||
Padding(0, 2).
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("8"))
|
|
||||||
styles.help = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("8")).
|
|
||||||
Italic(true)
|
|
||||||
} else {
|
|
||||||
styles.base = lipgloss.NewStyle()
|
|
||||||
styles.title = theme.Blurred.Title
|
|
||||||
styles.description = theme.Blurred.Description
|
|
||||||
styles.errorIndicator = theme.Blurred.ErrorIndicator
|
|
||||||
styles.selectedField = lipgloss.NewStyle().
|
|
||||||
Padding(0, 2).
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("8"))
|
|
||||||
styles.unselectedField = lipgloss.NewStyle().
|
|
||||||
Padding(0, 2).
|
|
||||||
Border(lipgloss.HiddenBorder())
|
|
||||||
styles.help = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("8"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return styles
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip returns whether the timestamp editor should be skipped
|
|
||||||
func (t *TimestampEditor) Skip() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zoom returns whether the timestamp editor should be zoomed
|
|
||||||
func (t *TimestampEditor) Zoom() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeyBinds returns the key bindings for the timestamp editor
|
|
||||||
func (t *TimestampEditor) KeyBinds() []key.Binding {
|
|
||||||
return []key.Binding{
|
|
||||||
t.common.Keymap.Left,
|
|
||||||
t.common.Keymap.Right,
|
|
||||||
t.common.Keymap.Up,
|
|
||||||
t.common.Keymap.Down,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
63
flake.lock
generated
63
flake.lock
generated
@@ -1,58 +1,59 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1731533236,
|
"lastModified": 1769996383,
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||||
"owner": "numtide",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-parts",
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "numtide",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-parts",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770197578,
|
"lastModified": 1771848320,
|
||||||
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=",
|
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2",
|
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"id": "nixpkgs",
|
"owner": "nixos",
|
||||||
"ref": "nixos-unstable",
|
"ref": "nixos-unstable",
|
||||||
"type": "indirect"
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-lib": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1769909678,
|
||||||
|
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-parts": "flake-parts",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
37
flake.nix
37
flake.nix
@@ -2,18 +2,16 @@
|
|||||||
description = "Tasksquire";
|
description = "Tasksquire";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
outputs = inputs@{ self, nixpkgs, flake-parts, ... }:
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
let
|
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
};
|
|
||||||
|
|
||||||
tasksquire = pkgs.buildGoModule {
|
perSystem = { config, self', inputs', pkgs, system, ... }: {
|
||||||
|
packages.tasksquire = pkgs.buildGoModule {
|
||||||
pname = "tasksquire";
|
pname = "tasksquire";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
@@ -32,15 +30,15 @@
|
|||||||
mainProgram = "tasksquire";
|
mainProgram = "tasksquire";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
in
|
|
||||||
{
|
|
||||||
packages.default = tasksquire;
|
|
||||||
packages.tasksquire = tasksquire;
|
|
||||||
apps.default = flake-utils.lib.mkApp { drv = tasksquire; };
|
|
||||||
|
|
||||||
|
# Set the default package
|
||||||
|
packages.default = self'.packages.tasksquire;
|
||||||
|
|
||||||
|
# Development shell
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
|
inputsFrom = [ self'.packages.tasksquire ];
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
go_1_24
|
go
|
||||||
gcc
|
gcc
|
||||||
gotools
|
gotools
|
||||||
golangci-lint
|
golangci-lint
|
||||||
@@ -50,14 +48,9 @@
|
|||||||
go-tools
|
go-tools
|
||||||
gotests
|
gotests
|
||||||
delve
|
delve
|
||||||
taskwarrior3
|
|
||||||
timewarrior
|
|
||||||
];
|
];
|
||||||
CGO_CFLAGS = "-O";
|
CGO_CFLAGS = "-O";
|
||||||
};
|
};
|
||||||
|
};
|
||||||
# Backward compatibility
|
};
|
||||||
devShell = self.devShells.${system}.default;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
go.mod
61
go.mod
@@ -1,51 +1,28 @@
|
|||||||
module tasksquire
|
module tasksquire
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25
|
||||||
|
|
||||||
toolchain go1.24.12
|
toolchain go1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbles v0.18.0
|
charm.land/bubbles/v2 v2.0.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v0.26.4
|
charm.land/bubbletea/v2 v2.0.0 // indirect
|
||||||
github.com/charmbracelet/glamour v0.10.0
|
charm.land/lipgloss/v2 v2.0.0 // indirect
|
||||||
github.com/charmbracelet/huh v0.4.2
|
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/sahilm/fuzzy v0.1.1
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
golang.org/x/term v0.31.0
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
)
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
require (
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
|
||||||
github.com/catppuccin/go v0.2.0 // indirect
|
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a // indirect
|
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 // indirect
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
|
||||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.7.8 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
|
||||||
golang.org/x/text v0.24.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
138
go.sum
138
go.sum
@@ -1,100 +1,50 @@
|
|||||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
|
||||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
||||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40=
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0=
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I=
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 h1:6Rw/MHf+Rm7wlUr+euFyaGw1fNKeZPKVh4gkFHUjFsY=
|
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7/go.mod h1:UZyEz89i6+jVx2oW3lgmIsVDNr7qzaKuvPVoSdwqDR0=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
|
||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
|
||||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
|
||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
|
||||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/internal/taskwarrior"
|
||||||
"tasksquire/timewarrior"
|
"tasksquire/internal/timewarrior"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import tea "github.com/charmbracelet/bubbletea"
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
type Component interface {
|
type Component interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"charm.land/bubbles/v2/key"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keymap is a collection of key bindings.
|
// Keymap is a collection of key bindings.
|
||||||
@@ -106,13 +106,13 @@ func NewKeymap() *Keymap {
|
|||||||
),
|
),
|
||||||
|
|
||||||
NextPage: key.NewBinding(
|
NextPage: key.NewBinding(
|
||||||
key.WithKeys("]"),
|
key.WithKeys("]", "L"),
|
||||||
key.WithHelp("[", "Next page"),
|
key.WithHelp("]/L", "Next page"),
|
||||||
),
|
),
|
||||||
|
|
||||||
PrevPage: key.NewBinding(
|
PrevPage: key.NewBinding(
|
||||||
key.WithKeys("["),
|
key.WithKeys("[", "H"),
|
||||||
key.WithHelp("]", "Previous page"),
|
key.WithHelp("[/H", "Previous page"),
|
||||||
),
|
),
|
||||||
|
|
||||||
SetReport: key.NewBinding(
|
SetReport: key.NewBinding(
|
||||||
18
internal/common/messages.go
Normal file
18
internal/common/messages.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tasksquire/internal/taskwarrior"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskMsg taskwarrior.Tasks
|
||||||
|
|
||||||
|
type TickMsg time.Time
|
||||||
|
|
||||||
|
func DoTick() tea.Cmd {
|
||||||
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return TickMsg(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
234
internal/common/styles.go
Normal file
234
internal/common/styles.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"tasksquire/internal/taskwarrior"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TableStyle struct {
|
||||||
|
Header lipgloss.Style
|
||||||
|
Cell lipgloss.Style
|
||||||
|
Selected lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
type Palette struct {
|
||||||
|
Primary lipgloss.Style
|
||||||
|
Secondary lipgloss.Style
|
||||||
|
Accent lipgloss.Style
|
||||||
|
Muted lipgloss.Style
|
||||||
|
Border lipgloss.Style
|
||||||
|
Background lipgloss.Style
|
||||||
|
Text lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
type Styles struct {
|
||||||
|
Colors map[string]*lipgloss.Style
|
||||||
|
Palette Palette
|
||||||
|
|
||||||
|
Base lipgloss.Style
|
||||||
|
|
||||||
|
// Form *huh.Theme
|
||||||
|
TableStyle TableStyle
|
||||||
|
|
||||||
|
Tab lipgloss.Style
|
||||||
|
ActiveTab lipgloss.Style
|
||||||
|
TabBar lipgloss.Style
|
||||||
|
|
||||||
|
ColumnFocused lipgloss.Style
|
||||||
|
ColumnBlurred lipgloss.Style
|
||||||
|
ColumnInsert lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
||||||
|
styles := Styles{}
|
||||||
|
|
||||||
|
colors := make(map[string]*lipgloss.Style)
|
||||||
|
|
||||||
|
for key, value := range config.GetConfig() {
|
||||||
|
if strings.HasPrefix(key, "color.") {
|
||||||
|
_, color, _ := strings.Cut(key, ".")
|
||||||
|
colors[color] = parseColorString(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
styles.Colors = colors
|
||||||
|
|
||||||
|
// Initialize Palette (Iceberg Light)
|
||||||
|
styles.Palette.Primary = lipgloss.NewStyle().Foreground(lipgloss.Color("#2d539e")) // Blue
|
||||||
|
styles.Palette.Secondary = lipgloss.NewStyle().Foreground(lipgloss.Color("#7759b4")) // Purple
|
||||||
|
styles.Palette.Accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c57339")) // Orange
|
||||||
|
styles.Palette.Muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#8389a3")) // Grey
|
||||||
|
styles.Palette.Border = lipgloss.NewStyle().Foreground(lipgloss.Color("#cad0de")) // Light Grey Border
|
||||||
|
styles.Palette.Background = lipgloss.NewStyle().Background(lipgloss.Color("#e8e9ec")) // Light Background
|
||||||
|
styles.Palette.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#33374c")) // Dark Text
|
||||||
|
|
||||||
|
// Override from config if available (example mapping)
|
||||||
|
if s, ok := styles.Colors["primary"]; ok {
|
||||||
|
styles.Palette.Primary = *s
|
||||||
|
}
|
||||||
|
if s, ok := styles.Colors["secondary"]; ok {
|
||||||
|
styles.Palette.Secondary = *s
|
||||||
|
}
|
||||||
|
if s, ok := styles.Colors["active"]; ok {
|
||||||
|
styles.Palette.Accent = *s
|
||||||
|
}
|
||||||
|
|
||||||
|
styles.Base = lipgloss.NewStyle().Foreground(styles.Palette.Text.GetForeground())
|
||||||
|
|
||||||
|
styles.TableStyle = TableStyle{
|
||||||
|
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
|
||||||
|
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
|
||||||
|
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true).Foreground(styles.Palette.Primary.GetForeground()),
|
||||||
|
Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// formTheme := huh.ThemeBase()
|
||||||
|
// formTheme.Focused.Title = formTheme.Focused.Title.Bold(true).Foreground(styles.Palette.Primary.GetForeground())
|
||||||
|
// formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||||
|
// formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true).Foreground(styles.Palette.Accent.GetForeground())
|
||||||
|
// formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||||
|
// formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||||
|
// formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
|
||||||
|
// formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true).Foreground(styles.Palette.Muted.GetForeground())
|
||||||
|
// formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
||||||
|
// formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
||||||
|
// formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
||||||
|
// formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ")
|
||||||
|
// formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ")
|
||||||
|
|
||||||
|
// styles.Form = formTheme
|
||||||
|
|
||||||
|
styles.Tab = lipgloss.NewStyle().
|
||||||
|
Padding(0, 1).
|
||||||
|
Foreground(styles.Palette.Muted.GetForeground())
|
||||||
|
|
||||||
|
styles.ActiveTab = styles.Tab.
|
||||||
|
Foreground(styles.Palette.Primary.GetForeground()).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styles.TabBar = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||||
|
BorderForeground(styles.Palette.Border.GetForeground()).
|
||||||
|
MarginBottom(1)
|
||||||
|
|
||||||
|
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Primary.GetForeground())
|
||||||
|
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1).BorderForeground(styles.Palette.Border.GetForeground())
|
||||||
|
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Accent.GetForeground())
|
||||||
|
|
||||||
|
return &styles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Styles) GetModalSize(width, height int) (int, int) {
|
||||||
|
modalWidth := 60
|
||||||
|
if width < 64 {
|
||||||
|
modalWidth = width - 4
|
||||||
|
}
|
||||||
|
|
||||||
|
modalHeight := 20
|
||||||
|
if height < 24 {
|
||||||
|
modalHeight = height - 4
|
||||||
|
}
|
||||||
|
|
||||||
|
return modalWidth, modalHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseColorString(color string) *lipgloss.Style {
|
||||||
|
if color == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle()
|
||||||
|
|
||||||
|
if strings.Contains(color, "on") {
|
||||||
|
fgbg := strings.Split(color, "on")
|
||||||
|
fg := strings.TrimSpace(fgbg[0])
|
||||||
|
bg := strings.TrimSpace(fgbg[1])
|
||||||
|
if fg != "" {
|
||||||
|
style = style.Foreground(parseColor(fg))
|
||||||
|
}
|
||||||
|
if bg != "" {
|
||||||
|
style = style.Background(parseColor(bg))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
style = style.Foreground(parseColor(strings.TrimSpace(color)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &style
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseColor(color string) color.Color {
|
||||||
|
if strings.HasPrefix(color, "rgb") {
|
||||||
|
return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb")))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(color, "color") {
|
||||||
|
return lipgloss.Color(strings.TrimPrefix(color, "color"))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(color, "gray") {
|
||||||
|
gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray"))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Invalid gray color format")
|
||||||
|
return lipgloss.Color("0")
|
||||||
|
}
|
||||||
|
return lipgloss.Color(strconv.Itoa(gray + 232))
|
||||||
|
}
|
||||||
|
if ansi, okcolor := colorStrings[color]; okcolor {
|
||||||
|
return lipgloss.Color(strconv.Itoa(ansi))
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("Invalid color format")
|
||||||
|
return lipgloss.Color("0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertRgbToAnsi(rgbString string) string {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if len(rgbString) != 3 {
|
||||||
|
slog.Error("Invalid RGB color format")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := strconv.Atoi(string(rgbString[0]))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Invalid value for R")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := strconv.Atoi(string(rgbString[1]))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Invalid value for G")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := strconv.Atoi(string(rgbString[2]))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Invalid value for B")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strconv.Itoa(16 + (36 * r) + (6 * g) + b)
|
||||||
|
}
|
||||||
|
|
||||||
|
var colorStrings = map[string]int{
|
||||||
|
"black": 0,
|
||||||
|
"red": 1,
|
||||||
|
"green": 2,
|
||||||
|
"yellow": 3,
|
||||||
|
"blue": 4,
|
||||||
|
"magenta": 5,
|
||||||
|
"cyan": 6,
|
||||||
|
"white": 7,
|
||||||
|
"bright black": 8,
|
||||||
|
"bright red": 9,
|
||||||
|
"bright green": 10,
|
||||||
|
"bright yellow": 11,
|
||||||
|
"bright blue": 12,
|
||||||
|
"bright magenta": 13,
|
||||||
|
"bright cyan": 14,
|
||||||
|
"bright white": 15,
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ package common
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/internal/taskwarrior"
|
||||||
"tasksquire/timewarrior"
|
"tasksquire/internal/timewarrior"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FindTaskByUUID queries Taskwarrior for a task with the given UUID.
|
// FindTaskByUUID queries Taskwarrior for a task with the given UUID.
|
||||||
@@ -178,7 +178,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
return t.Recur
|
return t.Recur
|
||||||
|
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
slog.Error("Field not implemented", "field", field)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +214,7 @@ func formatDate(date string, format string) string {
|
|||||||
dtformat := "20060102T150405Z"
|
dtformat := "20060102T150405Z"
|
||||||
dt, err := time.Parse(dtformat, date)
|
dt, err := time.Parse(dtformat, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ func formatDate(date string, format string) string {
|
|||||||
case "countdown":
|
case "countdown":
|
||||||
return parseCountdown(time.Since(dt))
|
return parseCountdown(time.Since(dt))
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
slog.Error("Date format not implemented", "format", format)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,26 @@
|
|||||||
package timetable
|
package tasktable
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
taskw "tasksquire/internal/taskwarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"charm.land/bubbles/v2/help"
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
"charm.land/bubbles/v2/viewport"
|
||||||
"github.com/charmbracelet/lipgloss"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/mattn/go-runewidth"
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/timewarrior"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model defines a state for the table widget.
|
// Model defines a state for the table widget.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
common *common.Common
|
|
||||||
KeyMap KeyMap
|
KeyMap KeyMap
|
||||||
|
Help help.Model
|
||||||
|
|
||||||
cols []Column
|
cols []Column
|
||||||
rows timewarrior.Intervals
|
rows []Row
|
||||||
rowStyles []lipgloss.Style
|
cursor int
|
||||||
cursor int
|
focus bool
|
||||||
focus bool
|
styles Styles
|
||||||
styles common.TableStyle
|
|
||||||
styleFunc StyleFunc
|
|
||||||
|
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
start int
|
start int
|
||||||
@@ -32,19 +28,20 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Row represents one line in the table.
|
// Row represents one line in the table.
|
||||||
type Row *timewarrior.Interval
|
type Row struct {
|
||||||
|
task taskw.Task
|
||||||
|
style lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
// Column defines the table structure.
|
// Column defines the table structure.
|
||||||
type Column struct {
|
type Column struct {
|
||||||
Title string
|
Title string
|
||||||
Name string
|
Name string
|
||||||
Width int
|
Width int
|
||||||
MaxWidth int
|
|
||||||
ContentWidth int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
|
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
|
||||||
// is used to render the menu.
|
// is used to render the help menu.
|
||||||
type KeyMap struct {
|
type KeyMap struct {
|
||||||
LineUp key.Binding
|
LineUp key.Binding
|
||||||
LineDown key.Binding
|
LineDown key.Binding
|
||||||
@@ -71,7 +68,6 @@ func (km KeyMap) FullHelp() [][]key.Binding {
|
|||||||
|
|
||||||
// DefaultKeyMap returns a default set of keybindings.
|
// DefaultKeyMap returns a default set of keybindings.
|
||||||
func DefaultKeyMap() KeyMap {
|
func DefaultKeyMap() KeyMap {
|
||||||
const spacebar = " "
|
|
||||||
return KeyMap{
|
return KeyMap{
|
||||||
LineUp: key.NewBinding(
|
LineUp: key.NewBinding(
|
||||||
key.WithKeys("up", "k"),
|
key.WithKeys("up", "k"),
|
||||||
@@ -86,7 +82,7 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithHelp("b/pgup", "page up"),
|
key.WithHelp("b/pgup", "page up"),
|
||||||
),
|
),
|
||||||
PageDown: key.NewBinding(
|
PageDown: key.NewBinding(
|
||||||
key.WithKeys("f", "pgdown", spacebar),
|
key.WithKeys("f", "pgdown", "space"),
|
||||||
key.WithHelp("f/pgdn", "page down"),
|
key.WithHelp("f/pgdn", "page down"),
|
||||||
),
|
),
|
||||||
HalfPageUp: key.NewBinding(
|
HalfPageUp: key.NewBinding(
|
||||||
@@ -108,8 +104,25 @@ func DefaultKeyMap() KeyMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Styles contains style definitions for this list component. By default, these
|
||||||
|
// values are generated by DefaultStyles.
|
||||||
|
type Styles struct {
|
||||||
|
Header lipgloss.Style
|
||||||
|
Cell lipgloss.Style
|
||||||
|
Selected lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStyles returns a set of default style definitions for this table.
|
||||||
|
func DefaultStyles() Styles {
|
||||||
|
return Styles{
|
||||||
|
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
|
||||||
|
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1),
|
||||||
|
Cell: lipgloss.NewStyle().Padding(0, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SetStyles sets the table styles.
|
// SetStyles sets the table styles.
|
||||||
func (m *Model) SetStyles(s common.TableStyle) {
|
func (m *Model) SetStyles(s Styles) {
|
||||||
m.styles = s
|
m.styles = s
|
||||||
m.UpdateViewport()
|
m.UpdateViewport()
|
||||||
}
|
}
|
||||||
@@ -120,83 +133,25 @@ func (m *Model) SetStyles(s common.TableStyle) {
|
|||||||
type Option func(*Model)
|
type Option func(*Model)
|
||||||
|
|
||||||
// New creates a new model for the table widget.
|
// New creates a new model for the table widget.
|
||||||
func New(com *common.Common, opts ...Option) Model {
|
func New(opts ...Option) Model {
|
||||||
m := Model{
|
m := Model{
|
||||||
common: com,
|
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
viewport: viewport.New(0, 20),
|
viewport: viewport.New(viewport.WithHeight(20)), //nolint:mnd
|
||||||
|
|
||||||
KeyMap: DefaultKeyMap(),
|
KeyMap: DefaultKeyMap(),
|
||||||
|
Help: help.New(),
|
||||||
|
styles: DefaultStyles(),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(&m)
|
opt(&m)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.cols = m.parseColumns(m.cols)
|
|
||||||
m.rowStyles = m.parseRowStyles(m.rows)
|
|
||||||
|
|
||||||
m.UpdateViewport()
|
m.UpdateViewport()
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) parseRowStyles(rows timewarrior.Intervals) []lipgloss.Style {
|
|
||||||
styles := make([]lipgloss.Style, len(rows))
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return styles
|
|
||||||
}
|
|
||||||
for i := range rows {
|
|
||||||
// Default style
|
|
||||||
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
|
|
||||||
// If active, maybe highlight?
|
|
||||||
if rows[i].IsActive() {
|
|
||||||
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
|
|
||||||
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return styles
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) parseColumns(cols []Column) []Column {
|
|
||||||
if len(cols) == 0 {
|
|
||||||
return cols
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, col := range cols {
|
|
||||||
for _, interval := range m.rows {
|
|
||||||
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(interval.GetString(col.Name)))
|
|
||||||
}
|
|
||||||
cols[i] = col
|
|
||||||
}
|
|
||||||
|
|
||||||
combinedSize := 0
|
|
||||||
nonZeroWidths := 0
|
|
||||||
tagIndex := -1
|
|
||||||
for i, col := range cols {
|
|
||||||
if col.ContentWidth > 0 {
|
|
||||||
col.Width = max(col.ContentWidth, lipgloss.Width(col.Title))
|
|
||||||
nonZeroWidths++
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(col.Name, "tags") {
|
|
||||||
combinedSize += col.Width
|
|
||||||
} else {
|
|
||||||
tagIndex = i
|
|
||||||
}
|
|
||||||
|
|
||||||
cols[i] = col
|
|
||||||
}
|
|
||||||
|
|
||||||
if tagIndex >= 0 {
|
|
||||||
cols[tagIndex].Width = m.Width() - combinedSize - nonZeroWidths
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithColumns sets the table columns (headers).
|
// WithColumns sets the table columns (headers).
|
||||||
func WithColumns(cols []Column) Option {
|
func WithColumns(cols []Column) Option {
|
||||||
return func(m *Model) {
|
return func(m *Model) {
|
||||||
@@ -205,7 +160,7 @@ func WithColumns(cols []Column) Option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WithRows sets the table rows (data).
|
// WithRows sets the table rows (data).
|
||||||
func WithIntervals(rows timewarrior.Intervals) Option {
|
func WithRows(rows []Row) Option {
|
||||||
return func(m *Model) {
|
return func(m *Model) {
|
||||||
m.rows = rows
|
m.rows = rows
|
||||||
}
|
}
|
||||||
@@ -214,14 +169,14 @@ func WithIntervals(rows timewarrior.Intervals) Option {
|
|||||||
// WithHeight sets the height of the table.
|
// WithHeight sets the height of the table.
|
||||||
func WithHeight(h int) Option {
|
func WithHeight(h int) Option {
|
||||||
return func(m *Model) {
|
return func(m *Model) {
|
||||||
m.viewport.Height = h - lipgloss.Height(m.headersView())
|
m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithWidth sets the width of the table.
|
// WithWidth sets the width of the table.
|
||||||
func WithWidth(w int) Option {
|
func WithWidth(w int) Option {
|
||||||
return func(m *Model) {
|
return func(m *Model) {
|
||||||
m.viewport.Width = w
|
m.viewport.SetWidth(w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,19 +188,12 @@ func WithFocused(f bool) Option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WithStyles sets the table styles.
|
// WithStyles sets the table styles.
|
||||||
func WithStyles(s common.TableStyle) Option {
|
func WithStyles(s Styles) Option {
|
||||||
return func(m *Model) {
|
return func(m *Model) {
|
||||||
m.styles = s
|
m.styles = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state.
|
|
||||||
func WithStyleFunc(f StyleFunc) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.styleFunc = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithKeyMap sets the key map.
|
// WithKeyMap sets the key map.
|
||||||
func WithKeyMap(km KeyMap) Option {
|
func WithKeyMap(km KeyMap) Option {
|
||||||
return func(m *Model) {
|
return func(m *Model) {
|
||||||
@@ -260,22 +208,20 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyPressMsg:
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, m.KeyMap.LineUp):
|
case key.Matches(msg, m.KeyMap.LineUp):
|
||||||
m.MoveUp(1)
|
m.MoveUp(1)
|
||||||
case key.Matches(msg, m.KeyMap.LineDown):
|
case key.Matches(msg, m.KeyMap.LineDown):
|
||||||
m.MoveDown(1)
|
m.MoveDown(1)
|
||||||
case key.Matches(msg, m.KeyMap.PageUp):
|
case key.Matches(msg, m.KeyMap.PageUp):
|
||||||
m.MoveUp(m.viewport.Height)
|
m.MoveUp(m.viewport.Height())
|
||||||
case key.Matches(msg, m.KeyMap.PageDown):
|
case key.Matches(msg, m.KeyMap.PageDown):
|
||||||
m.MoveDown(m.viewport.Height)
|
m.MoveDown(m.viewport.Height())
|
||||||
case key.Matches(msg, m.KeyMap.HalfPageUp):
|
case key.Matches(msg, m.KeyMap.HalfPageUp):
|
||||||
m.MoveUp(m.viewport.Height / 2)
|
m.MoveUp(m.viewport.Height() / 2) //nolint:mnd
|
||||||
case key.Matches(msg, m.KeyMap.HalfPageDown):
|
case key.Matches(msg, m.KeyMap.HalfPageDown):
|
||||||
m.MoveDown(m.viewport.Height / 2)
|
m.MoveDown(m.viewport.Height() / 2) //nolint:mnd
|
||||||
case key.Matches(msg, m.KeyMap.LineDown):
|
|
||||||
m.MoveDown(1)
|
|
||||||
case key.Matches(msg, m.KeyMap.GotoTop):
|
case key.Matches(msg, m.KeyMap.GotoTop):
|
||||||
m.GotoTop()
|
m.GotoTop()
|
||||||
case key.Matches(msg, m.KeyMap.GotoBottom):
|
case key.Matches(msg, m.KeyMap.GotoBottom):
|
||||||
@@ -309,17 +255,27 @@ func (m Model) View() string {
|
|||||||
return m.headersView() + "\n" + m.viewport.View()
|
return m.headersView() + "\n" + m.viewport.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HelpView is a helper method for rendering the help menu from the keymap.
|
||||||
|
// Note that this view is not rendered by default and you must call it
|
||||||
|
// manually in your application, where applicable.
|
||||||
|
func (m Model) HelpView() string {
|
||||||
|
return m.Help.View(m.KeyMap)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateViewport updates the list content based on the previously defined
|
// UpdateViewport updates the list content based on the previously defined
|
||||||
// columns and rows.
|
// columns and rows.
|
||||||
func (m *Model) UpdateViewport() {
|
func (m *Model) UpdateViewport() {
|
||||||
renderedRows := make([]string, 0, len(m.rows))
|
renderedRows := make([]string, 0, len(m.rows))
|
||||||
|
|
||||||
|
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
|
||||||
|
// Constant runtime, independent of number of rows in a table.
|
||||||
|
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
|
||||||
if m.cursor >= 0 {
|
if m.cursor >= 0 {
|
||||||
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)
|
m.start = clamp(m.cursor-m.viewport.Height(), 0, m.cursor)
|
||||||
} else {
|
} else {
|
||||||
m.start = 0
|
m.start = 0
|
||||||
}
|
}
|
||||||
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))
|
m.end = clamp(m.cursor+m.viewport.Height(), m.cursor, len(m.rows))
|
||||||
for i := m.start; i < m.end; i++ {
|
for i := m.start; i < m.end; i++ {
|
||||||
renderedRows = append(renderedRows, m.renderRow(i))
|
renderedRows = append(renderedRows, m.renderRow(i))
|
||||||
}
|
}
|
||||||
@@ -330,22 +286,17 @@ func (m *Model) UpdateViewport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SelectedRow returns the selected row.
|
// SelectedRow returns the selected row.
|
||||||
// Returns nil if cursor is on a gap row or out of bounds.
|
// You can cast it to your own implementation.
|
||||||
func (m Model) SelectedRow() Row {
|
func (m Model) SelectedRow() Row {
|
||||||
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
||||||
return nil
|
return Row{}
|
||||||
}
|
|
||||||
|
|
||||||
// Don't return gap rows as selected
|
|
||||||
if m.rows[m.cursor].IsGap {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.rows[m.cursor]
|
return m.rows[m.cursor]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rows returns the current rows.
|
// Rows returns the current rows.
|
||||||
func (m Model) Rows() timewarrior.Intervals {
|
func (m Model) Rows() []Row {
|
||||||
return m.rows
|
return m.rows
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,8 +306,13 @@ func (m Model) Columns() []Column {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetRows sets a new rows state.
|
// SetRows sets a new rows state.
|
||||||
func (m *Model) SetRows(r timewarrior.Intervals) {
|
func (m *Model) SetRows(r []Row) {
|
||||||
m.rows = r
|
m.rows = r
|
||||||
|
|
||||||
|
if m.cursor > len(m.rows)-1 {
|
||||||
|
m.cursor = len(m.rows) - 1
|
||||||
|
}
|
||||||
|
|
||||||
m.UpdateViewport()
|
m.UpdateViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,24 +324,24 @@ func (m *Model) SetColumns(c []Column) {
|
|||||||
|
|
||||||
// SetWidth sets the width of the viewport of the table.
|
// SetWidth sets the width of the viewport of the table.
|
||||||
func (m *Model) SetWidth(w int) {
|
func (m *Model) SetWidth(w int) {
|
||||||
m.viewport.Width = w
|
m.viewport.SetWidth(w)
|
||||||
m.UpdateViewport()
|
m.UpdateViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHeight sets the height of the viewport of the table.
|
// SetHeight sets the height of the viewport of the table.
|
||||||
func (m *Model) SetHeight(h int) {
|
func (m *Model) SetHeight(h int) {
|
||||||
m.viewport.Height = h - lipgloss.Height(m.headersView())
|
m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
|
||||||
m.UpdateViewport()
|
m.UpdateViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Height returns the viewport height of the table.
|
// Height returns the viewport height of the table.
|
||||||
func (m Model) Height() int {
|
func (m Model) Height() int {
|
||||||
return m.viewport.Height
|
return m.viewport.Height()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Width returns the viewport width of the table.
|
// Width returns the viewport width of the table.
|
||||||
func (m Model) Width() int {
|
func (m Model) Width() int {
|
||||||
return m.viewport.Width
|
return m.viewport.Width()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor returns the index of the selected row.
|
// Cursor returns the index of the selected row.
|
||||||
@@ -394,109 +350,46 @@ func (m Model) Cursor() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetCursor sets the cursor position in the table.
|
// 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) {
|
func (m *Model) SetCursor(n int) {
|
||||||
m.cursor = clamp(n, 0, len(m.rows)-1)
|
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()
|
m.UpdateViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveUp moves the selection up by any number of rows.
|
// MoveUp moves the selection up by any number of rows.
|
||||||
// It can not go above the first row. Skips gap rows.
|
// It can not go above the first row.
|
||||||
func (m *Model) MoveUp(n int) {
|
func (m *Model) MoveUp(n int) {
|
||||||
originalCursor := m.cursor
|
|
||||||
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
||||||
|
|
||||||
// Skip gap rows
|
offset := m.viewport.YOffset()
|
||||||
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 {
|
switch {
|
||||||
case m.start == 0:
|
case m.start == 0:
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
|
offset = clamp(offset, 0, m.cursor)
|
||||||
case m.start < m.viewport.Height:
|
case m.start < m.viewport.Height():
|
||||||
m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height))
|
offset = clamp(clamp(offset+n, 0, m.cursor), 0, m.viewport.Height())
|
||||||
case m.viewport.YOffset >= 1:
|
case offset >= 1:
|
||||||
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)
|
offset = clamp(offset+n, 1, m.viewport.Height())
|
||||||
}
|
}
|
||||||
|
m.viewport.SetYOffset(offset)
|
||||||
m.UpdateViewport()
|
m.UpdateViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveDown moves the selection down by any number of rows.
|
// MoveDown moves the selection down by any number of rows.
|
||||||
// It can not go below the last row. Skips gap rows.
|
// It can not go below the last row.
|
||||||
func (m *Model) MoveDown(n int) {
|
func (m *Model) MoveDown(n int) {
|
||||||
originalCursor := m.cursor
|
|
||||||
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
|
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()
|
m.UpdateViewport()
|
||||||
|
|
||||||
|
offset := m.viewport.YOffset()
|
||||||
switch {
|
switch {
|
||||||
case m.end == len(m.rows) && m.viewport.YOffset > 0:
|
case m.end == len(m.rows) && offset > 0:
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))
|
offset = clamp(offset-n, 1, m.viewport.Height())
|
||||||
case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0:
|
case m.cursor > (m.end-m.start)/2 && offset > 0:
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))
|
offset = clamp(offset-n, 1, m.cursor)
|
||||||
case m.viewport.YOffset > 1:
|
case offset > 1:
|
||||||
case m.cursor > m.viewport.YOffset+m.viewport.Height-1:
|
case m.cursor > offset+m.viewport.Height()-1:
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))
|
offset = clamp(offset+1, 0, 1)
|
||||||
}
|
}
|
||||||
|
m.viewport.SetYOffset(offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GotoTop moves the selection to the first row.
|
// GotoTop moves the selection to the first row.
|
||||||
@@ -509,50 +402,36 @@ func (m *Model) GotoBottom() {
|
|||||||
m.MoveDown(len(m.rows))
|
m.MoveDown(len(m.rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index.
|
|
||||||
type StyleFunc func(row, col int, value string) lipgloss.Style
|
|
||||||
|
|
||||||
func (m Model) headersView() string {
|
func (m Model) headersView() string {
|
||||||
var s = make([]string, 0, len(m.cols))
|
s := make([]string, 0, len(m.cols))
|
||||||
for _, col := range m.cols {
|
for _, col := range m.cols {
|
||||||
if col.Width <= 0 {
|
if col.Width <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
|
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
|
||||||
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
|
renderedCell := style.Render(ansi.Truncate(col.Title, col.Width, "…"))
|
||||||
s = append(s, m.styles.Header.Render(renderedCell))
|
s = append(s, m.styles.Header.Render(renderedCell))
|
||||||
}
|
}
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
|
return lipgloss.JoinHorizontal(lipgloss.Top, s...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderRow(r int) string {
|
func (m *Model) renderRow(r int) string {
|
||||||
// Special rendering for gap rows
|
s := make([]string, 0, len(m.cols))
|
||||||
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 {
|
for i, col := range m.cols {
|
||||||
if m.cols[i].Width <= 0 {
|
if m.cols[i].Width <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var cellStyle lipgloss.Style
|
cellStyle := m.rows[r].style
|
||||||
cellStyle = m.rowStyles[r]
|
|
||||||
if r == m.cursor {
|
if r == m.cursor {
|
||||||
cellStyle = cellStyle.Inherit(m.styles.Selected)
|
cellStyle = cellStyle.Inherit(m.styles.Selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
|
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
|
||||||
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…")))
|
renderedCell := cellStyle.Render(style.Render(ansi.Truncate(m.rows[r].task.GetString(col.Name), m.cols[i].Width, "…")))
|
||||||
s = append(s, renderedCell)
|
s = append(s, renderedCell)
|
||||||
}
|
}
|
||||||
|
|
||||||
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
|
row := lipgloss.JoinHorizontal(lipgloss.Top, s...)
|
||||||
|
|
||||||
if r == m.cursor {
|
if r == m.cursor {
|
||||||
return m.styles.Selected.Render(row)
|
return m.styles.Selected.Render(row)
|
||||||
@@ -561,22 +440,6 @@ func (m *Model) renderRow(r int) string {
|
|||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
func max(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func clamp(v, low, high int) int {
|
func clamp(v, low, high int) int {
|
||||||
return min(max(v, low), high)
|
return min(max(v, low), high)
|
||||||
}
|
}
|
||||||
112
internal/pages/main.go
Normal file
112
internal/pages/main.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tasksquire/internal/common"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
// "charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MainPage struct {
|
||||||
|
common *common.Common
|
||||||
|
activePage common.Component
|
||||||
|
|
||||||
|
taskPage common.Component
|
||||||
|
timePage common.Component
|
||||||
|
currentTab int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMainPage(common *common.Common) *MainPage {
|
||||||
|
m := &MainPage{
|
||||||
|
common: common,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.taskPage = NewTaskPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
|
||||||
|
// m.timePage = NewTimePage(common)
|
||||||
|
//
|
||||||
|
m.activePage = m.taskPage
|
||||||
|
m.currentTab = 0
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) Init() tea.Cmd {
|
||||||
|
// return tea.Batch(m.taskPage.Init(), m.timePage.Init())
|
||||||
|
return tea.Batch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
// switch msg := msg.(type) {
|
||||||
|
// case tea.WindowSizeMsg:
|
||||||
|
// m.width = msg.Width
|
||||||
|
// m.height = msg.Height
|
||||||
|
// m.common.SetSize(msg.Width, msg.Height)
|
||||||
|
//
|
||||||
|
// tabHeight := lipgloss.Height(m.renderTabBar())
|
||||||
|
// contentHeight := msg.Height - tabHeight
|
||||||
|
// if contentHeight < 0 {
|
||||||
|
// contentHeight = 0
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
|
||||||
|
// activePage, cmd := m.activePage.Update(newMsg)
|
||||||
|
// m.activePage = activePage.(common.Component)
|
||||||
|
// return m, cmd
|
||||||
|
//
|
||||||
|
// case tea.KeyMsg:
|
||||||
|
// // Only handle tab key for page switching when at the top level (no subpages active)
|
||||||
|
// if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
|
||||||
|
// if m.activePage == m.taskPage {
|
||||||
|
// m.activePage = m.timePage
|
||||||
|
// m.currentTab = 1
|
||||||
|
// } else {
|
||||||
|
// m.activePage = m.taskPage
|
||||||
|
// m.currentTab = 0
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// tabHeight := lipgloss.Height(m.renderTabBar())
|
||||||
|
// contentHeight := m.height - tabHeight
|
||||||
|
// if contentHeight < 0 {
|
||||||
|
// contentHeight = 0
|
||||||
|
// }
|
||||||
|
// m.activePage.SetSize(m.width, contentHeight)
|
||||||
|
//
|
||||||
|
// // Trigger a refresh/init on switch? Maybe not needed if we keep state.
|
||||||
|
// // But we might want to refresh data.
|
||||||
|
// return m, m.activePage.Init()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
activePage, cmd := m.activePage.Update(msg)
|
||||||
|
m.activePage = activePage.(common.Component)
|
||||||
|
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) renderTabBar() string {
|
||||||
|
var tabs []string
|
||||||
|
headers := []string{"Tasks", "Time"}
|
||||||
|
|
||||||
|
for i, header := range headers {
|
||||||
|
style := m.common.Styles.Tab
|
||||||
|
if m.currentTab == i {
|
||||||
|
style = m.common.Styles.ActiveTab
|
||||||
|
}
|
||||||
|
tabs = append(tabs, style.Render(header))
|
||||||
|
}
|
||||||
|
|
||||||
|
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||||
|
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) View() tea.View {
|
||||||
|
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View().Content))
|
||||||
|
v.AltScreen = true
|
||||||
|
return v
|
||||||
|
// return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
|
||||||
|
}
|
||||||
355
internal/pages/tasks.go
Normal file
355
internal/pages/tasks.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
// TODO: update table every second (to show correct relative time)
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tasksquire/internal/common"
|
||||||
|
"tasksquire/internal/components/tasktable"
|
||||||
|
"tasksquire/internal/taskwarrior"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
// "charm.land/lipgloss/v2"
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskPage struct {
|
||||||
|
common *common.Common
|
||||||
|
|
||||||
|
activeReport *taskwarrior.Report
|
||||||
|
activeContext *taskwarrior.Context
|
||||||
|
activeProject string
|
||||||
|
selectedTask *taskwarrior.Task
|
||||||
|
taskCursor int
|
||||||
|
|
||||||
|
tasks taskwarrior.Tasks
|
||||||
|
|
||||||
|
taskTable tasktable.Model
|
||||||
|
|
||||||
|
// Details panel state
|
||||||
|
// detailsPanelActive bool
|
||||||
|
// detailsViewer *detailsviewer.DetailsViewer
|
||||||
|
|
||||||
|
subpage common.Component
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskPage(com *common.Common, report *taskwarrior.Report) *TaskPage {
|
||||||
|
p := &TaskPage{
|
||||||
|
common: com,
|
||||||
|
activeReport: report,
|
||||||
|
activeContext: com.TW.GetActiveContext(),
|
||||||
|
activeProject: "",
|
||||||
|
taskTable: tasktable.New(),
|
||||||
|
// detailsPanelActive: false,
|
||||||
|
// detailsViewer: detailsviewer.New(com),
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskPage) SetSize(width int, height int) {
|
||||||
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
|
baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize()
|
||||||
|
baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize()
|
||||||
|
|
||||||
|
var tableHeight int
|
||||||
|
// if p.detailsPanelActive {
|
||||||
|
// // Allocate 60% for table, 40% for details panel
|
||||||
|
// // Minimum 5 lines for details, minimum 10 lines for table
|
||||||
|
// detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
||||||
|
// tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
||||||
|
//
|
||||||
|
// // Set component size (component handles its own border/padding)
|
||||||
|
// // p.detailsViewer.SetSize(baseWidth, detailsHeight)
|
||||||
|
// } else {
|
||||||
|
tableHeight = baseHeight
|
||||||
|
// }
|
||||||
|
|
||||||
|
p.taskTable.SetWidth(baseWidth)
|
||||||
|
p.taskTable.SetHeight(tableHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskPage) Init() tea.Cmd {
|
||||||
|
return tea.Batch(p.getTasks(), common.DoTick())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
p.SetSize(msg.Width, msg.Height)
|
||||||
|
// case BackMsg:
|
||||||
|
case common.TickMsg:
|
||||||
|
cmds = append(cmds, p.getTasks())
|
||||||
|
cmds = append(cmds, common.DoTick())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
case common.TaskMsg:
|
||||||
|
p.tasks = taskwarrior.Tasks(msg)
|
||||||
|
// case UpdateReportMsg:
|
||||||
|
// p.activeReport = msg
|
||||||
|
// cmds = append(cmds, p.getTasks())
|
||||||
|
// case UpdateContextMsg:
|
||||||
|
// p.activeContext = msg
|
||||||
|
// p.common.TW.SetContext(msg)
|
||||||
|
// p.populateTaskTable(p.tasks)
|
||||||
|
// cmds = append(cmds, p.getTasks())
|
||||||
|
// case UpdateProjectMsg:
|
||||||
|
// p.activeProject = string(msg)
|
||||||
|
// cmds = append(cmds, p.getTasks())
|
||||||
|
// case TaskPickedMsg:
|
||||||
|
// if msg.Task != nil && msg.Task.Status == "pending" {
|
||||||
|
// p.common.TW.StopActiveTasks()
|
||||||
|
// p.common.TW.StartTask(msg.Task)
|
||||||
|
// }
|
||||||
|
// cmds = append(cmds, p.getTasks())
|
||||||
|
// case UpdatedTasksMsg:
|
||||||
|
// cmds = append(cmds, p.getTasks())
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
// Handle ESC when details panel is active
|
||||||
|
// if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) {
|
||||||
|
// p.detailsPanelActive = false
|
||||||
|
// p.detailsViewer.Blur()
|
||||||
|
// p.SetSize(p.common.Width(), p.common.Height())
|
||||||
|
// return p, nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, p.common.Keymap.Quit):
|
||||||
|
return p, tea.Quit
|
||||||
|
}
|
||||||
|
// case key.Matches(msg, p.common.Keymap.SetReport):
|
||||||
|
// p.subpage = NewReportPickerPage(p.common, p.activeReport)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// case key.Matches(msg, p.common.Keymap.SetContext):
|
||||||
|
// p.subpage = NewContextPickerPage(p.common)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Add):
|
||||||
|
// p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Edit):
|
||||||
|
// p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Subtask):
|
||||||
|
// if p.selectedTask != nil {
|
||||||
|
// // Create new task inheriting parent's attributes
|
||||||
|
// newTask := taskwarrior.NewTask()
|
||||||
|
//
|
||||||
|
// // Set parent relationship
|
||||||
|
// newTask.Parent = p.selectedTask.Uuid
|
||||||
|
//
|
||||||
|
// // Copy parent's attributes
|
||||||
|
// newTask.Project = p.selectedTask.Project
|
||||||
|
// newTask.Priority = p.selectedTask.Priority
|
||||||
|
// newTask.Tags = make([]string, len(p.selectedTask.Tags))
|
||||||
|
// copy(newTask.Tags, p.selectedTask.Tags)
|
||||||
|
//
|
||||||
|
// // Copy UDAs (except "details" which is task-specific)
|
||||||
|
// if p.selectedTask.Udas != nil {
|
||||||
|
// newTask.Udas = make(map[string]any)
|
||||||
|
// for k, v := range p.selectedTask.Udas {
|
||||||
|
// // Skip "details" UDA - it's specific to parent task
|
||||||
|
// if k == "details" {
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// // Deep copy other UDA values
|
||||||
|
// newTask.Udas[k] = v
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Open task editor with pre-populated task
|
||||||
|
// p.subpage = NewTaskEditorPage(p.common, newTask)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// }
|
||||||
|
// return p, nil
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
|
// p.common.TW.SetTaskDone(p.selectedTask)
|
||||||
|
// return p, p.getTasks()
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Delete):
|
||||||
|
// p.common.TW.DeleteTask(p.selectedTask)
|
||||||
|
// return p, p.getTasks()
|
||||||
|
// case key.Matches(msg, p.common.Keymap.SetProject):
|
||||||
|
// p.subpage = NewProjectPickerPage(p.common, p.activeProject)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// case key.Matches(msg, p.common.Keymap.PickProjectTask):
|
||||||
|
// p.subpage = NewProjectTaskPickerPage(p.common)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Tag):
|
||||||
|
// if p.selectedTask != nil {
|
||||||
|
// tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
||||||
|
// if p.selectedTask.HasTag(tag) {
|
||||||
|
// p.selectedTask.RemoveTag(tag)
|
||||||
|
// } else {
|
||||||
|
// p.selectedTask.AddTag(tag)
|
||||||
|
// }
|
||||||
|
// p.common.TW.ImportTask(p.selectedTask)
|
||||||
|
// return p, p.getTasks()
|
||||||
|
// }
|
||||||
|
// return p, nil
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Undo):
|
||||||
|
// p.common.TW.Undo()
|
||||||
|
// return p, p.getTasks()
|
||||||
|
// case key.Matches(msg, p.common.Keymap.StartStop):
|
||||||
|
// if p.selectedTask != nil && p.selectedTask.Status == "pending" {
|
||||||
|
// if p.selectedTask.Start == "" {
|
||||||
|
// p.common.TW.StopActiveTasks()
|
||||||
|
// p.common.TW.StartTask(p.selectedTask)
|
||||||
|
// } else {
|
||||||
|
// p.common.TW.StopTask(p.selectedTask)
|
||||||
|
// }
|
||||||
|
// return p, p.getTasks()
|
||||||
|
// }
|
||||||
|
// case key.Matches(msg, p.common.Keymap.ViewDetails):
|
||||||
|
// if p.selectedTask != nil {
|
||||||
|
// // Toggle details panel
|
||||||
|
// p.detailsPanelActive = !p.detailsPanelActive
|
||||||
|
// if p.detailsPanelActive {
|
||||||
|
// p.detailsViewer.SetTask(p.selectedTask)
|
||||||
|
// p.detailsViewer.Focus()
|
||||||
|
// } else {
|
||||||
|
// p.detailsViewer.Blur()
|
||||||
|
// }
|
||||||
|
// p.SetSize(p.common.Width(), p.common.Height())
|
||||||
|
// return p, nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// var cmd tea.Cmd
|
||||||
|
//
|
||||||
|
// // Route keyboard messages to details viewer when panel is active
|
||||||
|
// if p.detailsPanelActive {
|
||||||
|
// var viewerCmd tea.Cmd
|
||||||
|
// var viewerModel tea.Model
|
||||||
|
// viewerModel, viewerCmd = p.detailsViewer.Update(msg)
|
||||||
|
// p.detailsViewer = viewerModel.(*detailsviewer.DetailsViewer)
|
||||||
|
// cmds = append(cmds, viewerCmd)
|
||||||
|
// } else {
|
||||||
|
// // Route to table when details panel not active
|
||||||
|
// p.taskTable, cmd = p.taskTable.Update(msg)
|
||||||
|
// cmds = append(cmds, cmd)
|
||||||
|
//
|
||||||
|
// if p.tasks != nil && len(p.tasks) > 0 {
|
||||||
|
// p.selectedTask = p.tasks[p.taskTable.Cursor()]
|
||||||
|
// } else {
|
||||||
|
// p.selectedTask = nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskPage) View() tea.View {
|
||||||
|
if len(p.tasks) == 0 {
|
||||||
|
return tea.NewView(p.common.Styles.Base.Render("No tasks found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tableView := p.taskTable.View()
|
||||||
|
|
||||||
|
return tea.NewView(tableView)
|
||||||
|
}
|
||||||
|
//
|
||||||
|
// if !p.detailsPanelActive {
|
||||||
|
// return tableView
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Combine table and details panel vertically
|
||||||
|
// return lipgloss.JoinVertical(
|
||||||
|
// lipgloss.Left,
|
||||||
|
// tableView,
|
||||||
|
// p.detailsViewer.View(),
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func (p *TaskPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||||
|
// if len(tasks) == 0 {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Build task tree for hierarchical display
|
||||||
|
// taskTree := taskwarrior.BuildTaskTree(tasks)
|
||||||
|
//
|
||||||
|
// // Use flattened tree list for display order
|
||||||
|
// orderedTasks := make(taskwarrior.Tasks, len(taskTree.FlatList))
|
||||||
|
// for i, node := range taskTree.FlatList {
|
||||||
|
// orderedTasks[i] = node.Task
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// selected := p.taskTable.Cursor()
|
||||||
|
//
|
||||||
|
// // Adjust cursor for tree ordering
|
||||||
|
// if p.selectedTask != nil {
|
||||||
|
// for i, task := range orderedTasks {
|
||||||
|
// if task.Uuid == p.selectedTask.Uuid {
|
||||||
|
// selected = i
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// if selected > len(orderedTasks)-1 {
|
||||||
|
// selected = len(orderedTasks) - 1
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Calculate proper dimensions based on whether details panel is active
|
||||||
|
// baseHeight := p.common.Height() - p.common.Styles.Base.GetVerticalFrameSize()
|
||||||
|
// baseWidth := p.common.Width() - p.common.Styles.Base.GetHorizontalFrameSize()
|
||||||
|
//
|
||||||
|
// var tableHeight int
|
||||||
|
// if p.detailsPanelActive {
|
||||||
|
// // Allocate 60% for table, 40% for details panel
|
||||||
|
// // Minimum 5 lines for details, minimum 10 lines for table
|
||||||
|
// detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
||||||
|
// tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
||||||
|
// } else {
|
||||||
|
// tableHeight = baseHeight
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// p.taskTable = table.New(
|
||||||
|
// p.common,
|
||||||
|
// able.WithReport(p.activeReport),
|
||||||
|
// table.WithTasks(orderedTasks),
|
||||||
|
// table.WithTaskTree(taskTree),
|
||||||
|
// table.WithFocused(true),
|
||||||
|
// table.WithWidth(baseWidth),
|
||||||
|
// table.WithHeight(tableHeight),
|
||||||
|
// table.WithStyles(p.common.Styles.TableStyle),
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// if selected == 0 {
|
||||||
|
// selected = p.taskTable.Cursor()
|
||||||
|
// }
|
||||||
|
// if selected < len(orderedTasks) {
|
||||||
|
// p.taskTable.SetCursor(selected)
|
||||||
|
// } else {
|
||||||
|
// p.taskTable.SetCursor(len(p.tasks) - 1)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Refresh details content if panel is active
|
||||||
|
// if p.detailsPanelActive && p.selectedTask != nil {
|
||||||
|
// p.detailsViewer.SetTask(p.selectedTask)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
func (p *TaskPage) getTasks() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
filters := []string{}
|
||||||
|
if p.activeProject != "" {
|
||||||
|
filters = append(filters, "project:"+p.activeProject)
|
||||||
|
}
|
||||||
|
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
|
||||||
|
return common.TaskMsg(tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ func (tc *TWConfig) GetConfig() map[string]string {
|
|||||||
|
|
||||||
func (tc *TWConfig) Get(key string) string {
|
func (tc *TWConfig) Get(key string) string {
|
||||||
if _, ok := tc.config[key]; !ok {
|
if _, ok := tc.config[key]; !ok {
|
||||||
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
|
slog.Debug(fmt.Sprintf("Key not found in Taskwarrior config: %s", key))
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
slog.Error("Field not implemented", "field", field)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ func (t *Task) GetDate(field string) time.Time {
|
|||||||
|
|
||||||
dt, err := time.Parse(dtformat, dateString)
|
dt, err := time.Parse(dtformat, dateString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
return dt
|
return dt
|
||||||
@@ -384,7 +384,7 @@ func formatDate(date string, format string) string {
|
|||||||
|
|
||||||
dt, err := time.Parse(dtformat, date)
|
dt, err := time.Parse(dtformat, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +408,7 @@ func formatDate(date string, format string) string {
|
|||||||
case "countdown":
|
case "countdown":
|
||||||
return parseCountdown(time.Since(dt))
|
return parseCountdown(time.Since(dt))
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
slog.Error("Date format not implemented", "format", format)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ package taskwarrior
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -105,26 +106,28 @@ type TaskWarrior interface {
|
|||||||
Undo()
|
Undo()
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskSquire struct {
|
type TaskwarriorInterop struct {
|
||||||
configLocation string
|
configLocation string
|
||||||
defaultArgs []string
|
defaultArgs []string
|
||||||
config *TWConfig
|
config *TWConfig
|
||||||
reports Reports
|
reports Reports
|
||||||
contexts Contexts
|
contexts Contexts
|
||||||
|
ctx context.Context
|
||||||
|
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskSquire(configLocation string) *TaskSquire {
|
func NewTaskwarriorInterop(ctx context.Context, configLocation string) *TaskwarriorInterop {
|
||||||
if _, err := exec.LookPath(twBinary); err != nil {
|
if _, err := exec.LookPath(twBinary); err != nil {
|
||||||
slog.Error("Taskwarrior not found")
|
slog.Error("Taskwarrior not found")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defaultArgs := []string{fmt.Sprintf("rc=%s", configLocation), "rc.verbose=nothing", "rc.dependency.confirmation=no", "rc.recurrence.confirmation=no", "rc.confirmation=no", "rc.json.array=1"}
|
defaultArgs := []string{fmt.Sprintf("rc=%s", configLocation), "rc.verbose=nothing", "rc.dependency.confirmation=no", "rc.recurrence.confirmation=no", "rc.confirmation=no", "rc.json.array=1"}
|
||||||
|
|
||||||
ts := &TaskSquire{
|
ts := &TaskwarriorInterop{
|
||||||
configLocation: configLocation,
|
configLocation: configLocation,
|
||||||
defaultArgs: defaultArgs,
|
defaultArgs: defaultArgs,
|
||||||
|
ctx: ctx,
|
||||||
mutex: sync.Mutex{},
|
mutex: sync.Mutex{},
|
||||||
}
|
}
|
||||||
ts.config = ts.extractConfig()
|
ts.config = ts.extractConfig()
|
||||||
@@ -138,14 +141,14 @@ func NewTaskSquire(configLocation string) *TaskSquire {
|
|||||||
return ts
|
return ts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetConfig() *TWConfig {
|
func (ts *TaskwarriorInterop) GetConfig() *TWConfig {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
return ts.config
|
return ts.config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
func (ts *TaskwarriorInterop) GetTasks(report *Report, filter ...string) Tasks {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@@ -169,22 +172,25 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
|||||||
exportArgs = append(exportArgs, report.Name)
|
exportArgs = append(exportArgs, report.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(args, exportArgs...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(args, exportArgs...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting report:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting report", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks := make(Tasks, 0)
|
tasks := make(Tasks, 0)
|
||||||
err = json.Unmarshal(output, &tasks)
|
err = json.Unmarshal(output, &tasks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed unmarshalling tasks:", err)
|
slog.Error("Failed unmarshalling tasks", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, task := range tasks {
|
for _, task := range tasks {
|
||||||
if task.Depends != nil && len(task.Depends) > 0 {
|
if len(task.Depends) > 0 {
|
||||||
ids := make([]string, len(task.Depends))
|
ids := make([]string, len(task.Depends))
|
||||||
for i, dependUuid := range task.Depends {
|
for i, dependUuid := range task.Depends {
|
||||||
ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)})
|
ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)})
|
||||||
@@ -197,18 +203,18 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
|||||||
return tasks
|
return tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) getIds(filter []string) string {
|
func (ts *TaskwarriorInterop) getIds(filter []string) string {
|
||||||
cmd := exec.Command(twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting field:", err)
|
slog.Error("Failed getting field", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(string(out))
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetContext(context string) *Context {
|
func (ts *TaskwarriorInterop) GetContext(context string) *Context {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@@ -219,12 +225,12 @@ func (ts *TaskSquire) GetContext(context string) *Context {
|
|||||||
if context, ok := ts.contexts[context]; ok {
|
if context, ok := ts.contexts[context]; ok {
|
||||||
return context
|
return context
|
||||||
} else {
|
} else {
|
||||||
slog.Error(fmt.Sprintf("Context not found: %s", context.Name))
|
slog.Error("Context not found", "name", context)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetActiveContext() *Context {
|
func (ts *TaskwarriorInterop) GetActiveContext() *Context {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@@ -237,22 +243,22 @@ func (ts *TaskSquire) GetActiveContext() *Context {
|
|||||||
return ts.contexts["none"]
|
return ts.contexts["none"]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetContexts() Contexts {
|
func (ts *TaskwarriorInterop) GetContexts() Contexts {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
return ts.contexts
|
return ts.contexts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetProjects() []string {
|
func (ts *TaskwarriorInterop) GetProjects() []string {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting projects:", err)
|
slog.Error("Failed getting projects", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +274,7 @@ func (ts *TaskSquire) GetProjects() []string {
|
|||||||
return projects
|
return projects
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetPriorities() []string {
|
func (ts *TaskwarriorInterop) GetPriorities() []string {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@@ -282,15 +288,15 @@ func (ts *TaskSquire) GetPriorities() []string {
|
|||||||
return priorities
|
return priorities
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetTags() []string {
|
func (ts *TaskwarriorInterop) GetTags() []string {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting tags:", err)
|
slog.Error("Failed getting tags", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +310,7 @@ func (ts *TaskSquire) GetTags() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tag := range strings.Split(ts.config.Get("uda.tasksquire.tags.default"), ",") {
|
for _, tag := range strings.Split(ts.config.Get("uda.TaskwarriorInterop.tags.default"), ",") {
|
||||||
if _, ok := tagSet[tag]; !ok {
|
if _, ok := tagSet[tag]; !ok {
|
||||||
tags = append(tags, tag)
|
tags = append(tags, tag)
|
||||||
}
|
}
|
||||||
@@ -315,28 +321,31 @@ func (ts *TaskSquire) GetTags() []string {
|
|||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetReport(report string) *Report {
|
func (ts *TaskwarriorInterop) GetReport(report string) *Report {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
return ts.reports[report]
|
return ts.reports[report]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetReports() Reports {
|
func (ts *TaskwarriorInterop) GetReports() Reports {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
return ts.reports
|
return ts.reports
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetUdas() []Uda {
|
func (ts *TaskwarriorInterop) GetUdas() []Uda {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "_udas")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "_udas")...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting config:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting UDAs", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +354,7 @@ func (ts *TaskSquire) GetUdas() []Uda {
|
|||||||
if uda != "" {
|
if uda != "" {
|
||||||
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
|
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
|
||||||
if udatype == "" {
|
if udatype == "" {
|
||||||
slog.Error(fmt.Sprintf("UDA type not found: %s", uda))
|
slog.Error("UDA type not found", "uda", uda)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +377,7 @@ func (ts *TaskSquire) GetUdas() []Uda {
|
|||||||
return udas
|
return udas
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) SetContext(context *Context) error {
|
func (ts *TaskwarriorInterop) SetContext(context *Context) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@@ -376,9 +385,9 @@ func (ts *TaskSquire) SetContext(context *Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, []string{"context", context.Name}...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, []string{"context", context.Name}...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed setting context:", err)
|
slog.Error("Failed setting context", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +398,7 @@ func (ts *TaskSquire) SetContext(context *Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// func (ts *TaskSquire) AddTask(task *Task) error {
|
// func (ts *TaskwarriorInterop) AddTask(task *Task) error {
|
||||||
// ts.mutex.Lock()
|
// ts.mutex.Lock()
|
||||||
// defer ts.mutex.Unlock()
|
// defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@@ -427,135 +436,147 @@ func (ts *TaskSquire) SetContext(context *Context) error {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
// TODO error handling
|
// TODO error handling
|
||||||
func (ts *TaskSquire) ImportTask(task *Task) {
|
func (ts *TaskwarriorInterop) ImportTask(task *Task) {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
tasks, err := json.Marshal(Tasks{task})
|
tasks, err := json.Marshal(Tasks{task})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed marshalling task:", err)
|
slog.Error("Failed marshalling task", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
|
||||||
cmd.Stdin = bytes.NewBuffer(tasks)
|
cmd.Stdin = bytes.NewBuffer(tasks)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed modifying task:", err, string(out))
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("Failed modifying task", "error", err, "output", string(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) SetTaskDone(task *Task) {
|
func (ts *TaskwarriorInterop) SetTaskDone(task *Task) {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed setting task done:", err)
|
slog.Error("Failed setting task done", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) DeleteTask(task *Task) {
|
func (ts *TaskwarriorInterop) DeleteTask(task *Task) {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed deleting task:", err)
|
slog.Error("Failed deleting task", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) Undo() {
|
func (ts *TaskwarriorInterop) Undo() {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed undoing task:", err)
|
slog.Error("Failed undoing task", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) StartTask(task *Task) {
|
func (ts *TaskwarriorInterop) StartTask(task *Task) {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed starting task:", err)
|
slog.Error("Failed starting task", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) StopTask(task *Task) {
|
func (ts *TaskwarriorInterop) StopTask(task *Task) {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed stopping task:", err)
|
slog.Error("Failed stopping task", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) StopActiveTasks() {
|
func (ts *TaskwarriorInterop) StopActiveTasks() {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting active tasks:", "error", err, "output", string(output))
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting active tasks", "error", err, "output", string(output))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks := make(Tasks, 0)
|
tasks := make(Tasks, 0)
|
||||||
err = json.Unmarshal(output, &tasks)
|
err = json.Unmarshal(output, &tasks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed unmarshalling active tasks:", err)
|
slog.Error("Failed unmarshalling active tasks", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, task := range tasks {
|
for _, task := range tasks {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed stopping task:", err)
|
slog.Error("Failed stopping task", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetInformation(task *Task) string {
|
func (ts *TaskwarriorInterop) GetInformation(task *Task) string {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting task information:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting task information", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(output)
|
return string(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) {
|
func (ts *TaskwarriorInterop) AddTaskAnnotation(uuid string, annotation string) {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed adding annotation:", err)
|
slog.Error("Failed adding annotation", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) extractConfig() *TWConfig {
|
func (ts *TaskwarriorInterop) extractConfig() *TWConfig {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
slog.Error("Failed getting config", "error", err, "output", string(output))
|
slog.Error("Failed getting config", "error", err, "output", string(output))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -563,8 +584,8 @@ func (ts *TaskSquire) extractConfig() *TWConfig {
|
|||||||
return NewConfig(strings.Split(string(output), "\n"))
|
return NewConfig(strings.Split(string(output), "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) extractReports() Reports {
|
func (ts *TaskwarriorInterop) extractReports() Reports {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -609,11 +630,14 @@ func extractReports(config string) []string {
|
|||||||
return reports
|
return reports
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) extractContexts() Contexts {
|
func (ts *TaskwarriorInterop) extractContexts() Contexts {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
slog.Error("Failed getting contexts", "error", err, "output", string(output))
|
slog.Error("Failed getting contexts", "error", err, "output", string(output))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package taskwarrior
|
package taskwarrior
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -56,7 +57,7 @@ func TestTaskSquire_GetContext(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
tt.prep()
|
tt.prep()
|
||||||
ts := NewTaskSquire(tt.fields.configLocation)
|
ts := NewTaskSquire(context.Background(), tt.fields.configLocation)
|
||||||
if got := ts.GetActiveContext(); got.Name != tt.want {
|
if got := ts.GetActiveContext(); got.Name != tt.want {
|
||||||
t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want)
|
t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ func (i *Interval) GetString(field string) string {
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
slog.Error("Field not implemented", "field", field)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func (i *Interval) GetString(field string) string {
|
|||||||
func (i *Interval) GetDuration() string {
|
func (i *Interval) GetDuration() string {
|
||||||
start, err := time.Parse(dtformat, i.Start)
|
start, err := time.Parse(dtformat, i.Start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse start time:", err)
|
slog.Error("Failed to parse start time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ func (i *Interval) GetDuration() string {
|
|||||||
} else {
|
} else {
|
||||||
end, err = time.Parse(dtformat, i.End)
|
end, err = time.Parse(dtformat, i.End)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse end time:", err)
|
slog.Error("Failed to parse end time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ func (i *Interval) GetDuration() string {
|
|||||||
func (i *Interval) GetStartTime() time.Time {
|
func (i *Interval) GetStartTime() time.Time {
|
||||||
dt, err := time.Parse(dtformat, i.Start)
|
dt, err := time.Parse(dtformat, i.Start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
return dt
|
return dt
|
||||||
@@ -146,7 +146,7 @@ func (i *Interval) GetEndTime() time.Time {
|
|||||||
}
|
}
|
||||||
dt, err := time.Parse(dtformat, i.End)
|
dt, err := time.Parse(dtformat, i.End)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
return dt
|
return dt
|
||||||
@@ -187,7 +187,7 @@ func formatDate(date string, format string) string {
|
|||||||
|
|
||||||
dt, err := time.Parse(dtformat, date)
|
dt, err := time.Parse(dtformat, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ func formatDate(date string, format string) string {
|
|||||||
case "relative":
|
case "relative":
|
||||||
return parseDurationVague(time.Until(dt))
|
return parseDurationVague(time.Until(dt))
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
slog.Error("Date format not implemented", "format", format)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ package timewarrior
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -40,23 +41,25 @@ type TimeWarrior interface {
|
|||||||
Undo()
|
Undo()
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimeSquire struct {
|
type TimewarriorInterop struct {
|
||||||
configLocation string
|
configLocation string
|
||||||
defaultArgs []string
|
defaultArgs []string
|
||||||
config *TWConfig
|
config *TWConfig
|
||||||
|
ctx context.Context
|
||||||
|
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTimeSquire(configLocation string) *TimeSquire {
|
func NewTimewarriorInterop(ctx context.Context, configLocation string) *TimewarriorInterop {
|
||||||
if _, err := exec.LookPath(twBinary); err != nil {
|
if _, err := exec.LookPath(twBinary); err != nil {
|
||||||
slog.Error("Timewarrior not found")
|
slog.Error("Timewarrior not found")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := &TimeSquire{
|
ts := &TimewarriorInterop{
|
||||||
configLocation: configLocation,
|
configLocation: configLocation,
|
||||||
defaultArgs: []string{},
|
defaultArgs: []string{},
|
||||||
|
ctx: ctx,
|
||||||
mutex: sync.Mutex{},
|
mutex: sync.Mutex{},
|
||||||
}
|
}
|
||||||
ts.config = ts.extractConfig()
|
ts.config = ts.extractConfig()
|
||||||
@@ -64,22 +67,22 @@ func NewTimeSquire(configLocation string) *TimeSquire {
|
|||||||
return ts
|
return ts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) GetConfig() *TWConfig {
|
func (ts *TimewarriorInterop) GetConfig() *TWConfig {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
return ts.config
|
return ts.config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) GetTags() []string {
|
func (ts *TimewarriorInterop) GetTags() []string {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting tags:", err)
|
slog.Error("Failed getting tags", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +108,7 @@ func (ts *TimeSquire) GetTags() []string {
|
|||||||
// GetTagCombinations returns unique tag combinations from intervals,
|
// GetTagCombinations returns unique tag combinations from intervals,
|
||||||
// ordered newest first (most recent intervals' tags appear first).
|
// ordered newest first (most recent intervals' tags appear first).
|
||||||
// Returns formatted strings like "dev client-work meeting".
|
// Returns formatted strings like "dev client-work meeting".
|
||||||
func (ts *TimeSquire) GetTagCombinations() []string {
|
func (ts *TimewarriorInterop) GetTagCombinations() []string {
|
||||||
intervals := ts.GetIntervals() // Already sorted newest first
|
intervals := ts.GetIntervals() // Already sorted newest first
|
||||||
|
|
||||||
// Track unique combinations while preserving order
|
// Track unique combinations while preserving order
|
||||||
@@ -144,23 +147,26 @@ func formatTagsForCombination(tags []string) string {
|
|||||||
|
|
||||||
// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
|
// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
|
||||||
// Caller must hold ts.mutex.
|
// Caller must hold ts.mutex.
|
||||||
func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
|
func (ts *TimewarriorInterop) getIntervalsUnlocked(filter ...string) Intervals {
|
||||||
args := append(ts.defaultArgs, "export")
|
args := append(ts.defaultArgs, "export")
|
||||||
if filter != nil {
|
if filter != nil {
|
||||||
args = append(args, filter...)
|
args = append(args, filter...)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, args...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting intervals:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting intervals", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
intervals := make(Intervals, 0)
|
intervals := make(Intervals, 0)
|
||||||
err = json.Unmarshal(output, &intervals)
|
err = json.Unmarshal(output, &intervals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed unmarshalling intervals:", err)
|
slog.Error("Failed unmarshalling intervals", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,14 +182,14 @@ func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
|
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
|
||||||
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
func (ts *TimewarriorInterop) GetIntervals(filter ...string) Intervals {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
return ts.getIntervalsUnlocked(filter...)
|
return ts.getIntervalsUnlocked(filter...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) StartTracking(tags []string) error {
|
func (ts *TimewarriorInterop) StartTracking(tags []string) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@@ -194,116 +200,116 @@ func (ts *TimeSquire) StartTracking(tags []string) error {
|
|||||||
args := append(ts.defaultArgs, "start")
|
args := append(ts.defaultArgs, "start")
|
||||||
args = append(args, tags...)
|
args = append(args, tags...)
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, args...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed starting tracking:", err)
|
slog.Error("Failed starting tracking", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) StopTracking() error {
|
func (ts *TimewarriorInterop) StopTracking() error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "stop")...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed stopping tracking:", err)
|
slog.Error("Failed stopping tracking", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) ContinueTracking() error {
|
func (ts *TimewarriorInterop) ContinueTracking() error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "continue")...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed continuing tracking:", err)
|
slog.Error("Failed continuing tracking", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) ContinueInterval(id int) error {
|
func (ts *TimewarriorInterop) ContinueInterval(id int) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed continuing interval:", err)
|
slog.Error("Failed continuing interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) CancelTracking() error {
|
func (ts *TimewarriorInterop) CancelTracking() error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "cancel")...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed canceling tracking:", err)
|
slog.Error("Failed canceling tracking", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) DeleteInterval(id int) error {
|
func (ts *TimewarriorInterop) DeleteInterval(id int) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed deleting interval:", err)
|
slog.Error("Failed deleting interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) FillInterval(id int) error {
|
func (ts *TimewarriorInterop) FillInterval(id int) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed filling interval:", err)
|
slog.Error("Failed filling interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) JoinInterval(id int) error {
|
func (ts *TimewarriorInterop) JoinInterval(id int) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
// Join the current interval with the previous one
|
// Join the current interval with the previous one
|
||||||
// The previous interval has id+1 (since intervals are ordered newest first)
|
// The previous interval has id+1 (since intervals are ordered newest first)
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed joining interval:", err)
|
slog.Error("Failed joining interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
func (ts *TimewarriorInterop) ModifyInterval(interval *Interval, adjust bool) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
// Export the modified interval
|
// Export the modified interval
|
||||||
intervals, err := json.Marshal(Intervals{interval})
|
intervals, err := json.Marshal(Intervals{interval})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed marshalling interval:", err)
|
slog.Error("Failed marshalling interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,18 +320,18 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import the modified interval
|
// Import the modified interval
|
||||||
cmd := exec.Command(twBinary, args...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
cmd.Stdin = bytes.NewBuffer(intervals)
|
cmd.Stdin = bytes.NewBuffer(intervals)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed modifying interval:", err, string(out))
|
slog.Error("Failed modifying interval", "error", err, "output", string(out))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) GetSummary(filter ...string) string {
|
func (ts *TimewarriorInterop) GetSummary(filter ...string) string {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@@ -334,21 +340,21 @@ func (ts *TimeSquire) GetSummary(filter ...string) string {
|
|||||||
args = append(args, filter...)
|
args = append(args, filter...)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, args...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting summary:", err)
|
slog.Error("Failed getting summary", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(output)
|
return string(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) GetActive() *Interval {
|
func (ts *TimewarriorInterop) GetActive() *Interval {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil || string(output) == "0\n" {
|
if err != nil || string(output) == "0\n" {
|
||||||
return nil
|
return nil
|
||||||
@@ -365,22 +371,22 @@ func (ts *TimeSquire) GetActive() *Interval {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) Undo() {
|
func (ts *TimewarriorInterop) Undo() {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed undoing:", err)
|
slog.Error("Failed undoing", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) extractConfig() *TWConfig {
|
func (ts *TimewarriorInterop) extractConfig() *TWConfig {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting config:", err)
|
slog.Error("Failed getting config", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
85
main.go
85
main.go
@@ -1,85 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/pages"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
"tasksquire/timewarrior"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var taskrcPath string
|
|
||||||
if taskrcEnv := os.Getenv("TASKRC"); taskrcEnv != "" {
|
|
||||||
taskrcPath = taskrcEnv
|
|
||||||
} else if _, err := os.Stat(os.Getenv("HOME") + "/.taskrc"); err == nil {
|
|
||||||
taskrcPath = os.Getenv("HOME") + "/.taskrc"
|
|
||||||
} else if _, err := os.Stat(os.Getenv("HOME") + "/.config/task/taskrc"); err == nil {
|
|
||||||
taskrcPath = os.Getenv("HOME") + "/.config/task/taskrc"
|
|
||||||
} else {
|
|
||||||
log.Fatal("Unable to find taskrc file")
|
|
||||||
}
|
|
||||||
|
|
||||||
var timewConfigPath string
|
|
||||||
if timewConfigEnv := os.Getenv("TIMEWARRIORDB"); timewConfigEnv != "" {
|
|
||||||
timewConfigPath = timewConfigEnv
|
|
||||||
} else if _, err := os.Stat(os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg"); err == nil {
|
|
||||||
timewConfigPath = os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg"
|
|
||||||
} else {
|
|
||||||
// Default to empty string if not found, let TimeSquire handle defaults or errors if necessary
|
|
||||||
// But TimeSquire seems to only take config location.
|
|
||||||
// Let's assume standard location if not found or pass empty if it auto-detects.
|
|
||||||
// Checking TimeSquire implementation: it calls `timew show` which usually works if timew is in path.
|
|
||||||
timewConfigPath = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
ts := taskwarrior.NewTaskSquire(taskrcPath)
|
|
||||||
if ts == nil {
|
|
||||||
log.Fatal("Failed to initialize TaskSquire. Please check your Taskwarrior installation and taskrc file.")
|
|
||||||
}
|
|
||||||
tws := timewarrior.NewTimeSquire(timewConfigPath)
|
|
||||||
ctx := context.Background()
|
|
||||||
common := common.NewCommon(ctx, ts, tws)
|
|
||||||
|
|
||||||
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to open log file: %v", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// Create a new slog handler for the file
|
|
||||||
handler := slog.NewTextHandler(file, &slog.HandlerOptions{})
|
|
||||||
|
|
||||||
// Set the default logger to use the file handler
|
|
||||||
slog.SetDefault(slog.New(handler))
|
|
||||||
|
|
||||||
// form := huh.NewForm(
|
|
||||||
// huh.NewGroup(
|
|
||||||
// huh.NewSelect[string]().
|
|
||||||
// Options(huh.NewOptions(config.Reports...)...).
|
|
||||||
// Title("Report").
|
|
||||||
// Description("Choose the report to display").
|
|
||||||
// Value(&report),
|
|
||||||
// ),
|
|
||||||
// )
|
|
||||||
|
|
||||||
// err = form.Run()
|
|
||||||
// if err != nil {
|
|
||||||
// slog.Error("Uh oh:", err)
|
|
||||||
// os.Exit(1)
|
|
||||||
// }
|
|
||||||
m := pages.NewMainPage(common)
|
|
||||||
|
|
||||||
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
#
|
|
||||||
# Copyright 2016 - 2021, 2023, Gothenburg Bit Factory
|
|
||||||
#
|
|
||||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
# of this software and associated documentation files (the "Software"), to deal
|
|
||||||
# in the Software without restriction, including without limitation the rights
|
|
||||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
# copies of the Software, and to permit persons to whom the Software is
|
|
||||||
# furnished to do so, subject to the following conditions:
|
|
||||||
#
|
|
||||||
# The above copyright notice and this permission notice shall be included
|
|
||||||
# in all copies or substantial portions of the Software.
|
|
||||||
#
|
|
||||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
||||||
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
||||||
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
# SOFTWARE.
|
|
||||||
#
|
|
||||||
# https://www.opensource.org/licenses/mit-license.php
|
|
||||||
#
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Hook should extract all the following for use as Timewarrior tags:
|
|
||||||
# UUID
|
|
||||||
# Project
|
|
||||||
# Tags
|
|
||||||
# Description
|
|
||||||
# UDAs
|
|
||||||
|
|
||||||
try:
|
|
||||||
input_stream = sys.stdin.buffer
|
|
||||||
except AttributeError:
|
|
||||||
input_stream = sys.stdin
|
|
||||||
|
|
||||||
|
|
||||||
def extract_tags_from(json_obj):
|
|
||||||
# Extract attributes for use as tags.
|
|
||||||
tags = [json_obj['description']]
|
|
||||||
|
|
||||||
# Add UUID with prefix for reliable task linking
|
|
||||||
if 'uuid' in json_obj:
|
|
||||||
tags.append('uuid:' + json_obj['uuid'])
|
|
||||||
|
|
||||||
# Add project with prefix for separate column display
|
|
||||||
if 'project' in json_obj:
|
|
||||||
tags.append('project:' + json_obj['project'])
|
|
||||||
|
|
||||||
if 'tags' in json_obj:
|
|
||||||
if type(json_obj['tags']) is str:
|
|
||||||
# Usage of tasklib (e.g. in taskpirate) converts the tag list into a string
|
|
||||||
# If this is the case, convert it back into a list first
|
|
||||||
# See https://github.com/tbabej/taskpirate/issues/11
|
|
||||||
task_tags = [tag for tag in json_obj['tags'].split(',') if tag != 'next']
|
|
||||||
tags.extend(task_tags)
|
|
||||||
else:
|
|
||||||
# Filter out the 'next' tag
|
|
||||||
task_tags = [tag for tag in json_obj['tags'] if tag != 'next']
|
|
||||||
tags.extend(task_tags)
|
|
||||||
|
|
||||||
return tags
|
|
||||||
|
|
||||||
|
|
||||||
def extract_annotation_from(json_obj):
|
|
||||||
|
|
||||||
if 'annotations' not in json_obj:
|
|
||||||
return '\'\''
|
|
||||||
|
|
||||||
return json_obj['annotations'][0]['description']
|
|
||||||
|
|
||||||
|
|
||||||
def main(old, new):
|
|
||||||
|
|
||||||
start_or_stop = ''
|
|
||||||
|
|
||||||
# Started task.
|
|
||||||
if 'start' in new and 'start' not in old:
|
|
||||||
start_or_stop = 'start'
|
|
||||||
|
|
||||||
# Stopped task.
|
|
||||||
elif ('start' not in new or 'end' in new) and 'start' in old:
|
|
||||||
start_or_stop = 'stop'
|
|
||||||
|
|
||||||
if start_or_stop:
|
|
||||||
tags = extract_tags_from(new)
|
|
||||||
|
|
||||||
subprocess.call(['timew', start_or_stop] + tags + [':yes'])
|
|
||||||
|
|
||||||
# Modifications to task other than start/stop
|
|
||||||
elif 'start' in new and 'start' in old:
|
|
||||||
old_tags = extract_tags_from(old)
|
|
||||||
new_tags = extract_tags_from(new)
|
|
||||||
|
|
||||||
if old_tags != new_tags:
|
|
||||||
subprocess.call(['timew', 'untag', '@1'] + old_tags + [':yes'])
|
|
||||||
subprocess.call(['timew', 'tag', '@1'] + new_tags + [':yes'])
|
|
||||||
|
|
||||||
old_annotation = extract_annotation_from(old)
|
|
||||||
new_annotation = extract_annotation_from(new)
|
|
||||||
|
|
||||||
if old_annotation != new_annotation:
|
|
||||||
subprocess.call(['timew', 'annotate', '@1', new_annotation])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
old = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
|
|
||||||
new = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
|
|
||||||
print(json.dumps(new))
|
|
||||||
main(old, new)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/components/picker"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ContextPickerPage struct {
|
|
||||||
common *common.Common
|
|
||||||
contexts taskwarrior.Contexts
|
|
||||||
picker *picker.Picker
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
|
||||||
p := &ContextPickerPage{
|
|
||||||
common: common,
|
|
||||||
contexts: common.TW.GetContexts(),
|
|
||||||
}
|
|
||||||
|
|
||||||
selected := common.TW.GetActiveContext().Name
|
|
||||||
|
|
||||||
itemProvider := func() []list.Item {
|
|
||||||
contexts := common.TW.GetContexts()
|
|
||||||
options := make([]string, 0)
|
|
||||||
for _, c := range contexts {
|
|
||||||
if c.Name != "none" {
|
|
||||||
options = append(options, c.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.Sort(options)
|
|
||||||
options = append([]string{"(none)"}, options...)
|
|
||||||
|
|
||||||
items := []list.Item{}
|
|
||||||
for _, opt := range options {
|
|
||||||
items = append(items, picker.NewItem(opt))
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect := func(item list.Item) tea.Cmd {
|
|
||||||
return func() tea.Msg { return contextSelectedMsg{item: item} }
|
|
||||||
}
|
|
||||||
|
|
||||||
p.picker = picker.New(common, "Contexts", itemProvider, onSelect)
|
|
||||||
|
|
||||||
// Set active context
|
|
||||||
if selected == "" {
|
|
||||||
selected = "(none)"
|
|
||||||
}
|
|
||||||
p.picker.SelectItemByFilterValue(selected)
|
|
||||||
|
|
||||||
p.SetSize(common.Width(), common.Height())
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ContextPickerPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
|
|
||||||
// Set list size with some padding/limits to look like a picker
|
|
||||||
listWidth := width - 4
|
|
||||||
if listWidth > 40 {
|
|
||||||
listWidth = 40
|
|
||||||
}
|
|
||||||
listHeight := height - 6
|
|
||||||
if listHeight > 20 {
|
|
||||||
listHeight = 20
|
|
||||||
}
|
|
||||||
p.picker.SetSize(listWidth, listHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ContextPickerPage) Init() tea.Cmd {
|
|
||||||
return p.picker.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
type contextSelectedMsg struct {
|
|
||||||
item list.Item
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
p.SetSize(msg.Width, msg.Height)
|
|
||||||
case contextSelectedMsg:
|
|
||||||
name := msg.item.FilterValue() // Use FilterValue (which is the name/text)
|
|
||||||
if name == "(none)" {
|
|
||||||
name = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := p.common.TW.GetContext(name)
|
|
||||||
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, func() tea.Msg { return UpdateContextMsg(ctx) }
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if !p.picker.IsFiltering() {
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, BackCmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, cmd = p.picker.Update(msg)
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ContextPickerPage) View() string {
|
|
||||||
width := p.common.Width() - 4
|
|
||||||
if width > 40 {
|
|
||||||
width = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
content := p.picker.View()
|
|
||||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
|
||||||
|
|
||||||
return lipgloss.Place(
|
|
||||||
p.common.Width(),
|
|
||||||
p.common.Height(),
|
|
||||||
lipgloss.Center,
|
|
||||||
lipgloss.Center,
|
|
||||||
p.common.Styles.Base.Render(styledContent),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateContextMsg *taskwarrior.Context
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
// import (
|
|
||||||
// "tasksquire/common"
|
|
||||||
|
|
||||||
// "github.com/charmbracelet/bubbles/textinput"
|
|
||||||
// tea "github.com/charmbracelet/bubbletea"
|
|
||||||
// "github.com/charmbracelet/lipgloss"
|
|
||||||
// datepicker "github.com/ethanefung/bubble-datepicker"
|
|
||||||
// )
|
|
||||||
|
|
||||||
// type Model struct {
|
|
||||||
// focus focus
|
|
||||||
// input textinput.Model
|
|
||||||
// datepicker datepicker.Model
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var inputStyles = lipgloss.NewStyle().Padding(1, 1, 0)
|
|
||||||
|
|
||||||
// func initializeModel() tea.Model {
|
|
||||||
// dp := datepicker.New(time.Now())
|
|
||||||
|
|
||||||
// input := textinput.New()
|
|
||||||
// input.Placeholder = "YYYY-MM-DD (enter date)"
|
|
||||||
// input.Focus()
|
|
||||||
// input.Width = 20
|
|
||||||
|
|
||||||
// return Model{
|
|
||||||
// focus: FocusInput,
|
|
||||||
// input: input,
|
|
||||||
// datepicker: dp,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m Model) Init() tea.Cmd {
|
|
||||||
// return textinput.Blink
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
|
|
||||||
// switch msg := msg.(type) {
|
|
||||||
// case tea.WindowSizeMsg:
|
|
||||||
// // TODO figure out how we want to size things
|
|
||||||
// // we'll probably want both bubbles to be vertically stacked
|
|
||||||
// // and to take as much room as the can
|
|
||||||
// return m, nil
|
|
||||||
// case tea.KeyMsg:
|
|
||||||
// switch msg.String() {
|
|
||||||
// case "ctrl+c", "q":
|
|
||||||
// return m, tea.Quit
|
|
||||||
// case "tab":
|
|
||||||
// if m.focus == FocusInput {
|
|
||||||
// m.focus = FocusDatePicker
|
|
||||||
// m.input.Blur()
|
|
||||||
// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly))
|
|
||||||
|
|
||||||
// m.datepicker.SelectDate()
|
|
||||||
// m.datepicker.SetFocus(datepicker.FocusHeaderMonth)
|
|
||||||
// m.datepicker = m.datepicker
|
|
||||||
// return m, nil
|
|
||||||
|
|
||||||
// }
|
|
||||||
// case "shift+tab":
|
|
||||||
// if m.focus == FocusDatePicker && m.datepicker.Focused == datepicker.FocusHeaderMonth {
|
|
||||||
// m.focus = FocusInput
|
|
||||||
// m.datepicker.Blur()
|
|
||||||
|
|
||||||
// m.input.Focus()
|
|
||||||
// return m, nil
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// switch m.focus {
|
|
||||||
// case FocusInput:
|
|
||||||
// m.input, cmd = m.UpdateInput(msg)
|
|
||||||
// case FocusDatePicker:
|
|
||||||
// m.datepicker, cmd = m.UpdateDatepicker(msg)
|
|
||||||
// case FocusNone:
|
|
||||||
// // do nothing
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return m, cmd
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m Model) View() string {
|
|
||||||
// return lipgloss.JoinVertical(lipgloss.Left, inputStyles.Render(m.input.View()), m.datepicker.View())
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m *Model) UpdateInput(msg tea.Msg) (textinput.Model, tea.Cmd) {
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
|
|
||||||
// m.input, cmd = m.input.Update(msg)
|
|
||||||
|
|
||||||
// val := m.input.Value()
|
|
||||||
// t, err := time.Parse(time.DateOnly, strings.TrimSpace(val))
|
|
||||||
// if err == nil {
|
|
||||||
// m.datepicker.SetTime(t)
|
|
||||||
// m.datepicker.SelectDate()
|
|
||||||
// m.datepicker.Blur()
|
|
||||||
// }
|
|
||||||
// if err != nil && m.datepicker.Selected {
|
|
||||||
// m.datepicker.UnselectDate()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return m.input, cmd
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m *Model) UpdateDatepicker(msg tea.Msg) (datepicker.Model, tea.Cmd) {
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
|
|
||||||
// prev := m.datepicker.Time
|
|
||||||
|
|
||||||
// m.datepicker, cmd = m.datepicker.Update(msg)
|
|
||||||
|
|
||||||
// if prev != m.datepicker.Time {
|
|
||||||
// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly))
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return m.datepicker, cmd
|
|
||||||
// }
|
|
||||||
108
pages/main.go
108
pages/main.go
@@ -1,108 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"tasksquire/common"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MainPage struct {
|
|
||||||
common *common.Common
|
|
||||||
activePage common.Component
|
|
||||||
|
|
||||||
taskPage common.Component
|
|
||||||
timePage common.Component
|
|
||||||
currentTab int
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMainPage(common *common.Common) *MainPage {
|
|
||||||
m := &MainPage{
|
|
||||||
common: common,
|
|
||||||
}
|
|
||||||
|
|
||||||
m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
|
|
||||||
m.timePage = NewTimePage(common)
|
|
||||||
|
|
||||||
m.activePage = m.taskPage
|
|
||||||
m.currentTab = 0
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MainPage) Init() tea.Cmd {
|
|
||||||
return tea.Batch(m.taskPage.Init(), m.timePage.Init())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width = msg.Width
|
|
||||||
m.height = msg.Height
|
|
||||||
m.common.SetSize(msg.Width, msg.Height)
|
|
||||||
|
|
||||||
tabHeight := lipgloss.Height(m.renderTabBar())
|
|
||||||
contentHeight := msg.Height - tabHeight
|
|
||||||
if contentHeight < 0 {
|
|
||||||
contentHeight = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
|
|
||||||
activePage, cmd := m.activePage.Update(newMsg)
|
|
||||||
m.activePage = activePage.(common.Component)
|
|
||||||
return m, cmd
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
// Only handle tab key for page switching when at the top level (no subpages active)
|
|
||||||
if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
|
|
||||||
if m.activePage == m.taskPage {
|
|
||||||
m.activePage = m.timePage
|
|
||||||
m.currentTab = 1
|
|
||||||
} else {
|
|
||||||
m.activePage = m.taskPage
|
|
||||||
m.currentTab = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
tabHeight := lipgloss.Height(m.renderTabBar())
|
|
||||||
contentHeight := m.height - tabHeight
|
|
||||||
if contentHeight < 0 {
|
|
||||||
contentHeight = 0
|
|
||||||
}
|
|
||||||
m.activePage.SetSize(m.width, contentHeight)
|
|
||||||
|
|
||||||
// Trigger a refresh/init on switch? Maybe not needed if we keep state.
|
|
||||||
// But we might want to refresh data.
|
|
||||||
return m, m.activePage.Init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
activePage, cmd := m.activePage.Update(msg)
|
|
||||||
m.activePage = activePage.(common.Component)
|
|
||||||
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MainPage) renderTabBar() string {
|
|
||||||
var tabs []string
|
|
||||||
headers := []string{"Tasks", "Time"}
|
|
||||||
|
|
||||||
for i, header := range headers {
|
|
||||||
style := m.common.Styles.Tab
|
|
||||||
if m.currentTab == i {
|
|
||||||
style = m.common.Styles.ActiveTab
|
|
||||||
}
|
|
||||||
tabs = append(tabs, style.Render(header))
|
|
||||||
}
|
|
||||||
|
|
||||||
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
|
||||||
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MainPage) View() string {
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UpdatedTasksMsg struct{}
|
|
||||||
|
|
||||||
type nextColumnMsg struct{}
|
|
||||||
|
|
||||||
func nextColumn() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return nextColumnMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type prevColumnMsg struct{}
|
|
||||||
|
|
||||||
func prevColumn() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return prevColumnMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type nextFieldMsg struct{}
|
|
||||||
|
|
||||||
func nextField() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return nextFieldMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type prevFieldMsg struct{}
|
|
||||||
|
|
||||||
func prevField() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return prevFieldMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type nextAreaMsg struct{}
|
|
||||||
|
|
||||||
func nextArea() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return nextAreaMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type prevAreaMsg struct{}
|
|
||||||
|
|
||||||
func prevArea() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return prevAreaMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type changeAreaMsg int
|
|
||||||
|
|
||||||
func changeArea(a int) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return changeAreaMsg(a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func changeMode(mode mode) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return changeModeMsg(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type changeModeMsg mode
|
|
||||||
|
|
||||||
type taskMsg taskwarrior.Tasks
|
|
||||||
|
|
||||||
type tickMsg time.Time
|
|
||||||
|
|
||||||
func doTick() tea.Cmd {
|
|
||||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
|
||||||
return tickMsg(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type TaskPickedMsg struct {
|
|
||||||
Task *taskwarrior.Task
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func BackCmd() tea.Msg {
|
|
||||||
return BackMsg{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackMsg struct{}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/components/picker"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProjectPickerPage struct {
|
|
||||||
common *common.Common
|
|
||||||
picker *picker.Picker
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage {
|
|
||||||
p := &ProjectPickerPage{
|
|
||||||
common: common,
|
|
||||||
}
|
|
||||||
|
|
||||||
itemProvider := func() []list.Item {
|
|
||||||
projects := common.TW.GetProjects()
|
|
||||||
items := []list.Item{picker.NewItem("(none)")}
|
|
||||||
for _, proj := range projects {
|
|
||||||
items = append(items, picker.NewItem(proj))
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect := func(item list.Item) tea.Cmd {
|
|
||||||
return func() tea.Msg { return projectSelectedMsg{item: item} }
|
|
||||||
}
|
|
||||||
|
|
||||||
// onCreate := func(name string) tea.Cmd {
|
|
||||||
// return func() tea.Msg { return projectSelectedMsg{item: picker.NewItem(name)} }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// p.picker = picker.New(common, "Projects", itemProvider, onSelect, picker.WithOnCreate(onCreate))
|
|
||||||
p.picker = picker.New(common, "Projects", itemProvider, onSelect)
|
|
||||||
|
|
||||||
// Set active project
|
|
||||||
if activeProject == "" {
|
|
||||||
activeProject = "(none)"
|
|
||||||
}
|
|
||||||
p.picker.SelectItemByFilterValue(activeProject)
|
|
||||||
|
|
||||||
p.SetSize(common.Width(), common.Height())
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectPickerPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
|
|
||||||
// Set list size with some padding/limits to look like a picker
|
|
||||||
listWidth := width - 4
|
|
||||||
if listWidth > 40 {
|
|
||||||
listWidth = 40
|
|
||||||
}
|
|
||||||
listHeight := height - 6
|
|
||||||
if listHeight > 20 {
|
|
||||||
listHeight = 20
|
|
||||||
}
|
|
||||||
p.picker.SetSize(listWidth, listHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectPickerPage) Init() tea.Cmd {
|
|
||||||
return p.picker.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
type projectSelectedMsg struct {
|
|
||||||
item list.Item
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
p.SetSize(msg.Width, msg.Height)
|
|
||||||
case projectSelectedMsg:
|
|
||||||
proj := msg.item.FilterValue() // Use FilterValue (text)
|
|
||||||
if proj == "(none)" {
|
|
||||||
proj = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, func() tea.Msg { return UpdateProjectMsg(proj) }
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if !p.picker.IsFiltering() {
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, BackCmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, cmd = p.picker.Update(msg)
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectPickerPage) View() string {
|
|
||||||
width := p.common.Width() - 4
|
|
||||||
if width > 40 {
|
|
||||||
width = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
content := p.picker.View()
|
|
||||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
|
||||||
|
|
||||||
return lipgloss.Place(
|
|
||||||
p.common.Width(),
|
|
||||||
p.common.Height(),
|
|
||||||
lipgloss.Center,
|
|
||||||
lipgloss.Center,
|
|
||||||
p.common.Styles.Base.Render(styledContent),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateProjectMsg string
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/components/picker"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
"tasksquire/timewarrior"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProjectTaskPickerPage struct {
|
|
||||||
common *common.Common
|
|
||||||
|
|
||||||
// Both pickers visible simultaneously
|
|
||||||
projectPicker *picker.Picker
|
|
||||||
taskPicker *picker.Picker
|
|
||||||
selectedProject string
|
|
||||||
selectedTask *taskwarrior.Task
|
|
||||||
|
|
||||||
// Focus tracking: 0 = project picker, 1 = task picker
|
|
||||||
focusedPicker int
|
|
||||||
}
|
|
||||||
|
|
||||||
type projectTaskPickerProjectSelectedMsg struct {
|
|
||||||
project string
|
|
||||||
}
|
|
||||||
|
|
||||||
type projectTaskPickerTaskSelectedMsg struct {
|
|
||||||
task *taskwarrior.Task
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewProjectTaskPickerPage(com *common.Common) *ProjectTaskPickerPage {
|
|
||||||
p := &ProjectTaskPickerPage{
|
|
||||||
common: com,
|
|
||||||
focusedPicker: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create project picker
|
|
||||||
projectItemProvider := func() []list.Item {
|
|
||||||
projects := com.TW.GetProjects()
|
|
||||||
items := make([]list.Item, 0, len(projects))
|
|
||||||
|
|
||||||
for _, proj := range projects {
|
|
||||||
items = append(items, picker.NewItem(proj))
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
projectOnSelect := func(item list.Item) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return projectTaskPickerProjectSelectedMsg{project: item.FilterValue()}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.projectPicker = picker.New(
|
|
||||||
com,
|
|
||||||
"Projects",
|
|
||||||
projectItemProvider,
|
|
||||||
projectOnSelect,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize with the first project's tasks
|
|
||||||
projects := com.TW.GetProjects()
|
|
||||||
if len(projects) > 0 {
|
|
||||||
p.selectedProject = projects[0]
|
|
||||||
p.createTaskPicker(projects[0])
|
|
||||||
} else {
|
|
||||||
// No projects - create empty task picker
|
|
||||||
p.createTaskPicker("")
|
|
||||||
}
|
|
||||||
|
|
||||||
p.SetSize(com.Width(), com.Height())
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectTaskPickerPage) createTaskPicker(project string) {
|
|
||||||
// Build filters for tasks
|
|
||||||
filters := []string{"+track", "status:pending"}
|
|
||||||
|
|
||||||
if project != "" {
|
|
||||||
// Tasks in the selected project
|
|
||||||
filters = append(filters, "project:"+project)
|
|
||||||
}
|
|
||||||
|
|
||||||
taskItemProvider := func() []list.Item {
|
|
||||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
|
||||||
items := make([]list.Item, 0, len(tasks))
|
|
||||||
|
|
||||||
for i := range tasks {
|
|
||||||
// Just use the description as the item text
|
|
||||||
// picker.NewItem creates a simple item with title and filter value
|
|
||||||
items = append(items, picker.NewItem(tasks[i].Description))
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
taskOnSelect := func(item list.Item) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
// Find the task by description
|
|
||||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
|
||||||
for _, task := range tasks {
|
|
||||||
if task.Description == item.FilterValue() {
|
|
||||||
// tasks is already []*Task, so task is already *Task
|
|
||||||
return projectTaskPickerTaskSelectedMsg{task: task}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
title := "Tasks with +track"
|
|
||||||
if project != "" {
|
|
||||||
title = fmt.Sprintf("Tasks: %s", project)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.taskPicker = picker.New(
|
|
||||||
p.common,
|
|
||||||
title,
|
|
||||||
taskItemProvider,
|
|
||||||
taskOnSelect,
|
|
||||||
picker.WithFilterByDefault(false), // Start in list mode, not filter mode
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectTaskPickerPage) Init() tea.Cmd {
|
|
||||||
// Focus the project picker initially
|
|
||||||
p.projectPicker.Focus()
|
|
||||||
return tea.Batch(p.projectPicker.Init(), p.taskPicker.Init())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectTaskPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
p.SetSize(msg.Width, msg.Height)
|
|
||||||
|
|
||||||
case projectTaskPickerProjectSelectedMsg:
|
|
||||||
// Project selected - update task picker
|
|
||||||
p.selectedProject = msg.project
|
|
||||||
p.createTaskPicker(msg.project)
|
|
||||||
// Move focus to task picker
|
|
||||||
p.projectPicker.Blur()
|
|
||||||
p.taskPicker.Focus()
|
|
||||||
p.focusedPicker = 1
|
|
||||||
p.SetSize(p.common.Width(), p.common.Height())
|
|
||||||
return p, p.taskPicker.Init()
|
|
||||||
|
|
||||||
case projectTaskPickerTaskSelectedMsg:
|
|
||||||
// Task selected - emit TaskPickedMsg and return to parent
|
|
||||||
p.selectedTask = msg.task
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
return p, tea.Quit
|
|
||||||
}
|
|
||||||
return model, func() tea.Msg {
|
|
||||||
return TaskPickedMsg{Task: p.selectedTask}
|
|
||||||
}
|
|
||||||
|
|
||||||
case UpdatedTasksMsg:
|
|
||||||
// Task was edited - refresh the task list and recreate the task picker
|
|
||||||
if p.selectedProject != "" {
|
|
||||||
p.createTaskPicker(p.selectedProject)
|
|
||||||
p.SetSize(p.common.Width(), p.common.Height())
|
|
||||||
// Keep the task picker focused
|
|
||||||
p.taskPicker.Focus()
|
|
||||||
p.focusedPicker = 1
|
|
||||||
return p, p.taskPicker.Init()
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
// Check if the focused picker is in filtering mode BEFORE handling any keys
|
|
||||||
var focusedPickerFiltering bool
|
|
||||||
if p.focusedPicker == 0 {
|
|
||||||
focusedPickerFiltering = p.projectPicker.IsFiltering()
|
|
||||||
} else {
|
|
||||||
focusedPickerFiltering = p.taskPicker.IsFiltering()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
|
||||||
// If the focused picker is filtering, let it handle the escape key to dismiss the filter
|
|
||||||
// and don't exit the page
|
|
||||||
if focusedPickerFiltering {
|
|
||||||
// Don't handle the Back key - let it fall through to the picker
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// Exit picker completely
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
return p, tea.Quit
|
|
||||||
}
|
|
||||||
return model, BackCmd
|
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Add):
|
|
||||||
// Don't handle 'a' if focused picker is filtering - let the picker handle it for typing
|
|
||||||
if focusedPickerFiltering {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// Create new task with selected project and track tag pre-filled
|
|
||||||
newTask := taskwarrior.NewTask()
|
|
||||||
newTask.Project = p.selectedProject
|
|
||||||
newTask.Tags = []string{"track"}
|
|
||||||
|
|
||||||
// Open task editor with pre-populated task
|
|
||||||
taskEditor := NewTaskEditorPage(p.common, newTask)
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return taskEditor, taskEditor.Init()
|
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Edit):
|
|
||||||
// Don't handle 'e' if focused picker is filtering - let the picker handle it for typing
|
|
||||||
if focusedPickerFiltering {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// Edit task when task picker is focused and a task is selected
|
|
||||||
if p.focusedPicker == 1 && p.selectedProject != "" {
|
|
||||||
// Get the currently highlighted task
|
|
||||||
selectedItemText := p.taskPicker.GetValue()
|
|
||||||
if selectedItemText != "" {
|
|
||||||
// Find the task by description
|
|
||||||
filters := []string{"+track", "status:pending"}
|
|
||||||
filters = append(filters, "project:"+p.selectedProject)
|
|
||||||
|
|
||||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
|
||||||
for _, task := range tasks {
|
|
||||||
if task.Description == selectedItemText {
|
|
||||||
// Found the task - open editor
|
|
||||||
p.selectedTask = task
|
|
||||||
taskEditor := NewTaskEditorPage(p.common, *task)
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return taskEditor, taskEditor.Init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Tag):
|
|
||||||
// Don't handle 't' if focused picker is filtering - let the picker handle it for typing
|
|
||||||
if focusedPickerFiltering {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// Open time editor with task pre-filled when task picker is focused
|
|
||||||
if p.focusedPicker == 1 && p.selectedProject != "" {
|
|
||||||
// Get the currently highlighted task
|
|
||||||
selectedItemText := p.taskPicker.GetValue()
|
|
||||||
if selectedItemText != "" {
|
|
||||||
// Find the task by description
|
|
||||||
filters := []string{"+track", "status:pending"}
|
|
||||||
filters = append(filters, "project:"+p.selectedProject)
|
|
||||||
|
|
||||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
|
||||||
for _, task := range tasks {
|
|
||||||
if task.Description == selectedItemText {
|
|
||||||
// Found the task - create new interval with task pre-filled
|
|
||||||
interval := createIntervalFromTask(task)
|
|
||||||
|
|
||||||
// Open time editor with pre-populated interval
|
|
||||||
timeEditor := NewTimeEditorPage(p.common, interval)
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return timeEditor, timeEditor.Init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Next):
|
|
||||||
// Tab: switch focus between pickers
|
|
||||||
if p.focusedPicker == 0 {
|
|
||||||
p.projectPicker.Blur()
|
|
||||||
p.taskPicker.Focus()
|
|
||||||
p.focusedPicker = 1
|
|
||||||
} else {
|
|
||||||
p.taskPicker.Blur()
|
|
||||||
p.projectPicker.Focus()
|
|
||||||
p.focusedPicker = 0
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Prev):
|
|
||||||
// Shift+Tab: switch focus between pickers (reverse)
|
|
||||||
if p.focusedPicker == 1 {
|
|
||||||
p.taskPicker.Blur()
|
|
||||||
p.projectPicker.Focus()
|
|
||||||
p.focusedPicker = 0
|
|
||||||
} else {
|
|
||||||
p.projectPicker.Blur()
|
|
||||||
p.taskPicker.Focus()
|
|
||||||
p.focusedPicker = 1
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the focused picker
|
|
||||||
var cmd tea.Cmd
|
|
||||||
if p.focusedPicker == 0 {
|
|
||||||
// Track the previous project selection
|
|
||||||
previousProject := p.selectedProject
|
|
||||||
|
|
||||||
_, cmd = p.projectPicker.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
// Check if the highlighted project changed
|
|
||||||
currentProject := p.projectPicker.GetValue()
|
|
||||||
if currentProject != previousProject && currentProject != "" {
|
|
||||||
// Update the selected project and refresh task picker
|
|
||||||
p.selectedProject = currentProject
|
|
||||||
p.createTaskPicker(currentProject)
|
|
||||||
p.SetSize(p.common.Width(), p.common.Height())
|
|
||||||
cmds = append(cmds, p.taskPicker.Init())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_, cmd = p.taskPicker.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectTaskPickerPage) View() string {
|
|
||||||
// Render both pickers (they handle their own focused/blurred styling)
|
|
||||||
projectView := p.projectPicker.View()
|
|
||||||
taskView := p.taskPicker.View()
|
|
||||||
|
|
||||||
// Create distinct styling for focused vs blurred pickers
|
|
||||||
var projectStyled, taskStyled string
|
|
||||||
|
|
||||||
if p.focusedPicker == 0 {
|
|
||||||
// Project picker is focused
|
|
||||||
projectStyled = lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.ThickBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("6")). // Cyan for focused
|
|
||||||
Padding(0, 1).
|
|
||||||
Render(projectView)
|
|
||||||
|
|
||||||
taskStyled = lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.NormalBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("240")). // Gray for blurred
|
|
||||||
Padding(0, 1).
|
|
||||||
Render(taskView)
|
|
||||||
} else {
|
|
||||||
// Task picker is focused
|
|
||||||
projectStyled = lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.NormalBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("240")). // Gray for blurred
|
|
||||||
Padding(0, 1).
|
|
||||||
Render(projectView)
|
|
||||||
|
|
||||||
taskStyled = lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.ThickBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("6")). // Cyan for focused
|
|
||||||
Padding(0, 1).
|
|
||||||
Render(taskView)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout side by side if width permits, otherwise stack vertically
|
|
||||||
var content string
|
|
||||||
if p.common.Width() >= 100 {
|
|
||||||
// Side by side layout
|
|
||||||
content = lipgloss.JoinHorizontal(lipgloss.Top, projectStyled, " ", taskStyled)
|
|
||||||
} else {
|
|
||||||
// Vertical stack layout
|
|
||||||
content = lipgloss.JoinVertical(lipgloss.Left, projectStyled, "", taskStyled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add help text
|
|
||||||
helpText := "Tab/Shift+Tab: switch focus • Enter: select • a: add task • e: edit • t: track time • Esc: cancel"
|
|
||||||
helpStyled := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("241")).
|
|
||||||
Italic(true).
|
|
||||||
Render(helpText)
|
|
||||||
|
|
||||||
fullContent := lipgloss.JoinVertical(lipgloss.Left, content, "", helpStyled)
|
|
||||||
|
|
||||||
return lipgloss.Place(
|
|
||||||
p.common.Width(),
|
|
||||||
p.common.Height(),
|
|
||||||
lipgloss.Center,
|
|
||||||
lipgloss.Center,
|
|
||||||
fullContent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectTaskPickerPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
|
|
||||||
// Calculate sizes based on layout
|
|
||||||
var projectWidth, taskWidth, listHeight int
|
|
||||||
|
|
||||||
if width >= 100 {
|
|
||||||
// Side by side layout
|
|
||||||
projectWidth = 30
|
|
||||||
taskWidth = width - projectWidth - 10 // Account for margins and padding
|
|
||||||
if taskWidth > 60 {
|
|
||||||
taskWidth = 60
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Vertical stack layout
|
|
||||||
projectWidth = width - 8
|
|
||||||
taskWidth = width - 8
|
|
||||||
if projectWidth > 60 {
|
|
||||||
projectWidth = 60
|
|
||||||
}
|
|
||||||
if taskWidth > 60 {
|
|
||||||
taskWidth = 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Height for each picker
|
|
||||||
listHeight = height - 10 // Account for help text and padding
|
|
||||||
if listHeight > 25 {
|
|
||||||
listHeight = 25
|
|
||||||
}
|
|
||||||
if listHeight < 10 {
|
|
||||||
listHeight = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.projectPicker != nil {
|
|
||||||
p.projectPicker.SetSize(projectWidth, listHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.taskPicker != nil {
|
|
||||||
p.taskPicker.SetSize(taskWidth, listHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createIntervalFromTask creates a new time interval pre-filled with task metadata
|
|
||||||
func createIntervalFromTask(task *taskwarrior.Task) *timewarrior.Interval {
|
|
||||||
interval := timewarrior.NewInterval()
|
|
||||||
|
|
||||||
// Set start time to now (UTC format)
|
|
||||||
interval.Start = time.Now().UTC().Format("20060102T150405Z")
|
|
||||||
// Leave End empty for active tracking
|
|
||||||
interval.End = ""
|
|
||||||
|
|
||||||
// Build tags from task metadata
|
|
||||||
tags := []string{}
|
|
||||||
|
|
||||||
// Add UUID tag for task linking
|
|
||||||
if task.Uuid != "" {
|
|
||||||
tags = append(tags, "uuid:"+task.Uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add project tag
|
|
||||||
if task.Project != "" {
|
|
||||||
tags = append(tags, "project:"+task.Project)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add existing task tags (excluding virtual tags)
|
|
||||||
tags = append(tags, task.Tags...)
|
|
||||||
|
|
||||||
interval.Tags = tags
|
|
||||||
|
|
||||||
return interval
|
|
||||||
}
|
|
||||||
374
pages/report.go
374
pages/report.go
@@ -1,374 +0,0 @@
|
|||||||
// TODO: update table every second (to show correct relative time)
|
|
||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/components/detailsviewer"
|
|
||||||
"tasksquire/components/table"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReportPage struct {
|
|
||||||
common *common.Common
|
|
||||||
|
|
||||||
activeReport *taskwarrior.Report
|
|
||||||
activeContext *taskwarrior.Context
|
|
||||||
activeProject string
|
|
||||||
selectedTask *taskwarrior.Task
|
|
||||||
taskCursor int
|
|
||||||
|
|
||||||
tasks taskwarrior.Tasks
|
|
||||||
|
|
||||||
taskTable table.Model
|
|
||||||
|
|
||||||
// Details panel state
|
|
||||||
detailsPanelActive bool
|
|
||||||
detailsViewer *detailsviewer.DetailsViewer
|
|
||||||
|
|
||||||
subpage common.Component
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
|
||||||
// return &ReportPage{
|
|
||||||
// common: com,
|
|
||||||
// activeReport: report,
|
|
||||||
// activeContext: com.TW.GetActiveContext(),
|
|
||||||
// activeProject: "",
|
|
||||||
// taskTable: table.New(com),
|
|
||||||
// }
|
|
||||||
|
|
||||||
p := &ReportPage{
|
|
||||||
common: com,
|
|
||||||
activeReport: report,
|
|
||||||
activeContext: com.TW.GetActiveContext(),
|
|
||||||
activeProject: "",
|
|
||||||
taskTable: table.New(com),
|
|
||||||
detailsPanelActive: false,
|
|
||||||
detailsViewer: detailsviewer.New(com),
|
|
||||||
}
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) SetSize(width int, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
|
|
||||||
baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize()
|
|
||||||
baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize()
|
|
||||||
|
|
||||||
var tableHeight int
|
|
||||||
if p.detailsPanelActive {
|
|
||||||
// Allocate 60% for table, 40% for details panel
|
|
||||||
// Minimum 5 lines for details, minimum 10 lines for table
|
|
||||||
detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
|
||||||
tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
|
||||||
|
|
||||||
// Set component size (component handles its own border/padding)
|
|
||||||
p.detailsViewer.SetSize(baseWidth, detailsHeight)
|
|
||||||
} else {
|
|
||||||
tableHeight = baseHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
p.taskTable.SetWidth(baseWidth)
|
|
||||||
p.taskTable.SetHeight(tableHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
func max(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) Init() tea.Cmd {
|
|
||||||
return tea.Batch(p.getTasks(), doTick())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
p.SetSize(msg.Width, msg.Height)
|
|
||||||
// case BackMsg:
|
|
||||||
case tickMsg:
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
cmds = append(cmds, doTick())
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
case taskMsg:
|
|
||||||
p.tasks = taskwarrior.Tasks(msg)
|
|
||||||
p.populateTaskTable(p.tasks)
|
|
||||||
case UpdateReportMsg:
|
|
||||||
p.activeReport = msg
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
case UpdateContextMsg:
|
|
||||||
p.activeContext = msg
|
|
||||||
p.common.TW.SetContext(msg)
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
case UpdateProjectMsg:
|
|
||||||
p.activeProject = string(msg)
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
case TaskPickedMsg:
|
|
||||||
if msg.Task != nil && msg.Task.Status == "pending" {
|
|
||||||
p.common.TW.StopActiveTasks()
|
|
||||||
p.common.TW.StartTask(msg.Task)
|
|
||||||
}
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
case UpdatedTasksMsg:
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
case tea.KeyMsg:
|
|
||||||
// Handle ESC when details panel is active
|
|
||||||
if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) {
|
|
||||||
p.detailsPanelActive = false
|
|
||||||
p.detailsViewer.Blur()
|
|
||||||
p.SetSize(p.common.Width(), p.common.Height())
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Quit):
|
|
||||||
return p, tea.Quit
|
|
||||||
case key.Matches(msg, p.common.Keymap.SetReport):
|
|
||||||
p.subpage = NewReportPickerPage(p.common, p.activeReport)
|
|
||||||
cmd := p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, cmd
|
|
||||||
case key.Matches(msg, p.common.Keymap.SetContext):
|
|
||||||
p.subpage = NewContextPickerPage(p.common)
|
|
||||||
cmd := p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, cmd
|
|
||||||
case key.Matches(msg, p.common.Keymap.Add):
|
|
||||||
p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
|
|
||||||
cmd := p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, cmd
|
|
||||||
case key.Matches(msg, p.common.Keymap.Edit):
|
|
||||||
p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
|
|
||||||
cmd := p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, cmd
|
|
||||||
case key.Matches(msg, p.common.Keymap.Subtask):
|
|
||||||
if p.selectedTask != nil {
|
|
||||||
// Create new task inheriting parent's attributes
|
|
||||||
newTask := taskwarrior.NewTask()
|
|
||||||
|
|
||||||
// Set parent relationship
|
|
||||||
newTask.Parent = p.selectedTask.Uuid
|
|
||||||
|
|
||||||
// Copy parent's attributes
|
|
||||||
newTask.Project = p.selectedTask.Project
|
|
||||||
newTask.Priority = p.selectedTask.Priority
|
|
||||||
newTask.Tags = make([]string, len(p.selectedTask.Tags))
|
|
||||||
copy(newTask.Tags, p.selectedTask.Tags)
|
|
||||||
|
|
||||||
// Copy UDAs (except "details" which is task-specific)
|
|
||||||
if p.selectedTask.Udas != nil {
|
|
||||||
newTask.Udas = make(map[string]any)
|
|
||||||
for k, v := range p.selectedTask.Udas {
|
|
||||||
// Skip "details" UDA - it's specific to parent task
|
|
||||||
if k == "details" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Deep copy other UDA values
|
|
||||||
newTask.Udas[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open task editor with pre-populated task
|
|
||||||
p.subpage = NewTaskEditorPage(p.common, newTask)
|
|
||||||
cmd := p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, cmd
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
|
||||||
p.common.TW.SetTaskDone(p.selectedTask)
|
|
||||||
return p, p.getTasks()
|
|
||||||
case key.Matches(msg, p.common.Keymap.Delete):
|
|
||||||
p.common.TW.DeleteTask(p.selectedTask)
|
|
||||||
return p, p.getTasks()
|
|
||||||
case key.Matches(msg, p.common.Keymap.SetProject):
|
|
||||||
p.subpage = NewProjectPickerPage(p.common, p.activeProject)
|
|
||||||
cmd := p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, cmd
|
|
||||||
case key.Matches(msg, p.common.Keymap.PickProjectTask):
|
|
||||||
p.subpage = NewProjectTaskPickerPage(p.common)
|
|
||||||
cmd := p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, cmd
|
|
||||||
case key.Matches(msg, p.common.Keymap.Tag):
|
|
||||||
if p.selectedTask != nil {
|
|
||||||
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
|
||||||
if p.selectedTask.HasTag(tag) {
|
|
||||||
p.selectedTask.RemoveTag(tag)
|
|
||||||
} else {
|
|
||||||
p.selectedTask.AddTag(tag)
|
|
||||||
}
|
|
||||||
p.common.TW.ImportTask(p.selectedTask)
|
|
||||||
return p, p.getTasks()
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
case key.Matches(msg, p.common.Keymap.Undo):
|
|
||||||
p.common.TW.Undo()
|
|
||||||
return p, p.getTasks()
|
|
||||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
|
||||||
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
|
|
||||||
if p.selectedTask.Start == "" {
|
|
||||||
p.common.TW.StopActiveTasks()
|
|
||||||
p.common.TW.StartTask(p.selectedTask)
|
|
||||||
} else {
|
|
||||||
p.common.TW.StopTask(p.selectedTask)
|
|
||||||
}
|
|
||||||
return p, p.getTasks()
|
|
||||||
}
|
|
||||||
case key.Matches(msg, p.common.Keymap.ViewDetails):
|
|
||||||
if p.selectedTask != nil {
|
|
||||||
// Toggle details panel
|
|
||||||
p.detailsPanelActive = !p.detailsPanelActive
|
|
||||||
if p.detailsPanelActive {
|
|
||||||
p.detailsViewer.SetTask(p.selectedTask)
|
|
||||||
p.detailsViewer.Focus()
|
|
||||||
} else {
|
|
||||||
p.detailsViewer.Blur()
|
|
||||||
}
|
|
||||||
p.SetSize(p.common.Width(), p.common.Height())
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
// Route keyboard messages to details viewer when panel is active
|
|
||||||
if p.detailsPanelActive {
|
|
||||||
var viewerCmd tea.Cmd
|
|
||||||
var viewerModel tea.Model
|
|
||||||
viewerModel, viewerCmd = p.detailsViewer.Update(msg)
|
|
||||||
p.detailsViewer = viewerModel.(*detailsviewer.DetailsViewer)
|
|
||||||
cmds = append(cmds, viewerCmd)
|
|
||||||
} else {
|
|
||||||
// Route to table when details panel not active
|
|
||||||
p.taskTable, cmd = p.taskTable.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
if p.tasks != nil && len(p.tasks) > 0 {
|
|
||||||
p.selectedTask = p.tasks[p.taskTable.Cursor()]
|
|
||||||
} else {
|
|
||||||
p.selectedTask = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) View() string {
|
|
||||||
if p.tasks == nil || len(p.tasks) == 0 {
|
|
||||||
return p.common.Styles.Base.Render("No tasks found")
|
|
||||||
}
|
|
||||||
|
|
||||||
tableView := p.taskTable.View()
|
|
||||||
|
|
||||||
if !p.detailsPanelActive {
|
|
||||||
return tableView
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine table and details panel vertically
|
|
||||||
return lipgloss.JoinVertical(
|
|
||||||
lipgloss.Left,
|
|
||||||
tableView,
|
|
||||||
p.detailsViewer.View(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
|
||||||
if len(tasks) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build task tree for hierarchical display
|
|
||||||
taskTree := taskwarrior.BuildTaskTree(tasks)
|
|
||||||
|
|
||||||
// Use flattened tree list for display order
|
|
||||||
orderedTasks := make(taskwarrior.Tasks, len(taskTree.FlatList))
|
|
||||||
for i, node := range taskTree.FlatList {
|
|
||||||
orderedTasks[i] = node.Task
|
|
||||||
}
|
|
||||||
|
|
||||||
selected := p.taskTable.Cursor()
|
|
||||||
|
|
||||||
// Adjust cursor for tree ordering
|
|
||||||
if p.selectedTask != nil {
|
|
||||||
for i, task := range orderedTasks {
|
|
||||||
if task.Uuid == p.selectedTask.Uuid {
|
|
||||||
selected = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if selected > len(orderedTasks)-1 {
|
|
||||||
selected = len(orderedTasks) - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate proper dimensions based on whether details panel is active
|
|
||||||
baseHeight := p.common.Height() - p.common.Styles.Base.GetVerticalFrameSize()
|
|
||||||
baseWidth := p.common.Width() - p.common.Styles.Base.GetHorizontalFrameSize()
|
|
||||||
|
|
||||||
var tableHeight int
|
|
||||||
if p.detailsPanelActive {
|
|
||||||
// Allocate 60% for table, 40% for details panel
|
|
||||||
// Minimum 5 lines for details, minimum 10 lines for table
|
|
||||||
detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
|
||||||
tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
|
||||||
} else {
|
|
||||||
tableHeight = baseHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
p.taskTable = table.New(
|
|
||||||
p.common,
|
|
||||||
table.WithReport(p.activeReport),
|
|
||||||
table.WithTasks(orderedTasks),
|
|
||||||
table.WithTaskTree(taskTree),
|
|
||||||
table.WithFocused(true),
|
|
||||||
table.WithWidth(baseWidth),
|
|
||||||
table.WithHeight(tableHeight),
|
|
||||||
table.WithStyles(p.common.Styles.TableStyle),
|
|
||||||
)
|
|
||||||
|
|
||||||
if selected == 0 {
|
|
||||||
selected = p.taskTable.Cursor()
|
|
||||||
}
|
|
||||||
if selected < len(orderedTasks) {
|
|
||||||
p.taskTable.SetCursor(selected)
|
|
||||||
} else {
|
|
||||||
p.taskTable.SetCursor(len(p.tasks) - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh details content if panel is active
|
|
||||||
if p.detailsPanelActive && p.selectedTask != nil {
|
|
||||||
p.detailsViewer.SetTask(p.selectedTask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) getTasks() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
filters := []string{}
|
|
||||||
if p.activeProject != "" {
|
|
||||||
filters = append(filters, "project:"+p.activeProject)
|
|
||||||
}
|
|
||||||
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
|
|
||||||
return taskMsg(tasks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/components/picker"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReportPickerPage struct {
|
|
||||||
common *common.Common
|
|
||||||
reports taskwarrior.Reports
|
|
||||||
picker *picker.Picker
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage {
|
|
||||||
p := &ReportPickerPage{
|
|
||||||
common: common,
|
|
||||||
reports: common.TW.GetReports(),
|
|
||||||
}
|
|
||||||
|
|
||||||
itemProvider := func() []list.Item {
|
|
||||||
options := make([]string, 0)
|
|
||||||
for _, r := range p.reports {
|
|
||||||
options = append(options, r.Name)
|
|
||||||
}
|
|
||||||
slices.Sort(options)
|
|
||||||
|
|
||||||
items := []list.Item{}
|
|
||||||
for _, opt := range options {
|
|
||||||
items = append(items, picker.NewItem(opt))
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect := func(item list.Item) tea.Cmd {
|
|
||||||
return func() tea.Msg { return reportSelectedMsg{item: item} }
|
|
||||||
}
|
|
||||||
|
|
||||||
p.picker = picker.New(common, "Reports", itemProvider, onSelect)
|
|
||||||
|
|
||||||
if activeReport != nil {
|
|
||||||
p.picker.SelectItemByFilterValue(activeReport.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.SetSize(common.Width(), common.Height())
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPickerPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
|
|
||||||
// Set list size with some padding/limits to look like a picker
|
|
||||||
listWidth := width - 4
|
|
||||||
if listWidth > 40 {
|
|
||||||
listWidth = 40
|
|
||||||
}
|
|
||||||
listHeight := height - 6
|
|
||||||
if listHeight > 20 {
|
|
||||||
listHeight = 20
|
|
||||||
}
|
|
||||||
p.picker.SetSize(listWidth, listHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPickerPage) Init() tea.Cmd {
|
|
||||||
return p.picker.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
type reportSelectedMsg struct {
|
|
||||||
item list.Item
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
p.SetSize(msg.Width, msg.Height)
|
|
||||||
case reportSelectedMsg:
|
|
||||||
reportName := msg.item.FilterValue()
|
|
||||||
report := p.common.TW.GetReport(reportName)
|
|
||||||
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, func() tea.Msg { return UpdateReportMsg(report) }
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if !p.picker.IsFiltering() {
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, BackCmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, cmd = p.picker.Update(msg)
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPickerPage) View() string {
|
|
||||||
width := p.common.Width() - 4
|
|
||||||
if width > 40 {
|
|
||||||
width = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
content := p.picker.View()
|
|
||||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
|
||||||
|
|
||||||
return lipgloss.Place(
|
|
||||||
p.common.Width(),
|
|
||||||
p.common.Height(),
|
|
||||||
lipgloss.Center,
|
|
||||||
lipgloss.Center,
|
|
||||||
p.common.Styles.Base.Render(styledContent),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateReportMsg *taskwarrior.Report
|
|
||||||
1299
pages/taskEditor.go
1299
pages/taskEditor.go
File diff suppressed because it is too large
Load Diff
@@ -1,746 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/components/autocomplete"
|
|
||||||
"tasksquire/components/picker"
|
|
||||||
"tasksquire/components/timestampeditor"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
"tasksquire/timewarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TimeEditorPage struct {
|
|
||||||
common *common.Common
|
|
||||||
interval *timewarrior.Interval
|
|
||||||
|
|
||||||
// Fields
|
|
||||||
projectPicker *picker.Picker
|
|
||||||
taskPicker *picker.Picker
|
|
||||||
startEditor *timestampeditor.TimestampEditor
|
|
||||||
endEditor *timestampeditor.TimestampEditor
|
|
||||||
tagsInput *autocomplete.Autocomplete
|
|
||||||
adjust bool
|
|
||||||
|
|
||||||
// State
|
|
||||||
selectedProject string
|
|
||||||
selectedTask *taskwarrior.Task
|
|
||||||
currentField int
|
|
||||||
totalFields int
|
|
||||||
uuid string // Preserved UUID tag
|
|
||||||
track string // Preserved track tag (if present)
|
|
||||||
}
|
|
||||||
|
|
||||||
type timeEditorProjectSelectedMsg struct {
|
|
||||||
project string
|
|
||||||
}
|
|
||||||
|
|
||||||
type timeEditorTaskSelectedMsg struct {
|
|
||||||
task *taskwarrior.Task
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTaskPickerForProject creates a picker showing tasks with +track tag for the given project
|
|
||||||
func createTaskPickerForProject(com *common.Common, project string, defaultTask string) *picker.Picker {
|
|
||||||
// Build filters for tasks with +track tag
|
|
||||||
filters := []string{"+track", "status:pending"}
|
|
||||||
if project != "" {
|
|
||||||
filters = append(filters, "project:"+project)
|
|
||||||
}
|
|
||||||
|
|
||||||
taskItemProvider := func() []list.Item {
|
|
||||||
tasks := com.TW.GetTasks(nil, filters...)
|
|
||||||
// Add "(none)" as first option, then all tasks
|
|
||||||
items := make([]list.Item, 0, len(tasks)+1)
|
|
||||||
items = append(items, picker.NewItem("(none)"))
|
|
||||||
for i := range tasks {
|
|
||||||
items = append(items, picker.NewItem(tasks[i].Description))
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
taskOnSelect := func(item list.Item) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
// Handle "(none)" selection
|
|
||||||
if item.FilterValue() == "(none)" {
|
|
||||||
return timeEditorTaskSelectedMsg{task: nil}
|
|
||||||
}
|
|
||||||
// Find the task by description
|
|
||||||
tasks := com.TW.GetTasks(nil, filters...)
|
|
||||||
for _, task := range tasks {
|
|
||||||
if task.Description == item.FilterValue() {
|
|
||||||
return timeEditorTaskSelectedMsg{task: task}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
title := "Task"
|
|
||||||
if project != "" {
|
|
||||||
title = fmt.Sprintf("Task (%s)", project)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []picker.PickerOption{
|
|
||||||
picker.WithFilterByDefault(false), // Start in list mode, not filter mode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-select task if provided, otherwise default to "(none)"
|
|
||||||
if defaultTask != "" {
|
|
||||||
opts = append(opts, picker.WithDefaultValue(defaultTask))
|
|
||||||
} else {
|
|
||||||
opts = append(opts, picker.WithDefaultValue("(none)"))
|
|
||||||
}
|
|
||||||
|
|
||||||
taskPicker := picker.New(
|
|
||||||
com,
|
|
||||||
title,
|
|
||||||
taskItemProvider,
|
|
||||||
taskOnSelect,
|
|
||||||
opts...,
|
|
||||||
)
|
|
||||||
taskPicker.SetSize(50, 10)
|
|
||||||
|
|
||||||
return taskPicker
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
|
|
||||||
// Extract special tags (uuid, project, track) and display tags
|
|
||||||
uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags)
|
|
||||||
|
|
||||||
// Track selected task for pre-selection
|
|
||||||
var selectedTask *taskwarrior.Task
|
|
||||||
var defaultTaskDescription string
|
|
||||||
|
|
||||||
// If UUID exists, fetch the task and add its title to display tags
|
|
||||||
if uuid != "" {
|
|
||||||
tasks := com.TW.GetTasks(nil, "uuid:"+uuid)
|
|
||||||
if len(tasks) > 0 {
|
|
||||||
selectedTask = tasks[0]
|
|
||||||
defaultTaskDescription = selectedTask.Description
|
|
||||||
// Add to display tags if not already present
|
|
||||||
// Note: formatTags() will handle quoting for display, so we store the raw title
|
|
||||||
displayTags = ensureTagPresent(displayTags, defaultTaskDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create project picker with onCreate support for new projects
|
|
||||||
projectItemProvider := func() []list.Item {
|
|
||||||
projects := com.TW.GetProjects()
|
|
||||||
items := make([]list.Item, len(projects))
|
|
||||||
for i, proj := range projects {
|
|
||||||
items[i] = picker.NewItem(proj)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
projectOnSelect := func(item list.Item) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return timeEditorProjectSelectedMsg{project: item.FilterValue()}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
projectOnCreate := func(name string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return timeEditorProjectSelectedMsg{project: name}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []picker.PickerOption{
|
|
||||||
picker.WithOnCreate(projectOnCreate),
|
|
||||||
}
|
|
||||||
if selectedProject != "" {
|
|
||||||
opts = append(opts, picker.WithDefaultValue(selectedProject))
|
|
||||||
} else {
|
|
||||||
opts = append(opts, picker.WithFilterByDefault(true))
|
|
||||||
}
|
|
||||||
|
|
||||||
projectPicker := picker.New(
|
|
||||||
com,
|
|
||||||
"Project",
|
|
||||||
projectItemProvider,
|
|
||||||
projectOnSelect,
|
|
||||||
opts...,
|
|
||||||
)
|
|
||||||
projectPicker.SetSize(50, 10) // Compact size for inline use
|
|
||||||
|
|
||||||
// Create task picker (only if project is selected)
|
|
||||||
var taskPicker *picker.Picker
|
|
||||||
if selectedProject != "" {
|
|
||||||
taskPicker = createTaskPickerForProject(com, selectedProject, defaultTaskDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create start timestamp editor
|
|
||||||
startEditor := timestampeditor.New(com).
|
|
||||||
Title("Start").
|
|
||||||
ValueFromString(interval.Start)
|
|
||||||
|
|
||||||
// Create end timestamp editor
|
|
||||||
endEditor := timestampeditor.New(com).
|
|
||||||
Title("End").
|
|
||||||
ValueFromString(interval.End)
|
|
||||||
|
|
||||||
// Get tag combinations filtered by selected project
|
|
||||||
tagCombinations := filterTagCombinationsByProject(
|
|
||||||
com.TimeW.GetTagCombinations(),
|
|
||||||
selectedProject,
|
|
||||||
)
|
|
||||||
|
|
||||||
tagsInput := autocomplete.New(tagCombinations, 3, 10)
|
|
||||||
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces")
|
|
||||||
tagsInput.SetValue(formatTags(displayTags)) // Use display tags only (no uuid, project, track)
|
|
||||||
tagsInput.SetWidth(50)
|
|
||||||
|
|
||||||
p := &TimeEditorPage{
|
|
||||||
common: com,
|
|
||||||
interval: interval,
|
|
||||||
projectPicker: projectPicker,
|
|
||||||
taskPicker: taskPicker,
|
|
||||||
startEditor: startEditor,
|
|
||||||
endEditor: endEditor,
|
|
||||||
tagsInput: tagsInput,
|
|
||||||
adjust: true, // Enable :adjust by default
|
|
||||||
selectedProject: selectedProject,
|
|
||||||
selectedTask: selectedTask,
|
|
||||||
currentField: 0,
|
|
||||||
totalFields: 6, // 6 fields: project, task, tags, start, end, adjust
|
|
||||||
uuid: uuid,
|
|
||||||
track: track,
|
|
||||||
}
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimeEditorPage) Init() tea.Cmd {
|
|
||||||
// Focus the first field (project picker)
|
|
||||||
p.currentField = 0
|
|
||||||
return p.projectPicker.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case timeEditorProjectSelectedMsg:
|
|
||||||
// Project selection happens on Enter - advance to task picker
|
|
||||||
// (Auto-selection of project already happened in Update() switch)
|
|
||||||
p.blurCurrentField()
|
|
||||||
p.currentField = 1
|
|
||||||
cmds = append(cmds, p.focusCurrentField())
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
|
|
||||||
case timeEditorTaskSelectedMsg:
|
|
||||||
// Task selection happens on Enter - advance to tags field
|
|
||||||
// (Auto-selection of task already happened in Update() switch)
|
|
||||||
p.blurCurrentField()
|
|
||||||
p.currentField = 2
|
|
||||||
cmds = append(cmds, p.focusCurrentField())
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, BackCmd
|
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
|
||||||
// Handle Enter based on current field
|
|
||||||
if p.currentField == 0 {
|
|
||||||
// Project picker - let it handle Enter (will trigger projectSelectedMsg)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.currentField == 1 {
|
|
||||||
// Task picker - let it handle Enter (will trigger taskSelectedMsg)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.currentField == 2 {
|
|
||||||
// Tags field
|
|
||||||
if p.tagsInput.HasSuggestions() {
|
|
||||||
// Let autocomplete handle suggestion selection
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// Tags confirmed without suggestions - advance to start timestamp
|
|
||||||
p.blurCurrentField()
|
|
||||||
p.currentField = 3
|
|
||||||
cmds = append(cmds, p.focusCurrentField())
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For all other fields (3-5: start, end, adjust), save and exit
|
|
||||||
p.saveInterval()
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
|
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Next):
|
|
||||||
// Move to next field
|
|
||||||
p.blurCurrentField()
|
|
||||||
p.currentField = (p.currentField + 1) % p.totalFields
|
|
||||||
cmds = append(cmds, p.focusCurrentField())
|
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Prev):
|
|
||||||
// Move to previous field
|
|
||||||
p.blurCurrentField()
|
|
||||||
p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields
|
|
||||||
cmds = append(cmds, p.focusCurrentField())
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
p.SetSize(msg.Width, msg.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the currently focused field
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch p.currentField {
|
|
||||||
case 0: // Project picker
|
|
||||||
// Track the previous project selection
|
|
||||||
previousProject := p.selectedProject
|
|
||||||
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = p.projectPicker.Update(msg)
|
|
||||||
if pk, ok := model.(*picker.Picker); ok {
|
|
||||||
p.projectPicker = pk
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the highlighted project changed (auto-selection)
|
|
||||||
currentProject := p.projectPicker.GetValue()
|
|
||||||
if currentProject != previousProject && currentProject != "" {
|
|
||||||
// Update the selected project and refresh task picker
|
|
||||||
p.selectedProject = currentProject
|
|
||||||
// Clear task selection when project changes
|
|
||||||
p.selectedTask = nil
|
|
||||||
p.uuid = ""
|
|
||||||
// Create/update task picker for the new project
|
|
||||||
p.taskPicker = createTaskPickerForProject(p.common, currentProject, "")
|
|
||||||
// Refresh tag autocomplete with filtered combinations
|
|
||||||
cmds = append(cmds, p.updateTagSuggestions())
|
|
||||||
}
|
|
||||||
case 1: // Task picker
|
|
||||||
if p.taskPicker != nil {
|
|
||||||
// Track the previous task selection
|
|
||||||
var previousTaskDesc string
|
|
||||||
if p.selectedTask != nil {
|
|
||||||
previousTaskDesc = p.selectedTask.Description
|
|
||||||
}
|
|
||||||
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = p.taskPicker.Update(msg)
|
|
||||||
if pk, ok := model.(*picker.Picker); ok {
|
|
||||||
p.taskPicker = pk
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the highlighted task changed (auto-selection)
|
|
||||||
currentTaskDesc := p.taskPicker.GetValue()
|
|
||||||
if currentTaskDesc != previousTaskDesc && currentTaskDesc != "" {
|
|
||||||
// Handle "(none)" selection - clear task state
|
|
||||||
if currentTaskDesc == "(none)" {
|
|
||||||
p.selectedTask = nil
|
|
||||||
p.uuid = ""
|
|
||||||
p.track = ""
|
|
||||||
// Don't clear tags - user might still want manual tags
|
|
||||||
// Refresh tag suggestions
|
|
||||||
cmds = append(cmds, p.updateTagSuggestions())
|
|
||||||
} else {
|
|
||||||
// Find and update the selected task
|
|
||||||
filters := []string{"+track", "status:pending"}
|
|
||||||
if p.selectedProject != "" {
|
|
||||||
filters = append(filters, "project:"+p.selectedProject)
|
|
||||||
}
|
|
||||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
|
||||||
for _, task := range tasks {
|
|
||||||
if task.Description == currentTaskDesc {
|
|
||||||
// Update selected task
|
|
||||||
p.selectedTask = task
|
|
||||||
p.uuid = task.Uuid
|
|
||||||
|
|
||||||
// Build tags from task
|
|
||||||
tags := []string{}
|
|
||||||
|
|
||||||
// Add task description
|
|
||||||
if task.Description != "" {
|
|
||||||
tags = append(tags, task.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add task tags (excluding "track" tag since it's preserved separately)
|
|
||||||
for _, tag := range task.Tags {
|
|
||||||
if tag != "track" {
|
|
||||||
tags = append(tags, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store track tag if present
|
|
||||||
if task.HasTag("track") {
|
|
||||||
p.track = "track"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update tags input
|
|
||||||
p.tagsInput.SetValue(formatTags(tags))
|
|
||||||
|
|
||||||
// Refresh tag suggestions
|
|
||||||
cmds = append(cmds, p.updateTagSuggestions())
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 2: // Tags
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = p.tagsInput.Update(msg)
|
|
||||||
if ac, ok := model.(*autocomplete.Autocomplete); ok {
|
|
||||||
p.tagsInput = ac
|
|
||||||
}
|
|
||||||
case 3: // Start
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = p.startEditor.Update(msg)
|
|
||||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
|
||||||
p.startEditor = editor
|
|
||||||
}
|
|
||||||
case 4: // End
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = p.endEditor.Update(msg)
|
|
||||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
|
||||||
p.endEditor = editor
|
|
||||||
}
|
|
||||||
case 5: // Adjust
|
|
||||||
// Handle adjust toggle with space/enter
|
|
||||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
|
||||||
if msg.String() == " " || msg.String() == "enter" {
|
|
||||||
p.adjust = !p.adjust
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
|
||||||
switch p.currentField {
|
|
||||||
case 0:
|
|
||||||
return p.projectPicker.Init() // Picker doesn't have explicit Focus()
|
|
||||||
case 1:
|
|
||||||
if p.taskPicker != nil {
|
|
||||||
return p.taskPicker.Init()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case 2:
|
|
||||||
p.tagsInput.Focus()
|
|
||||||
return p.tagsInput.Init()
|
|
||||||
case 3:
|
|
||||||
return p.startEditor.Focus()
|
|
||||||
case 4:
|
|
||||||
return p.endEditor.Focus()
|
|
||||||
case 5:
|
|
||||||
// Adjust checkbox doesn't need focus action
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimeEditorPage) blurCurrentField() {
|
|
||||||
switch p.currentField {
|
|
||||||
case 0:
|
|
||||||
// Project picker doesn't have explicit Blur(), state handled by Update
|
|
||||||
case 1:
|
|
||||||
// Task picker doesn't have explicit Blur(), state handled by Update
|
|
||||||
case 2:
|
|
||||||
p.tagsInput.Blur()
|
|
||||||
case 3:
|
|
||||||
p.startEditor.Blur()
|
|
||||||
case 4:
|
|
||||||
p.endEditor.Blur()
|
|
||||||
case 5:
|
|
||||||
// Adjust checkbox doesn't need blur action
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimeEditorPage) View() string {
|
|
||||||
var sections []string
|
|
||||||
|
|
||||||
// Title
|
|
||||||
titleStyle := p.common.Styles.Form.Focused.Title
|
|
||||||
sections = append(sections, titleStyle.Render("Edit Time Interval"))
|
|
||||||
sections = append(sections, "")
|
|
||||||
|
|
||||||
// Project picker (field 0)
|
|
||||||
if p.currentField == 0 {
|
|
||||||
sections = append(sections, p.projectPicker.View())
|
|
||||||
} else {
|
|
||||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
|
||||||
sections = append(sections, blurredLabelStyle.Render("Project"))
|
|
||||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedProject))
|
|
||||||
}
|
|
||||||
|
|
||||||
sections = append(sections, "")
|
|
||||||
sections = append(sections, "")
|
|
||||||
|
|
||||||
// Task picker (field 1)
|
|
||||||
if p.currentField == 1 {
|
|
||||||
if p.taskPicker != nil {
|
|
||||||
sections = append(sections, p.taskPicker.View())
|
|
||||||
} else {
|
|
||||||
// No project selected yet
|
|
||||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
|
||||||
sections = append(sections, blurredLabelStyle.Render("Task"))
|
|
||||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(select a project first)"))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
|
||||||
sections = append(sections, blurredLabelStyle.Render("Task"))
|
|
||||||
if p.selectedTask != nil {
|
|
||||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedTask.Description))
|
|
||||||
} else {
|
|
||||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(none)"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sections = append(sections, "")
|
|
||||||
sections = append(sections, "")
|
|
||||||
|
|
||||||
// Tags input (field 2)
|
|
||||||
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
|
||||||
tagsLabel := tagsLabelStyle.Render("Tags")
|
|
||||||
if p.currentField == 2 {
|
|
||||||
sections = append(sections, tagsLabel)
|
|
||||||
sections = append(sections, p.tagsInput.View())
|
|
||||||
descStyle := p.common.Styles.Form.Focused.Description
|
|
||||||
sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces"))
|
|
||||||
} else {
|
|
||||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
|
||||||
sections = append(sections, blurredLabelStyle.Render("Tags"))
|
|
||||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.GetValue()))
|
|
||||||
}
|
|
||||||
|
|
||||||
sections = append(sections, "")
|
|
||||||
sections = append(sections, "")
|
|
||||||
|
|
||||||
// Start editor (field 3)
|
|
||||||
sections = append(sections, p.startEditor.View())
|
|
||||||
sections = append(sections, "")
|
|
||||||
|
|
||||||
// End editor (field 4)
|
|
||||||
sections = append(sections, p.endEditor.View())
|
|
||||||
sections = append(sections, "")
|
|
||||||
|
|
||||||
// Adjust checkbox (field 5)
|
|
||||||
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 == 5 {
|
|
||||||
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: select/save • esc: cancel"))
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimeEditorPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimeEditorPage) saveInterval() {
|
|
||||||
// If it's an existing interval (has ID), delete it first so we can replace it with the modified version
|
|
||||||
if p.interval.ID != 0 {
|
|
||||||
err := p.common.TimeW.DeleteInterval(p.interval.ID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to delete old interval during edit", "err", err)
|
|
||||||
// Proceeding to import anyway, attempting to save user data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.interval.Start = p.startEditor.GetValueString()
|
|
||||||
p.interval.End = p.endEditor.GetValueString()
|
|
||||||
|
|
||||||
// Parse display tags from input
|
|
||||||
displayTags := parseTags(p.tagsInput.GetValue())
|
|
||||||
|
|
||||||
// Reconstruct full tags array by combining special tags and display tags
|
|
||||||
var tags []string
|
|
||||||
|
|
||||||
// Add preserved special tags first
|
|
||||||
if p.uuid != "" {
|
|
||||||
tags = append(tags, "uuid:"+p.uuid)
|
|
||||||
}
|
|
||||||
if p.track != "" {
|
|
||||||
tags = append(tags, p.track)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add project tag
|
|
||||||
if p.selectedProject != "" {
|
|
||||||
tags = append(tags, "project:"+p.selectedProject)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add display tags (user-entered tags from the input field)
|
|
||||||
tags = append(tags, displayTags...)
|
|
||||||
|
|
||||||
p.interval.Tags = tags
|
|
||||||
|
|
||||||
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to modify interval", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTags(tagsStr string) []string {
|
|
||||||
var tags []string
|
|
||||||
var current strings.Builder
|
|
||||||
inQuotes := false
|
|
||||||
|
|
||||||
for _, r := range tagsStr {
|
|
||||||
switch {
|
|
||||||
case r == '"':
|
|
||||||
inQuotes = !inQuotes
|
|
||||||
case r == ' ' && !inQuotes:
|
|
||||||
if current.Len() > 0 {
|
|
||||||
tags = append(tags, current.String())
|
|
||||||
current.Reset()
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
current.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if current.Len() > 0 {
|
|
||||||
tags = append(tags, current.String())
|
|
||||||
}
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTags(tags []string) string {
|
|
||||||
var formatted []string
|
|
||||||
for _, t := range tags {
|
|
||||||
if strings.Contains(t, " ") {
|
|
||||||
formatted = append(formatted, "\""+t+"\"")
|
|
||||||
} else {
|
|
||||||
formatted = append(formatted, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(formatted, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractSpecialTags separates special tags (uuid, project, track) from display tags
|
|
||||||
// Returns uuid, project, track as separate strings, and displayTags for user editing
|
|
||||||
func extractSpecialTags(tags []string) (uuid, project, track string, displayTags []string) {
|
|
||||||
for _, tag := range tags {
|
|
||||||
if strings.HasPrefix(tag, "uuid:") {
|
|
||||||
uuid = strings.TrimPrefix(tag, "uuid:")
|
|
||||||
} else if strings.HasPrefix(tag, "project:") {
|
|
||||||
project = strings.TrimPrefix(tag, "project:")
|
|
||||||
} else if tag == "track" {
|
|
||||||
track = tag
|
|
||||||
} else {
|
|
||||||
displayTags = append(displayTags, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractProjectFromTags finds and removes the first tag that matches a known project
|
|
||||||
// Returns the found project (or empty string) and the remaining tags
|
|
||||||
// This is kept for backward compatibility but now uses extractSpecialTags internally
|
|
||||||
func extractProjectFromTags(tags []string, projects []string) (string, []string) {
|
|
||||||
_, project, _, remaining := extractSpecialTags(tags)
|
|
||||||
return project, remaining
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureTagPresent adds a tag to the list if not already present
|
|
||||||
func ensureTagPresent(tags []string, tag string) []string {
|
|
||||||
for _, t := range tags {
|
|
||||||
if t == tag {
|
|
||||||
return tags // Already present
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(tags, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterTagCombinationsByProject filters tag combinations to only show those
|
|
||||||
// containing the exact project tag, and removes the project from the displayed combination
|
|
||||||
func filterTagCombinationsByProject(combinations []string, project string) []string {
|
|
||||||
if project == "" {
|
|
||||||
return combinations
|
|
||||||
}
|
|
||||||
|
|
||||||
projectTag := "project:" + project
|
|
||||||
|
|
||||||
var filtered []string
|
|
||||||
for _, combo := range combinations {
|
|
||||||
// Parse the combination into individual tags
|
|
||||||
tags := parseTags(combo)
|
|
||||||
|
|
||||||
// Check if project exists in this combination
|
|
||||||
for _, tag := range tags {
|
|
||||||
if tag == projectTag {
|
|
||||||
// Found the project - now remove it from display
|
|
||||||
var displayTags []string
|
|
||||||
for _, t := range tags {
|
|
||||||
if t != projectTag {
|
|
||||||
displayTags = append(displayTags, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(displayTags) > 0 {
|
|
||||||
filtered = append(filtered, formatTags(displayTags))
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateTagSuggestions refreshes the tag autocomplete with filtered combinations
|
|
||||||
func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd {
|
|
||||||
combinations := filterTagCombinationsByProject(
|
|
||||||
p.common.TimeW.GetTagCombinations(),
|
|
||||||
p.selectedProject,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update autocomplete suggestions
|
|
||||||
currentValue := p.tagsInput.GetValue()
|
|
||||||
p.tagsInput.SetSuggestions(combinations)
|
|
||||||
p.tagsInput.SetValue(currentValue)
|
|
||||||
|
|
||||||
// If tags field is focused, refocus it
|
|
||||||
if p.currentField == 2 {
|
|
||||||
p.tagsInput.Focus()
|
|
||||||
return p.tagsInput.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/components/timetable"
|
|
||||||
"tasksquire/timewarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TimePage struct {
|
|
||||||
common *common.Common
|
|
||||||
|
|
||||||
intervals timetable.Model
|
|
||||||
data timewarrior.Intervals
|
|
||||||
|
|
||||||
shouldSelectActive bool
|
|
||||||
pendingSyncAction string // "start", "stop", or "" (empty means no pending action)
|
|
||||||
|
|
||||||
selectedTimespan string
|
|
||||||
subpage common.Component
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTimePage(com *common.Common) *TimePage {
|
|
||||||
p := &TimePage{
|
|
||||||
common: com,
|
|
||||||
selectedTimespan: ":day",
|
|
||||||
}
|
|
||||||
|
|
||||||
p.populateTable(timewarrior.Intervals{})
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimePage) isMultiDayTimespan() bool {
|
|
||||||
switch p.selectedTimespan {
|
|
||||||
case ":day", ":yesterday":
|
|
||||||
return false
|
|
||||||
case ":week", ":lastweek", ":month", ":lastmonth", ":year":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimePage) getTimespanLabel() string {
|
|
||||||
switch p.selectedTimespan {
|
|
||||||
case ":day":
|
|
||||||
return "Today"
|
|
||||||
case ":yesterday":
|
|
||||||
return "Yesterday"
|
|
||||||
case ":week":
|
|
||||||
return "Week"
|
|
||||||
case ":lastweek":
|
|
||||||
return "Last Week"
|
|
||||||
case ":month":
|
|
||||||
return "Month"
|
|
||||||
case ":lastmonth":
|
|
||||||
return "Last Month"
|
|
||||||
case ":year":
|
|
||||||
return "Year"
|
|
||||||
default:
|
|
||||||
return p.selectedTimespan
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimePage) getTimespanDateRange() (start, end time.Time) {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
switch p.selectedTimespan {
|
|
||||||
case ":day":
|
|
||||||
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
||||||
end = start.AddDate(0, 0, 1)
|
|
||||||
case ":yesterday":
|
|
||||||
start = time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, now.Location())
|
|
||||||
end = start.AddDate(0, 0, 1)
|
|
||||||
case ":week":
|
|
||||||
// Find the start of the week (Monday)
|
|
||||||
offset := int(time.Monday - now.Weekday())
|
|
||||||
if offset > 0 {
|
|
||||||
offset = -6
|
|
||||||
}
|
|
||||||
start = time.Date(now.Year(), now.Month(), now.Day()+offset, 0, 0, 0, 0, now.Location())
|
|
||||||
end = start.AddDate(0, 0, 7)
|
|
||||||
case ":lastweek":
|
|
||||||
// Find the start of last week
|
|
||||||
offset := int(time.Monday - now.Weekday())
|
|
||||||
if offset > 0 {
|
|
||||||
offset = -6
|
|
||||||
}
|
|
||||||
start = time.Date(now.Year(), now.Month(), now.Day()+offset-7, 0, 0, 0, 0, now.Location())
|
|
||||||
end = start.AddDate(0, 0, 7)
|
|
||||||
case ":month":
|
|
||||||
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
|
||||||
end = start.AddDate(0, 1, 0)
|
|
||||||
case ":lastmonth":
|
|
||||||
start = time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location())
|
|
||||||
end = start.AddDate(0, 1, 0)
|
|
||||||
case ":year":
|
|
||||||
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
|
||||||
end = start.AddDate(1, 0, 0)
|
|
||||||
default:
|
|
||||||
start = now
|
|
||||||
end = now
|
|
||||||
}
|
|
||||||
|
|
||||||
return start, end
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimePage) renderHeader() string {
|
|
||||||
label := p.getTimespanLabel()
|
|
||||||
start, end := p.getTimespanDateRange()
|
|
||||||
|
|
||||||
var headerText string
|
|
||||||
if p.isMultiDayTimespan() {
|
|
||||||
// Multi-day format: "Week: Feb 02 - Feb 08, 2026"
|
|
||||||
if start.Year() == end.AddDate(0, 0, -1).Year() {
|
|
||||||
headerText = fmt.Sprintf("%s: %s - %s, %d",
|
|
||||||
label,
|
|
||||||
start.Format("Jan 02"),
|
|
||||||
end.AddDate(0, 0, -1).Format("Jan 02"),
|
|
||||||
start.Year())
|
|
||||||
} else {
|
|
||||||
headerText = fmt.Sprintf("%s: %s, %d - %s, %d",
|
|
||||||
label,
|
|
||||||
start.Format("Jan 02"),
|
|
||||||
start.Year(),
|
|
||||||
end.AddDate(0, 0, -1).Format("Jan 02"),
|
|
||||||
end.AddDate(0, 0, -1).Year())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Single-day format: "Today (Mon, Feb 02, 2026)"
|
|
||||||
headerText = fmt.Sprintf("%s (%s, %s, %d)",
|
|
||||||
label,
|
|
||||||
start.Format("Mon"),
|
|
||||||
start.Format("Jan 02"),
|
|
||||||
start.Year())
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan)
|
|
||||||
// Make header bold and prominent
|
|
||||||
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
|
||||||
return headerStyle.Render(headerText)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimePage) Init() tea.Cmd {
|
|
||||||
return tea.Batch(p.getIntervals(), doTick())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
p.SetSize(msg.Width, msg.Height)
|
|
||||||
case UpdateTimespanMsg:
|
|
||||||
p.selectedTimespan = string(msg)
|
|
||||||
cmds = append(cmds, p.getIntervals())
|
|
||||||
case intervalsMsg:
|
|
||||||
p.data = timewarrior.Intervals(msg)
|
|
||||||
p.populateTable(p.data)
|
|
||||||
|
|
||||||
// If we have a pending sync action (from continuing an interval),
|
|
||||||
// execute it now that intervals are refreshed
|
|
||||||
if p.pendingSyncAction != "" {
|
|
||||||
action := p.pendingSyncAction
|
|
||||||
p.pendingSyncAction = ""
|
|
||||||
cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action))
|
|
||||||
}
|
|
||||||
case TaskPickedMsg:
|
|
||||||
if msg.Task != nil && msg.Task.Status == "pending" {
|
|
||||||
p.common.TW.StopActiveTasks()
|
|
||||||
p.common.TW.StartTask(msg.Task)
|
|
||||||
cmds = append(cmds, p.getIntervals())
|
|
||||||
cmds = append(cmds, doTick())
|
|
||||||
}
|
|
||||||
case RefreshIntervalsMsg:
|
|
||||||
cmds = append(cmds, p.getIntervals())
|
|
||||||
cmds = append(cmds, doTick())
|
|
||||||
case BackMsg:
|
|
||||||
// Restart tick loop when returning from subpage
|
|
||||||
cmds = append(cmds, doTick())
|
|
||||||
case tickMsg:
|
|
||||||
cmds = append(cmds, p.getIntervals())
|
|
||||||
cmds = append(cmds, doTick())
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Quit):
|
|
||||||
return p, tea.Quit
|
|
||||||
case key.Matches(msg, p.common.Keymap.SetReport):
|
|
||||||
// Use 'r' key to show timespan picker
|
|
||||||
p.subpage = NewTimespanPickerPage(p.common, p.selectedTimespan)
|
|
||||||
cmd := p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, cmd
|
|
||||||
case key.Matches(msg, p.common.Keymap.PickProjectTask):
|
|
||||||
p.subpage = NewProjectTaskPickerPage(p.common)
|
|
||||||
cmd := p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, cmd
|
|
||||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
|
||||||
row := p.intervals.SelectedRow()
|
|
||||||
if row != nil {
|
|
||||||
interval := (*timewarrior.Interval)(row)
|
|
||||||
|
|
||||||
// Validate interval before proceeding
|
|
||||||
if interval.IsGap {
|
|
||||||
slog.Debug("Cannot start/stop gap interval")
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if interval.IsActive() {
|
|
||||||
// Stop tracking
|
|
||||||
p.common.TimeW.StopTracking()
|
|
||||||
// Sync: stop corresponding Taskwarrior task immediately (interval has UUID)
|
|
||||||
common.SyncIntervalToTask(interval, p.common.TW, "stop")
|
|
||||||
} else {
|
|
||||||
// Continue tracking - creates a NEW interval
|
|
||||||
slog.Info("Continuing interval for task sync",
|
|
||||||
"intervalID", interval.ID,
|
|
||||||
"hasUUID", timewarrior.ExtractUUID(interval.Tags) != "",
|
|
||||||
"uuid", timewarrior.ExtractUUID(interval.Tags))
|
|
||||||
p.common.TimeW.ContinueInterval(interval.ID)
|
|
||||||
p.shouldSelectActive = true
|
|
||||||
// Set pending sync action instead of syncing immediately
|
|
||||||
// This ensures we sync AFTER intervals are refreshed
|
|
||||||
p.pendingSyncAction = "start"
|
|
||||||
}
|
|
||||||
cmds = append(cmds, p.getIntervals())
|
|
||||||
cmds = append(cmds, doTick())
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
case key.Matches(msg, p.common.Keymap.Delete):
|
|
||||||
row := p.intervals.SelectedRow()
|
|
||||||
if row != nil {
|
|
||||||
interval := (*timewarrior.Interval)(row)
|
|
||||||
p.common.TimeW.DeleteInterval(interval.ID)
|
|
||||||
return p, tea.Batch(p.getIntervals(), doTick())
|
|
||||||
}
|
|
||||||
case key.Matches(msg, p.common.Keymap.Edit):
|
|
||||||
row := p.intervals.SelectedRow()
|
|
||||||
if row != nil {
|
|
||||||
interval := (*timewarrior.Interval)(row)
|
|
||||||
editor := NewTimeEditorPage(p.common, interval)
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return editor, editor.Init()
|
|
||||||
}
|
|
||||||
case key.Matches(msg, p.common.Keymap.Add):
|
|
||||||
interval := timewarrior.NewInterval()
|
|
||||||
interval.Start = time.Now().UTC().Format("20060102T150405Z")
|
|
||||||
editor := NewTimeEditorPage(p.common, interval)
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return editor, editor.Init()
|
|
||||||
case key.Matches(msg, p.common.Keymap.Fill):
|
|
||||||
row := p.intervals.SelectedRow()
|
|
||||||
if row != nil {
|
|
||||||
interval := (*timewarrior.Interval)(row)
|
|
||||||
p.common.TimeW.FillInterval(interval.ID)
|
|
||||||
return p, tea.Batch(p.getIntervals(), doTick())
|
|
||||||
}
|
|
||||||
case key.Matches(msg, p.common.Keymap.Undo):
|
|
||||||
p.common.TimeW.Undo()
|
|
||||||
return p, tea.Batch(p.getIntervals(), doTick())
|
|
||||||
case key.Matches(msg, p.common.Keymap.Join):
|
|
||||||
row := p.intervals.SelectedRow()
|
|
||||||
if row != nil {
|
|
||||||
interval := (*timewarrior.Interval)(row)
|
|
||||||
// Don't join if this is the last (oldest) interval
|
|
||||||
if interval.ID < len(p.data) {
|
|
||||||
p.common.TimeW.JoinInterval(interval.ID)
|
|
||||||
return p, tea.Batch(p.getIntervals(), doTick())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
p.intervals, cmd = p.intervals.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
type RefreshIntervalsMsg struct{}
|
|
||||||
|
|
||||||
func refreshIntervals() tea.Msg {
|
|
||||||
return RefreshIntervalsMsg{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimePage) View() string {
|
|
||||||
header := p.renderHeader()
|
|
||||||
if len(p.data) == 0 {
|
|
||||||
noDataMsg := p.common.Styles.Base.Render("No intervals found")
|
|
||||||
content := header + "\n\n" + noDataMsg
|
|
||||||
return lipgloss.Place(
|
|
||||||
p.common.Width(),
|
|
||||||
p.common.Height(),
|
|
||||||
lipgloss.Left,
|
|
||||||
lipgloss.Top,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
tableView := p.intervals.View()
|
|
||||||
content := header + "\n\n" + tableView
|
|
||||||
|
|
||||||
contentHeight := lipgloss.Height(content)
|
|
||||||
tableHeight := lipgloss.Height(tableView)
|
|
||||||
headerHeight := lipgloss.Height(header)
|
|
||||||
|
|
||||||
slog.Info("TimePage View rendered",
|
|
||||||
"headerLen", len(header),
|
|
||||||
"dataCount", len(p.data),
|
|
||||||
"headerHeight", headerHeight,
|
|
||||||
"tableHeight", tableHeight,
|
|
||||||
"contentHeight", contentHeight,
|
|
||||||
"termHeight", p.common.Height())
|
|
||||||
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimePage) SetSize(width int, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
|
|
||||||
tableHeight := height - frameSize - 3
|
|
||||||
slog.Info("TimePage SetSize", "totalHeight", height, "frameSize", frameSize, "tableHeight", tableHeight)
|
|
||||||
p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
|
|
||||||
// Subtract 3: 1 for header line, 1 for empty line, 1 for safety margin
|
|
||||||
p.intervals.SetHeight(tableHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// insertGaps inserts gap intervals between actual intervals where there is untracked time.
|
|
||||||
// Gaps are not inserted before the first interval or after the last interval.
|
|
||||||
// Note: intervals are in reverse chronological order (newest first), so we need to account for that.
|
|
||||||
func insertGaps(intervals timewarrior.Intervals) timewarrior.Intervals {
|
|
||||||
if len(intervals) <= 1 {
|
|
||||||
return intervals
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make(timewarrior.Intervals, 0, len(intervals)*2)
|
|
||||||
|
|
||||||
for i := 0; i < len(intervals); i++ {
|
|
||||||
result = append(result, intervals[i])
|
|
||||||
|
|
||||||
// Don't add gap after the last interval
|
|
||||||
if i < len(intervals)-1 {
|
|
||||||
// Since intervals are reversed (newest first), the gap is between
|
|
||||||
// the end of the NEXT interval and the start of the CURRENT interval
|
|
||||||
currentStart := intervals[i].GetStartTime()
|
|
||||||
nextEnd := intervals[i+1].GetEndTime()
|
|
||||||
|
|
||||||
// Calculate gap duration
|
|
||||||
gap := currentStart.Sub(nextEnd)
|
|
||||||
|
|
||||||
// Only insert gap if there is untracked time
|
|
||||||
if gap > 0 {
|
|
||||||
gapInterval := timewarrior.NewGapInterval(nextEnd, currentStart)
|
|
||||||
result = append(result, gapInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
|
||||||
var selectedStart string
|
|
||||||
if row := p.intervals.SelectedRow(); row != nil {
|
|
||||||
selectedStart = row.Start
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert gap intervals between actual intervals
|
|
||||||
intervalsWithGaps := insertGaps(intervals)
|
|
||||||
|
|
||||||
// Determine column configuration based on timespan
|
|
||||||
var startEndWidth int
|
|
||||||
var startField, endField string
|
|
||||||
if p.isMultiDayTimespan() {
|
|
||||||
startEndWidth = 16 // "2006-01-02 15:04"
|
|
||||||
startField = "start"
|
|
||||||
endField = "end"
|
|
||||||
} else {
|
|
||||||
startEndWidth = 5 // "15:04"
|
|
||||||
startField = "start_time"
|
|
||||||
endField = "end_time"
|
|
||||||
}
|
|
||||||
|
|
||||||
columns := []timetable.Column{
|
|
||||||
{Title: "ID", Name: "id", Width: 4},
|
|
||||||
{Title: "Weekday", Name: "weekday", Width: 9},
|
|
||||||
{Title: "Start", Name: startField, Width: startEndWidth},
|
|
||||||
{Title: "End", Name: endField, Width: startEndWidth},
|
|
||||||
{Title: "Duration", Name: "duration", Width: 10},
|
|
||||||
{Title: "Project", Name: "project", Width: 0}, // flexible width
|
|
||||||
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate table height: total height - header (1 line) - blank line (1) - safety (1)
|
|
||||||
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
|
|
||||||
tableHeight := p.common.Height() - frameSize - 3
|
|
||||||
|
|
||||||
p.intervals = timetable.New(
|
|
||||||
p.common,
|
|
||||||
timetable.WithColumns(columns),
|
|
||||||
timetable.WithIntervals(intervalsWithGaps),
|
|
||||||
timetable.WithFocused(true),
|
|
||||||
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()),
|
|
||||||
timetable.WithHeight(tableHeight),
|
|
||||||
timetable.WithStyles(p.common.Styles.TableStyle),
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(intervalsWithGaps) > 0 {
|
|
||||||
newIdx := -1
|
|
||||||
|
|
||||||
if p.shouldSelectActive {
|
|
||||||
for i, interval := range intervalsWithGaps {
|
|
||||||
if !interval.IsGap && interval.IsActive() {
|
|
||||||
newIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.shouldSelectActive = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if newIdx == -1 && selectedStart != "" {
|
|
||||||
for i, interval := range intervalsWithGaps {
|
|
||||||
if !interval.IsGap && interval.Start == selectedStart {
|
|
||||||
newIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if newIdx == -1 {
|
|
||||||
// Default to first non-gap interval
|
|
||||||
for i, interval := range intervalsWithGaps {
|
|
||||||
if !interval.IsGap {
|
|
||||||
newIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if newIdx >= len(intervalsWithGaps) {
|
|
||||||
newIdx = len(intervalsWithGaps) - 1
|
|
||||||
}
|
|
||||||
if newIdx < 0 {
|
|
||||||
newIdx = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
p.intervals.SetCursor(newIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type intervalsMsg timewarrior.Intervals
|
|
||||||
|
|
||||||
func (p *TimePage) getIntervals() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
intervals := p.common.TimeW.GetIntervals(p.selectedTimespan)
|
|
||||||
return intervalsMsg(intervals)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior
|
|
||||||
func (p *TimePage) syncActiveInterval(action string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
// Get the currently active interval
|
|
||||||
activeInterval := p.common.TimeW.GetActive()
|
|
||||||
if activeInterval != nil {
|
|
||||||
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed
|
|
||||||
// to ensure we're working with current data
|
|
||||||
func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
// At this point, intervals have been refreshed, so GetActive() will work
|
|
||||||
activeInterval := p.common.TimeW.GetActive()
|
|
||||||
if activeInterval != nil {
|
|
||||||
slog.Info("Syncing active interval to task after refresh",
|
|
||||||
"action", action,
|
|
||||||
"intervalID", activeInterval.ID,
|
|
||||||
"hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "")
|
|
||||||
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
|
||||||
} else {
|
|
||||||
slog.Warn("No active interval found after refresh, cannot sync to task")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"tasksquire/timewarrior"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestInsertGaps(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
intervals timewarrior.Intervals
|
|
||||||
expectedCount int
|
|
||||||
expectedGaps int
|
|
||||||
description string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty intervals",
|
|
||||||
intervals: timewarrior.Intervals{},
|
|
||||||
expectedCount: 0,
|
|
||||||
expectedGaps: 0,
|
|
||||||
description: "Should return empty list for empty input",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single interval",
|
|
||||||
intervals: timewarrior.Intervals{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
End: time.Now().UTC().Format("20060102T150405Z"),
|
|
||||||
Tags: []string{"test"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedCount: 1,
|
|
||||||
expectedGaps: 0,
|
|
||||||
description: "Should return single interval without gaps",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "two intervals with gap (reverse chronological)",
|
|
||||||
intervals: timewarrior.Intervals{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
End: time.Now().UTC().Format("20060102T150405Z"),
|
|
||||||
Tags: []string{"test2"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2,
|
|
||||||
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
Tags: []string{"test1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedCount: 3,
|
|
||||||
expectedGaps: 1,
|
|
||||||
description: "Should insert one gap between two intervals (newest first order)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "three intervals with two gaps (reverse chronological)",
|
|
||||||
intervals: timewarrior.Intervals{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
End: time.Now().UTC().Format("20060102T150405Z"),
|
|
||||||
Tags: []string{"test3"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2,
|
|
||||||
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
Tags: []string{"test2"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 3,
|
|
||||||
Start: time.Now().Add(-5 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
End: time.Now().Add(-4 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
Tags: []string{"test1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedCount: 5,
|
|
||||||
expectedGaps: 2,
|
|
||||||
description: "Should insert two gaps between three intervals (newest first order)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "consecutive intervals with no gap (reverse chronological)",
|
|
||||||
intervals: timewarrior.Intervals{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
End: time.Now().UTC().Format("20060102T150405Z"),
|
|
||||||
Tags: []string{"test2"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2,
|
|
||||||
Start: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
End: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
|
||||||
Tags: []string{"test1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedCount: 2,
|
|
||||||
expectedGaps: 0,
|
|
||||||
description: "Should not insert gap when intervals are consecutive (newest first order)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := insertGaps(tt.intervals)
|
|
||||||
|
|
||||||
if len(result) != tt.expectedCount {
|
|
||||||
t.Errorf("insertGaps() returned %d intervals, expected %d. %s",
|
|
||||||
len(result), tt.expectedCount, tt.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
gapCount := 0
|
|
||||||
for _, interval := range result {
|
|
||||||
if interval.IsGap {
|
|
||||||
gapCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if gapCount != tt.expectedGaps {
|
|
||||||
t.Errorf("insertGaps() created %d gaps, expected %d. %s",
|
|
||||||
gapCount, tt.expectedGaps, tt.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify gaps are properly interleaved with intervals
|
|
||||||
for i := 0; i < len(result)-1; i++ {
|
|
||||||
if result[i].IsGap && result[i+1].IsGap {
|
|
||||||
t.Errorf("insertGaps() created consecutive gap rows at indices %d and %d", i, i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify first and last items are never gaps
|
|
||||||
if len(result) > 0 {
|
|
||||||
if result[0].IsGap {
|
|
||||||
t.Errorf("insertGaps() created gap as first item")
|
|
||||||
}
|
|
||||||
if result[len(result)-1].IsGap {
|
|
||||||
t.Errorf("insertGaps() created gap as last item")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/components/picker"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TimespanPickerPage struct {
|
|
||||||
common *common.Common
|
|
||||||
picker *picker.Picker
|
|
||||||
selectedTimespan string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTimespanPickerPage(common *common.Common, currentTimespan string) *TimespanPickerPage {
|
|
||||||
p := &TimespanPickerPage{
|
|
||||||
common: common,
|
|
||||||
selectedTimespan: currentTimespan,
|
|
||||||
}
|
|
||||||
|
|
||||||
timespanOptions := []list.Item{
|
|
||||||
picker.NewItem(":day"),
|
|
||||||
picker.NewItem(":yesterday"),
|
|
||||||
picker.NewItem(":week"),
|
|
||||||
picker.NewItem(":lastweek"),
|
|
||||||
picker.NewItem(":month"),
|
|
||||||
picker.NewItem(":lastmonth"),
|
|
||||||
picker.NewItem(":year"),
|
|
||||||
}
|
|
||||||
|
|
||||||
itemProvider := func() []list.Item {
|
|
||||||
return timespanOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect := func(item list.Item) tea.Cmd {
|
|
||||||
return func() tea.Msg { return timespanSelectedMsg{item: item} }
|
|
||||||
}
|
|
||||||
|
|
||||||
p.picker = picker.New(common, "Select Timespan", itemProvider, onSelect)
|
|
||||||
|
|
||||||
// Select the current timespan in the picker
|
|
||||||
p.picker.SelectItemByFilterValue(currentTimespan)
|
|
||||||
|
|
||||||
p.SetSize(common.Width(), common.Height())
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimespanPickerPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
|
|
||||||
// Set list size with some padding/limits to look like a picker
|
|
||||||
listWidth := width - 4
|
|
||||||
if listWidth > 40 {
|
|
||||||
listWidth = 40
|
|
||||||
}
|
|
||||||
listHeight := height - 6
|
|
||||||
if listHeight > 20 {
|
|
||||||
listHeight = 20
|
|
||||||
}
|
|
||||||
p.picker.SetSize(listWidth, listHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimespanPickerPage) Init() tea.Cmd {
|
|
||||||
return p.picker.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
type timespanSelectedMsg struct {
|
|
||||||
item list.Item
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimespanPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
p.SetSize(msg.Width, msg.Height)
|
|
||||||
case timespanSelectedMsg:
|
|
||||||
timespan := msg.item.FilterValue()
|
|
||||||
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, func() tea.Msg { return UpdateTimespanMsg(timespan) }
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if !p.picker.IsFiltering() {
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, BackCmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, cmd = p.picker.Update(msg)
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TimespanPickerPage) View() string {
|
|
||||||
width := p.common.Width() - 4
|
|
||||||
if width > 40 {
|
|
||||||
width = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
content := p.picker.View()
|
|
||||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
|
||||||
|
|
||||||
return lipgloss.Place(
|
|
||||||
p.common.Width(),
|
|
||||||
p.common.Height(),
|
|
||||||
lipgloss.Center,
|
|
||||||
lipgloss.Center,
|
|
||||||
p.common.Styles.Base.Render(styledContent),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateTimespanMsg string
|
|
||||||
Binary file not shown.
30
test/taskrc
30
test/taskrc
@@ -1,30 +0,0 @@
|
|||||||
include light-256.theme
|
|
||||||
|
|
||||||
uda.priority.values=H,M,,L
|
|
||||||
|
|
||||||
context.test.read=+test
|
|
||||||
context.test.write=+test
|
|
||||||
context.home.read=+home
|
|
||||||
context.home.write=+home
|
|
||||||
|
|
||||||
uda.testuda.type=string
|
|
||||||
uda.testuda.label=Testuda
|
|
||||||
uda.testuda.values=eins,zwei,drei
|
|
||||||
uda.testuda.default=eins
|
|
||||||
|
|
||||||
uda.testuda2.type=numeric
|
|
||||||
uda.testuda2.label=TESTUDA2
|
|
||||||
|
|
||||||
uda.testuda3.type=date
|
|
||||||
uda.testuda3.label=Ttttuda
|
|
||||||
|
|
||||||
uda.testuda4.type=duration
|
|
||||||
uda.testuda4.label=TtttudaDURUD
|
|
||||||
|
|
||||||
report.next.columns=id,testuda,start.age,entry.age,depends,priority,project,tags,recur,scheduled.countdown,due.relative,until.remaining,description,urgency
|
|
||||||
report.next.context=1
|
|
||||||
report.next.description=Most urgent tasks
|
|
||||||
report.next.filter=status:pending -WAITING
|
|
||||||
report.next.labels=ID,UDA,Active,Age,Deps,P,Project,Tag,Recur,S,Due,Until,Description,Urg
|
|
||||||
report.next.sort=urgency-
|
|
||||||
uda.tasksquire.use_details=true
|
|
||||||
Reference in New Issue
Block a user