Skip to content

Commit 2f2017a

Browse files
feat(ui): add strike animation for task completion
- Implement progressive strikethrough animation - Add `ToggleStrike` keybinding for task completion - Update task status after animation completes
1 parent 4700e34 commit 2f2017a

6 files changed

Lines changed: 220 additions & 7 deletions

File tree

internal/app/model.go

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ type Model struct {
9292

9393
plugHost *plugins.Host
9494
plugCh chan struct{}
95+
96+
// Animation state for strike action
97+
animatingTaskID string
98+
animationStarted time.Time
99+
animationDuration time.Duration
100+
animationReverse bool // true if uncompleting (reverse strike), false if completing
95101
}
96102

97103
func New(ctx context.Context, cfg config.Config, repo *storage.Repository) (tea.Model, error) {
@@ -322,6 +328,31 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
322328
case taskDeletedMsg:
323329
return m, tea.Batch(m.loadTagsCmd(), m.loadTasksCmd(), m.loadAllTasksCmd(), m.syncIfEnabledCmd())
324330

331+
case strikeAnimationTickMsg:
332+
if m.animatingTaskID != x.TaskID {
333+
return m, nil
334+
}
335+
elapsed := time.Since(m.animationStarted)
336+
if elapsed >= m.animationDuration {
337+
// Animation complete, update the task
338+
m.animatingTaskID = ""
339+
var taskToUpdate core.Task
340+
for _, t := range m.all {
341+
if t.ID == x.TaskID {
342+
taskToUpdate = t
343+
break
344+
}
345+
}
346+
newStatus := core.StatusDone
347+
if taskToUpdate.Status == core.StatusDone {
348+
newStatus = core.StatusTodo
349+
}
350+
patch := core.TaskPatch{Status: &newStatus}
351+
return m, m.updateTaskCmd(x.TaskID, patch)
352+
}
353+
// Continue animation
354+
return m, m.strikeAnimationTickCmd(x.TaskID)
355+
325356
case openTaskMsg:
326357
m.det.SetTask(x.Task)
327358
m.mode = ModeDetail
@@ -463,6 +494,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
463494
if t, ok := m.list.Selected(); ok {
464495
return m, m.fetchOpenTaskCmd(t.ID)
465496
}
497+
case keymapMatch(m.km.ToggleStrike, km):
498+
if t, ok := m.list.Selected(); ok {
499+
m.animatingTaskID = t.ID
500+
m.animationStarted = time.Now()
501+
m.animationDuration = 400 * time.Millisecond
502+
m.animationReverse = (t.Status == core.StatusDone)
503+
return m, m.strikeAnimationTickCmd(t.ID)
504+
}
466505
}
467506
}
468507

@@ -474,6 +513,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
474513
if keymapMatch(m.km.EditTask, km) {
475514
return m, m.fetchOpenEditCmd(m.det.Task().ID)
476515
}
516+
if keymapMatch(m.km.ToggleStrike, km) {
517+
t := m.det.Task()
518+
m.animatingTaskID = t.ID
519+
m.animationStarted = time.Now()
520+
m.animationDuration = 400 * time.Millisecond
521+
m.animationReverse = (t.Status == core.StatusDone)
522+
return m, m.strikeAnimationTickCmd(t.ID)
523+
}
477524
}
478525

