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