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