479526
if m.mode == ModePluginUninstall {
@@ -582,6 +629,11 @@ func (m *Model) renderMainUI() string {
582629
availableHeight = 0
583630
}
584631

632+
// Sync animation state to tasklist
633+
if m.animatingTaskID != "" {
634+
m.list.SetAnimation(m.animatingTaskID, m.animationStarted, m.animationDuration, m.animationReverse)
635+
}
636+
585637
var body string
586638
switch m.mode {
587639
case ModeList, ModeConfirmDelete:
@@ -723,7 +775,7 @@ func (m *Model) renderFooter() string {
723775
left = " " + m.s.Muted.Render(
724776
fk(m.km.Palette)+" "+styles.IconPalette+" • "+
725777
fk(m.km.NewTask)+" "+styles.IconNew+" • "+
726-
"g "+styles.IconSync+" • "+
778+
fk(m.km.ToggleStrike)+" "+styles.IconStrike+" • "+
727779
fk(m.km.DeleteTask)+" "+styles.IconDelete+" • "+
728780
fk(m.km.Help)+" "+styles.IconHelp+" • "+
729781
fk(m.km.ViewInbox)+"-"+fk(m.km.ViewPriority)+" "+styles.IconView+" ",
@@ -859,6 +911,13 @@ func (m *Model) deleteTaskCmd(id string) tea.Cmd {
859911
}
860912
}
861913

914+
func (m *Model) strikeAnimationTickCmd(taskID string) tea.Cmd {
915+
return func() tea.Msg {
916+
time.Sleep(16 * time.Millisecond) // ~60 FPS
917+
return strikeAnimationTickMsg{TaskID: taskID}
918+
}
919+
}
920+
862921
func (m *Model) fetchOpenTaskCmd(id string) tea.Cmd {
863922
return func() tea.Msg {
864923
t, err := m.repo.GetTask(m.ctx, id)

internal/app/msg.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ type openEditMsg struct{ Task core.Task }
1818
type pluginChangedMsg struct{}
1919

2020
type syncDoneMsg struct{ Err error }
21+
22+
type strikeAnimationTickMsg struct{ TaskID string }

internal/config/config.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,66 @@ func Load() (Config, error) {
168168
return cfg, err
169169
}
170170

171+
// Merge defaults for empty keybindings
172+
defaults := Default()
173+
if cfg.Keymap.Palette == "" {
174+
cfg.Keymap.Palette = defaults.Keymap.Palette
175+
}
176+
if cfg.Keymap.TaskSearch == "" {
177+
cfg.Keymap.TaskSearch = defaults.Keymap.TaskSearch
178+
}
179+
if cfg.Keymap.NewTask == "" {
180+
cfg.Keymap.NewTask = defaults.Keymap.NewTask
181+
}
182+
if cfg.Keymap.EditTask == "" {
183+
cfg.Keymap.EditTask = defaults.Keymap.EditTask
184+
}
185+
if cfg.Keymap.DeleteTask == "" {
186+
cfg.Keymap.DeleteTask = defaults.Keymap.DeleteTask
187+
}
188+
if cfg.Keymap.OpenTask == "" {
189+
cfg.Keymap.OpenTask = defaults.Keymap.OpenTask
190+
}
191+
if cfg.Keymap.Back == "" {
192+
cfg.Keymap.Back = defaults.Keymap.Back
193+
}
194+
if cfg.Keymap.Quit == "" {
195+
cfg.Keymap.Quit = defaults.Keymap.Quit
196+
}
197+
if cfg.Keymap.ViewInbox == "" {
198+
cfg.Keymap.ViewInbox = defaults.Keymap.ViewInbox
199+
}
200+
if cfg.Keymap.ViewToday == "" {
201+
cfg.Keymap.ViewToday = defaults.Keymap.ViewToday
202+
}
203+
if cfg.Keymap.ViewUpcoming == "" {
204+
cfg.Keymap.ViewUpcoming = defaults.Keymap.ViewUpcoming
205+
}
206+
if cfg.Keymap.ViewCompleted == "" {
207+
cfg.Keymap.ViewCompleted = defaults.Keymap.ViewCompleted
208+
}
209+
if cfg.Keymap.ViewTag == "" {
210+
cfg.Keymap.ViewTag = defaults.Keymap.ViewTag
211+
}
212+
if cfg.Keymap.ViewPriority == "" {
213+
cfg.Keymap.ViewPriority = defaults.Keymap.ViewPriority
214+
}
215+
if cfg.Keymap.CycleTheme == "" {
216+
cfg.Keymap.CycleTheme = defaults.Keymap.CycleTheme
217+
}
218+
if cfg.Keymap.OpenPluginDir == "" {
219+
cfg.Keymap.OpenPluginDir = defaults.Keymap.OpenPluginDir
220+
}
221+
if cfg.Keymap.ManagePlugins == "" {
222+
cfg.Keymap.ManagePlugins = defaults.Keymap.ManagePlugins
223+
}
224+
if cfg.Keymap.ToggleStrike == "" {
225+
cfg.Keymap.ToggleStrike = defaults.Keymap.ToggleStrike
226+
}
227+
if cfg.Keymap.Help == "" {
228+
cfg.Keymap.Help = defaults.Keymap.Help
229+
}
230+
171231
appDir, _ := util.AppDataDir(appName)
172232

173233
// Helper to resolve relative to app data dir

internal/ui/help/model.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func (m Model) View() string {
7676
[]struct{ key, desc string }{
7777
{getK(m.km.NewTask), "New task"},
7878
{getK(m.km.EditTask), "Edit task"},
79+
{getK(m.km.ToggleStrike), "Toggle completion with animation"},
7980
{getK(m.km.DeleteTask), "Delete task"},
8081
},
8182
},

internal/ui/styles/styles.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ const (
2525
IconSync = "↻ "
2626
IconError = "✖ "
2727
IconInfo = "ℹ "
28-
IconHelp = "? "
28+
IconHelp = "help"
2929
IconTask = "❖ "
3030
IconPlugin = "🧩 "
3131
// Additional safe icons for UI affordances
32-
IconPalette = "🔎 "
33-
IconNew = "✚ "
34-
IconDelete = "🗑 "
35-
IconView = "▣ "
32+
IconPalette = "🔎"
33+
IconNew = "✚"
34+
IconDelete = "🗑"
35+
IconView = "▣"
36+
IconStrike = "⚡"
3637
)
3738

3839
// Design System Constants

internal/ui/tasklist/model.go

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ type Model struct {
2121

2222
tasks []core.Task
2323
sel int
24+
25+
// Animation state
26+
animatingTaskID string
27+
animationStart time.Time
28+
animationDur time.Duration
29+
animationReverse bool
2430
}
2531

2632
func New(s styles.Styles, vimMode bool) Model {
@@ -48,6 +54,13 @@ func (m *Model) SetTasks(ts []core.Task) {
4854
}
4955
}
5056

57+
func (m *Model) SetAnimation(taskID string, start time.Time, duration time.Duration, reverse bool) {
58+
m.animatingTaskID = taskID
59+
m.animationStart = start
60+
m.animationDur = duration
61+
m.animationReverse = reverse
62+
}
63+
5164
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
5265
switch x := msg.(type) {
5366
case tea.KeyMsg:
@@ -158,6 +171,18 @@ func (m Model) renderEmpty() string {
158171
}
159172

160173
func (m Model) renderRow(t core.Task, selected bool) string {
174+
// Check if this task is being animated
175+
isAnimating := m.animatingTaskID == t.ID && m.animatingTaskID != ""
176+
animProgress := 0.0
177+
if isAnimating {
178+
elapsed := time.Since(m.animationStart)
179+
if elapsed < m.animationDur {
180+
animProgress = float64(elapsed) / float64(m.animationDur)
181+
} else {
182+
animProgress = 1.0
183+
}
184+
}
185+
161186
// Status icon with specific color
162187
statusIcon := styles.IconTodo
163188
statusStyle := m.styles.Muted
@@ -184,7 +209,14 @@ func (m Model) renderRow(t core.Task, selected bool) string {
184209
}
185210

186211
titleText := t.Title
187-
title := titleStyle.Render(truncate(titleText, max(20, m.width-40)))
212+
var title string
213+
if isAnimating {
214+
// Render progressive strikethrough across the text
215+
displayTitle := m.renderProgressiveStrikethrough(titleText, animProgress)
216+
title = m.styles.RowDimmed.Render(truncate(displayTitle, max(20, m.width-40)))
217+
} else {
218+
title = titleStyle.Render(truncate(titleText, max(20, m.width-40)))
219+
}
188220

189221
// Build left side
190222
left := indicator + statusStyle.Render(statusIcon) + " " + title
@@ -237,6 +269,64 @@ func (m Model) renderRow(t core.Task, selected bool) string {
237269
return rowStyle.Render(line)
238270
}
239271

272+
func (m Model) renderProgressBar(progress float64, width int) string {
273+
if progress < 0 {
274+
progress = 0
275+
}
276+
if progress > 1 {
277+
progress = 1
278+
}
279+
filled := int(float64(width) * progress)
280+
bar := strings.Repeat("▰", filled) + strings.Repeat("▱", width-filled)
281+
return m.styles.Muted.Foreground(m.styles.Theme.Accent).Render("[" + bar + "]")
282+
}
283+
284+
func (m Model) renderProgressiveStrikethrough(text string, progress float64) string {
285+
if progress < 0 {
286+
progress = 0
287+
}
288+
if progress > 1 {
289+
progress = 1
290+
}
291+
292+
runes := []rune(text)
293+
294+
if m.animationReverse {
295+
// Reverse animation: start fully struckthrough, remove from left to right
296+
normalCount := int(float64(len(runes)) * progress)
297+
298+
if normalCount == 0 {
299+
// All struckthrough
300+
return m.styles.RowDimmed.Strikethrough(true).Render(text)
301+
}
302+
if normalCount >= len(runes) {
303+
// All normal
304+
return text
305+
}
306+
307+
// Split: normal part on left, struckthrough on right
308+
normal := m.styles.RowDimmed.Render(string(runes[:normalCount]))
309+
strikethrough := m.styles.RowDimmed.Strikethrough(true).Render(string(runes[normalCount:]))
310+
return normal + strikethrough
311+
} else {
312+
// Forward animation: start normal, apply strikethrough from left to right
313+
strikeCount := int(float64(len(runes)) * progress)
314+
315+
if strikeCount == 0 {
316+
return text
317+
}
318+
if strikeCount >= len(runes) {
319+
// All struckthrough
320+
return m.styles.RowDimmed.Strikethrough(true).Render(text)
321+
}
322+
323+
// Split: struckthrough on left, normal on right
324+
strikethrough := m.styles.RowDimmed.Strikethrough(true).Render(string(runes[:strikeCount]))
325+
normal := m.styles.RowDimmed.Render(string(runes[strikeCount:]))
326+
return strikethrough + normal
327+
}
328+
}
329+
240330
func truncate(s string, w int) string {
241331
if w <= 0 {
242332
return ""

0 commit comments

Comments
 (0)